Skip to main content

retroglyph_window/winit/
run.rs

1//! The winit event loop and the windowed app drivers.
2//!
3//! [`run_windowed`] drives a raw `FnMut(&mut Terminal<..>)` closure;
4//! [`run_app`] drives an [`App`](retroglyph_core::App). This is the inverted
5//! driver: winit owns the loop and calls back into the app on each redraw,
6//! so it cannot be core's generic
7//! [`run_blocking`](retroglyph_core::run_blocking), which owns its own
8//! `while` loop.
9
10use super::translate::{
11    physical_pos_from, pixel_to_cell, translate_key, translate_modifiers, translate_mouse_button,
12};
13use crate::backend::WindowBackend;
14use crate::presenter::Presenter;
15use retroglyph_core::Terminal;
16use retroglyph_core::backend::Backend;
17use retroglyph_core::event::{Event, KeyModifiers, MouseEvent, MouseEventKind, PhysicalPos};
18use std::sync::Arc;
19#[cfg(not(target_arch = "wasm32"))]
20use std::time::Duration;
21use winit::application::ApplicationHandler;
22use winit::event::WindowEvent;
23use winit::event_loop::{ActiveEventLoop, EventLoop};
24use winit::window::{Window, WindowId};
25
26/// Window configuration for [`run_windowed`] / [`run_app`].
27///
28/// Deliberately renderer-agnostic: pixel dimensions, not grid/font/scale.
29/// Use [`fit`](Self::fit) to derive the pixel size from a presenter's own
30/// cell geometry.
31pub struct WindowConfig {
32    /// Window title.
33    pub title: String,
34    /// Initial inner width in physical pixels.
35    pub width: u32,
36    /// Initial inner height in physical pixels.
37    pub height: u32,
38    /// Optional frame-rate cap. `None` = uncapped (native) / display refresh
39    /// (wasm, which is always rAF-driven).
40    pub target_fps: Option<u32>,
41    /// On `wasm32`, size (and keep resizing) the canvas to fill the browser
42    /// viewport instead of `width`/`height` -- a full-screen, mobile-web-app
43    /// feel for games that want it. Has no effect on native, where the OS
44    /// window is already sized to `width`/`height` and the window manager
45    /// owns further resizing either way.
46    ///
47    /// Defaults to `false` in [`fit`](Self::fit): most demos/examples
48    /// should render at their natural grid size (`cols x cell_w` by `rows x
49    /// cell_h`) wherever they land on the page, not stretch to fill
50    /// whatever viewport happens to be hosting them. Opt in explicitly for
51    /// an app-like, full-screen game.
52    pub fill_viewport: bool,
53}
54
55impl WindowConfig {
56    /// Size the window to exactly fit `presenter`'s grid:
57    /// `cols x cell_w` by `rows x cell_h` physical pixels.
58    ///
59    /// This is why renderer crates don't need their own windowing code: the
60    /// grid/cell geometry already lives behind [`Presenter::size`] and
61    /// [`Presenter::cell_size`].
62    #[must_use]
63    pub fn fit<P: Presenter>(
64        presenter: &P,
65        title: impl Into<String>,
66        target_fps: Option<u32>,
67    ) -> Self {
68        let grid = presenter.size();
69        let (cell_w, cell_h) = presenter.cell_size();
70        Self {
71            title: title.into(),
72            width: u32::from(grid.width) * cell_w,
73            height: u32::from(grid.height) * cell_h,
74            target_fps,
75            fill_viewport: false,
76        }
77    }
78}
79
80/// Open a window and drive `app_loop` from the winit event loop.
81///
82/// On native this blocks the calling thread until the loop exits; on wasm it
83/// returns immediately and the loop continues on `requestAnimationFrame`.
84///
85/// The closure receives `&mut Terminal<WindowBackend<P>>` and is called on
86/// every frame tick. Window close pushes [`Event::Close`] into the event
87/// queue rather than exiting: the game decides when to terminate.
88///
89/// # Errors
90///
91/// Returns [`winit::error::EventLoopError`] if the event loop cannot be
92/// created or fails while running.
93pub fn run_windowed<P, F>(
94    config: WindowConfig,
95    presenter: P,
96    app_loop: F,
97) -> Result<(), winit::error::EventLoopError>
98where
99    P: Presenter + 'static,
100    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
101{
102    let terminal = Terminal::new(WindowBackend::new(presenter));
103    let event_loop = EventLoop::new()?;
104
105    #[cfg(not(target_arch = "wasm32"))]
106    let frame_interval = config
107        .target_fps
108        .map(|fps| Duration::from_secs_f64(1.0 / f64::from(fps)));
109
110    let app = WindowApp {
111        terminal: Some(terminal),
112        app_loop,
113        window: None,
114        title: config.title,
115        init_size: InitWindowSize {
116            width: config.width,
117            height: config.height,
118        },
119        #[cfg(target_arch = "wasm32")]
120        fill_viewport: config.fill_viewport,
121        current_modifiers: KeyModifiers::NONE,
122        cursor_px: (0.0, 0.0),
123        active_touch: None,
124        #[cfg(not(target_arch = "wasm32"))]
125        frame_interval,
126        #[cfg(not(target_arch = "wasm32"))]
127        next_frame: std::time::Instant::now(),
128    };
129
130    #[cfg(not(target_arch = "wasm32"))]
131    {
132        let mut app = app;
133        event_loop.run_app(&mut app)
134    }
135
136    #[cfg(target_arch = "wasm32")]
137    {
138        use winit::platform::web::EventLoopExtWebSys;
139        event_loop.spawn_app(app);
140        Ok(())
141    }
142}
143
144/// Drive an [`App`](retroglyph_core::App) from the windowed event loop.
145///
146/// This is the inverted driver: winit owns the event loop and calls back
147/// into the app on each redraw, rather than the app owning a `while` loop.
148///
149/// Each frame builds a [`Frame`](retroglyph_core::Frame) with a wall-clock
150/// `dt` measured via [`web_time::Instant`] -- a plain [`std::time::Instant`]
151/// re-export on native, backed by the browser's `Performance.now()` on
152/// `wasm32` (where `std::time::Instant` itself is unavailable). Calls
153/// [`step`](retroglyph_core::step).
154///
155/// On [`Flow::Exit`](retroglyph_core::Flow) the process exits on native (the
156/// window is torn down); on wasm the requestAnimationFrame loop cannot be
157/// stopped, so exit is a no-op.
158///
159/// # Errors
160///
161/// Returns [`winit::error::EventLoopError`] if the event loop cannot be
162/// created or fails while running.
163pub fn run_app<P, A>(
164    config: WindowConfig,
165    presenter: P,
166    mut app: A,
167) -> Result<(), winit::error::EventLoopError>
168where
169    P: Presenter + 'static,
170    A: retroglyph_core::App<WindowBackend<P>> + 'static,
171{
172    let mut frame_count = 0u64;
173    let mut last = web_time::Instant::now();
174    run_windowed(config, presenter, move |term| {
175        let now = web_time::Instant::now();
176        let delta = now.duration_since(last);
177        last = now;
178        let frame = retroglyph_core::Frame {
179            delta,
180            frame: frame_count,
181        };
182        frame_count = frame_count.wrapping_add(1);
183        if retroglyph_core::step(term, &mut app, &frame) == retroglyph_core::Flow::Exit {
184            #[cfg(not(target_arch = "wasm32"))]
185            std::process::exit(0);
186        }
187    })
188}
189
190/// Initial window dimensions used before the first Resized event.
191struct InitWindowSize {
192    width: u32,
193    height: u32,
194}
195
196/// The winit `ApplicationHandler`: owns the window, the terminal, and the
197/// per-frame closure.
198struct WindowApp<P: Presenter, F> {
199    terminal: Option<Terminal<WindowBackend<P>>>,
200    app_loop: F,
201    window: Option<Arc<Window>>,
202    title: String,
203    init_size: InitWindowSize,
204    /// See [`WindowConfig::fill_viewport`]. Only meaningful on `wasm32`; not
205    /// even stored on native, where it would do nothing.
206    #[cfg(target_arch = "wasm32")]
207    fill_viewport: bool,
208    /// Current modifier key state, updated by `ModifiersChanged` events.
209    current_modifiers: KeyModifiers,
210    /// Last known cursor position in physical pixels.
211    cursor_px: (f64, f64),
212    /// The finger currently treated as the pointer, if any.
213    ///
214    /// Touch input (mobile browsers, touchscreens) arrives as
215    /// [`WindowEvent::Touch`], not as `CursorMoved`/`MouseInput`. The first
216    /// finger down is adopted as "the pointer" and synthesized into the same
217    /// left-button mouse events games already handle; other fingers are
218    /// ignored until it lifts, so a stray second finger can't teleport the
219    /// cursor mid-drag.
220    active_touch: Option<u64>,
221    /// Optional frame interval for `WaitUntil` throttling. `None` = unbounded.
222    #[cfg(not(target_arch = "wasm32"))]
223    frame_interval: Option<Duration>,
224    /// Deadline for the next frame when `frame_interval` is set.
225    #[cfg(not(target_arch = "wasm32"))]
226    next_frame: std::time::Instant,
227}
228
229impl<P: Presenter, F> WindowApp<P, F> {
230    /// Create the window and initialize the surface.
231    ///
232    /// Returns `Some(window)` on success, logs and returns `None` on failure.
233    fn create_window_and_surface(&mut self, event_loop: &ActiveEventLoop) -> Option<Arc<Window>> {
234        // On native, size the window to fit the grid (`WindowConfig::fit`)
235        // and let the OS window manager own further resizing. On wasm, if
236        // `fill_viewport` is set, there's no OS window to fit into -- the
237        // canvas *is* the page -- so size it to the browser viewport
238        // instead, for a full-screen, mobile-web-app feel; otherwise it's
239        // sized the same as native (`init_size`, the natural grid size),
240        // which is what most demos/examples want -- see
241        // `WindowConfig::fill_viewport`'s doc comment. winit sets an inline
242        // `width`/`height` style on the canvas matching whatever size we
243        // request here; it does not derive that size from page CSS, so this
244        // has to happen in Rust.
245        //
246        // Crucially, the viewport-filling size *must* be the viewport size
247        // at the real (uncapped) device pixel ratio, not the DPR-capped size
248        // used for the software backing store below. winit's wasm backend
249        // converts whatever `PhysicalSize` we pass here back to a logical
250        // (CSS pixel) size using `window.devicePixelRatio()` -- the actual,
251        // uncapped ratio -- to set the canvas's inline `style.width`/
252        // `style.height`. Handing it a DPR-capped physical size makes it
253        // divide by a *larger* real DPR than the one used to compute that
254        // size, so the resulting CSS size comes out smaller than the
255        // viewport (the higher the real DPR above the cap, the more the
256        // canvas visibly shrinks -- on a phone with DPR 3 and our 1.5 cap,
257        // that's 50% of the screen). See `web_viewport_surface_physical_size`
258        // for the separate, capped size used for the raster backing store.
259        #[cfg(not(target_arch = "wasm32"))]
260        let physical_size =
261            winit::dpi::PhysicalSize::new(self.init_size.width, self.init_size.height);
262        #[cfg(target_arch = "wasm32")]
263        let physical_size = if self.fill_viewport {
264            web_viewport_layout_physical_size().unwrap_or_else(|| {
265                winit::dpi::PhysicalSize::new(self.init_size.width, self.init_size.height)
266            })
267        } else {
268            winit::dpi::PhysicalSize::new(self.init_size.width, self.init_size.height)
269        };
270        #[cfg(target_arch = "wasm32")]
271        let surface_physical_size = if self.fill_viewport {
272            web_viewport_surface_physical_size().unwrap_or(physical_size)
273        } else {
274            physical_size
275        };
276        #[cfg(not(target_arch = "wasm32"))]
277        let surface_physical_size = physical_size;
278
279        let attrs = Window::default_attributes()
280            .with_title(&self.title)
281            .with_inner_size(physical_size);
282
283        #[cfg(target_family = "wasm")]
284        let attrs = {
285            use winit::platform::web::WindowAttributesExtWebSys;
286            attrs.with_append(true)
287        };
288
289        let window = Arc::new(match event_loop.create_window(attrs) {
290            Ok(w) => w,
291            Err(e) => {
292                log::error!("window creation failed: {e}");
293                event_loop.exit();
294                return None;
295            }
296        });
297
298        if let Some(term) = self.terminal.as_mut() {
299            // Hand the presenter a windowing-library-agnostic handle (see
300            // `Presenter::init_surface`); the winit window stays owned here.
301            let handle: Arc<dyn crate::presenter::WindowHandle> = window.clone();
302            if let Err(e) = term.backend_mut().presenter_mut().init_surface(handle) {
303                log::error!("surface init failed: {e}");
304                event_loop.exit();
305                return None;
306            }
307            // Set the initial surface size (required on WASM before first present).
308            // Deliberately `surface_physical_size`, not `physical_size`: the
309            // raster backing store stays DPR-capped for present() cost even
310            // though the canvas's CSS size (driven by `physical_size` via
311            // winit above) matches the full, uncapped viewport.
312            term.backend_mut()
313                .presenter_mut()
314                .resize_surface(surface_physical_size.width, surface_physical_size.height);
315        }
316
317        // Keep the canvas matching the browser viewport as it changes
318        // (device rotation, browser window resize, address-bar
319        // show/hide): winit only reacts to size changes we ask for
320        // ourselves (`request_inner_size`), so a `resize` listener is
321        // required to make this genuinely responsive rather than a
322        // one-shot fit at startup. Only installed when `fill_viewport` is
323        // set -- otherwise the canvas should stay at its natural grid size
324        // regardless of viewport changes.
325        #[cfg(target_arch = "wasm32")]
326        if self.fill_viewport {
327            install_viewport_resize_listener(&window);
328        }
329
330        // `WindowEvent::ThemeChanged` (handled in `handle_window_event`)
331        // only fires on a *change*, so an app that never sees a system
332        // theme change would otherwise never learn the starting one.
333        // `Window::theme()` reflects the current system theme both on
334        // native and on winit's web target (backed by the
335        // `prefers-color-scheme` media query there), so query it once
336        // up-front and synthesize the same event a live change would send.
337        if let Some(theme) = window.theme()
338            && let Some(term) = self.terminal.as_mut()
339        {
340            term.backend_mut().push_event(system_theme_event(theme));
341        }
342
343        Some(window)
344    }
345}
346
347/// Maps winit's [`Theme`](winit::window::Theme) to the backend-agnostic
348/// [`Event::ThemeChanged`], the only place that conversion needs to happen.
349const fn system_theme_event(theme: winit::window::Theme) -> Event {
350    use retroglyph_core::event::SystemTheme;
351    match theme {
352        winit::window::Theme::Light => Event::ThemeChanged(SystemTheme::Light),
353        winit::window::Theme::Dark => Event::ThemeChanged(SystemTheme::Dark),
354    }
355}
356
357/// Upper bound on the device pixel ratio used to size the canvas backing
358/// store. Present cost is O(pixels), so an uncapped DPR (3 on many phones,
359/// 2 on most laptops) quadruples or worse the per-frame rasterize/present
360/// work for marginal crispness on a pseudo-graphic UI.
361#[cfg(target_arch = "wasm32")]
362const MAX_DEVICE_PIXEL_RATIO: f64 = 1.5;
363
364/// The browser viewport's CSS width/height, or `None` if running outside a
365/// browser `window` context. Shared by the two physical-size helpers below.
366#[cfg(target_arch = "wasm32")]
367fn web_viewport_css_size() -> Option<(f64, f64)> {
368    let window = web_sys::window()?;
369    let width = window.inner_width().ok()?.as_f64()?;
370    let height = window.inner_height().ok()?.as_f64()?;
371    Some((width, height))
372}
373
374/// The browser viewport size in true physical (device) pixels -- i.e. at the
375/// real, uncapped `devicePixelRatio`.
376///
377/// Pass this to winit's `with_inner_size`/`request_inner_size` (and *only*
378/// this -- never [`web_viewport_surface_physical_size`]). winit's wasm
379/// backend always converts the `PhysicalSize` it's given back to a logical
380/// (CSS pixel) size by dividing by the real `devicePixelRatio` to set the
381/// canvas's inline style; handing it anything scaled by a different ratio
382/// (like our DPR-capped surface size) makes the canvas's CSS size come out
383/// smaller than the viewport.
384#[cfg(target_arch = "wasm32")]
385#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
386fn web_viewport_layout_physical_size() -> Option<winit::dpi::PhysicalSize<u32>> {
387    let (width, height) = web_viewport_css_size()?;
388    let dpr = web_sys::window()?.device_pixel_ratio();
389    Some(winit::dpi::PhysicalSize::new(
390        (width * dpr).round() as u32,
391        (height * dpr).round() as u32,
392    ))
393}
394
395/// Ratio to convert a pointer position winit reports (always in *real*,
396/// uncapped-DPR physical pixels -- see `to_physical(super::scale_factor)` in
397/// `winit`'s wasm `pointer.rs`) into the raster-backing-store pixel space
398/// that [`Presenter::cell_size`](crate::presenter::Presenter::cell_size),
399/// and therefore [`pixel_to_cell`], are expressed in.
400///
401/// `1.0` whenever `real_dpr` is already at or below `capped_dpr` (desktop,
402/// non-Retina): no correction needed. Below that, taps/clicks land scaled
403/// past their true position -- south-east of the intended cell, growing
404/// with how far `real_dpr` exceeds the cap (2x at DPR 3 against a 1.5 cap).
405/// Pure math, kept separate from [`wasm_pointer_scale`] so it's unit
406/// -testable without a wasm window (hence `cfg(any(.., test))`: unused on a
407/// native non-test build, whose `on_cursor_moved` hardcodes `scale = 1.0`
408/// instead of calling this).
409#[cfg(any(target_arch = "wasm32", test))]
410fn dpr_pointer_scale(real_dpr: f64, capped_dpr: f64) -> f64 {
411    (capped_dpr / real_dpr).min(1.0)
412}
413
414/// [`dpr_pointer_scale`] using the page's actual `devicePixelRatio` and
415/// [`MAX_DEVICE_PIXEL_RATIO`]. `1.0` if no browser `window` is available.
416#[cfg(target_arch = "wasm32")]
417fn wasm_pointer_scale() -> f64 {
418    web_sys::window().map_or(1.0, |w| {
419        dpr_pointer_scale(w.device_pixel_ratio(), MAX_DEVICE_PIXEL_RATIO)
420    })
421}
422
423/// The physical pixel size of the software renderer's raster backing store,
424/// capped at [`MAX_DEVICE_PIXEL_RATIO`] for `present()` cost.
425///
426/// Deliberately *not* the size passed to winit (see
427/// [`web_viewport_layout_physical_size`]): winit's `Resized` event always
428/// reports back whatever physical size we last requested, so if this capped
429/// size were also used for `request_inner_size`, the canvas's CSS size would
430/// shrink below the viewport on any device whose real DPR exceeds the cap
431/// (i.e. almost every phone).
432#[cfg(target_arch = "wasm32")]
433#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
434fn web_viewport_surface_physical_size() -> Option<winit::dpi::PhysicalSize<u32>> {
435    let (width, height) = web_viewport_css_size()?;
436    let dpr = web_sys::window()?
437        .device_pixel_ratio()
438        .min(MAX_DEVICE_PIXEL_RATIO);
439    Some(winit::dpi::PhysicalSize::new(
440        (width * dpr).round() as u32,
441        (height * dpr).round() as u32,
442    ))
443}
444
445/// Re-requests the window's inner size to match the browser viewport on
446/// every `resize` event, so the canvas keeps filling the screen instead of
447/// staying pinned to its size at first paint.
448#[cfg(target_arch = "wasm32")]
449fn install_viewport_resize_listener(window: &Arc<Window>) {
450    use wasm_bindgen::JsCast;
451    use wasm_bindgen::prelude::Closure;
452
453    let Some(web_window) = web_sys::window() else {
454        return;
455    };
456    let window = window.clone();
457    let closure = Closure::<dyn FnMut()>::new(move || {
458        // Only the uncapped layout size goes to winit; `on_resized` (fired
459        // by the `Resized` event this triggers) independently recomputes
460        // the DPR-capped surface size for the backing store.
461        if let Some(size) = web_viewport_layout_physical_size() {
462            let _ = window.request_inner_size(size);
463        }
464    });
465    if web_window
466        .add_event_listener_with_callback("resize", closure.as_ref().unchecked_ref())
467        .is_ok()
468    {
469        // Leaked deliberately: the listener, and the closure it wraps, need
470        // to live as long as the page does -- there's no window-teardown
471        // hook on wasm to drop it from.
472        closure.forget();
473    }
474}
475
476impl<P, F> ApplicationHandler for WindowApp<P, F>
477where
478    P: Presenter,
479    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
480{
481    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
482        if let Some(window) = self.create_window_and_surface(event_loop) {
483            self.window = Some(window);
484        }
485    }
486
487    fn window_event(
488        &mut self,
489        _event_loop: &ActiveEventLoop,
490        _window_id: WindowId,
491        event: WindowEvent,
492    ) {
493        self.handle_window_event(event);
494    }
495
496    fn about_to_wait(
497        &mut self,
498        #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] event_loop: &ActiveEventLoop,
499    ) {
500        #[cfg(not(target_arch = "wasm32"))]
501        if let Some(interval) = self.frame_interval {
502            // Throttled: sleep until the next frame deadline, then render.
503            let now = std::time::Instant::now();
504            if self.next_frame > now {
505                event_loop
506                    .set_control_flow(winit::event_loop::ControlFlow::WaitUntil(self.next_frame));
507                return;
508            }
509            // Advance the deadline by one interval, clamping to now so a
510            // slow frame doesn't cause a burst of catch-up renders.
511            self.next_frame = (self.next_frame + interval).max(now);
512        }
513        if let Some(window) = &self.window {
514            window.request_redraw();
515        }
516    }
517}
518
519impl<P, F> WindowApp<P, F>
520where
521    P: Presenter,
522    F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
523{
524    /// Dispatch a [`WindowEvent`] without requiring an [`ActiveEventLoop`].
525    ///
526    /// Extracted from the `ApplicationHandler` impl so the translation and
527    /// event-buffer logic can be called directly in unit tests, where
528    /// [`ActiveEventLoop`] is not constructable.
529    fn handle_window_event(&mut self, event: WindowEvent) {
530        match event {
531            WindowEvent::CloseRequested => {
532                // Push the event so the game loop can process it (save game,
533                // confirm dialog, etc.).  Do not call event_loop.exit() here;
534                // the game decides when to terminate.
535                if let Some(term) = self.terminal.as_mut() {
536                    term.backend_mut().push_event(Event::Close);
537                }
538            }
539            WindowEvent::Resized(size) => self.on_resized(size),
540            WindowEvent::CursorMoved { position, .. } => self.on_cursor_moved(position),
541            WindowEvent::MouseInput { state, button, .. } => self.on_mouse_input(state, button),
542            WindowEvent::MouseWheel { delta, .. } => self.on_mouse_wheel(delta),
543            WindowEvent::Touch(touch) => self.on_touch(touch),
544            WindowEvent::ModifiersChanged(mods) => {
545                self.current_modifiers = translate_modifiers(mods.state());
546            }
547            WindowEvent::ThemeChanged(theme) => {
548                if let Some(term) = self.terminal.as_mut() {
549                    term.backend_mut().push_event(system_theme_event(theme));
550                }
551            }
552            WindowEvent::KeyboardInput { event, .. } => {
553                if let Some(term) = self.terminal.as_mut()
554                    && let Some(e) = translate_key(event, self.current_modifiers)
555                {
556                    term.backend_mut().push_event(e);
557                }
558            }
559
560            WindowEvent::RedrawRequested => {
561                let Some(term) = self.terminal.as_mut() else {
562                    return;
563                };
564                (self.app_loop)(term);
565                if let Err(e) = term.backend_mut().presenter_mut().present() {
566                    log::error!("frame present failed: {e}");
567                }
568            }
569
570            _ => {}
571        }
572    }
573
574    fn on_resized(&mut self, size: winit::dpi::PhysicalSize<u32>) {
575        let Some(term) = self.terminal.as_mut() else {
576            return;
577        };
578        // On wasm with `fill_viewport` set, `size` is whatever (uncapped)
579        // physical size we last handed winit for CSS layout purposes -- not
580        // the backing store size. Recompute the DPR-capped surface size
581        // independently so the raster buffer doesn't silently lose its cap
582        // on every resize. Without `fill_viewport`, the canvas never resizes
583        // on its own (no listener installed above), so `size` here is
584        // already the natural grid size and needs no such override.
585        #[cfg(target_arch = "wasm32")]
586        let size = if self.fill_viewport {
587            web_viewport_surface_physical_size().unwrap_or(size)
588        } else {
589            size
590        };
591        let (cell_w, cell_h) = term.backend().presenter().cell_size();
592        let cols = size.width / cell_w;
593        let rows = size.height / cell_h;
594        term.backend_mut()
595            .presenter_mut()
596            .resize_surface(cols * cell_w, rows * cell_h);
597        #[allow(clippy::cast_possible_truncation)]
598        term.backend_mut()
599            .push_event(Event::Resize(cols.max(1) as u16, rows.max(1) as u16));
600    }
601
602    fn on_cursor_moved(&mut self, position: winit::dpi::PhysicalPosition<f64>) {
603        // winit always reports pointer positions in real-DPR physical
604        // pixels; rescale to the (possibly DPR-capped, on wasm) backing-store
605        // pixel space that `cell_size`/`pixel_to_cell` use, so taps land on
606        // the cell actually under the finger/cursor instead of drifting
607        // south-east of it as the real DPR grows past the cap. `1.0` on
608        // native, where there's no such cap.
609        #[cfg(target_arch = "wasm32")]
610        let scale = wasm_pointer_scale();
611        #[cfg(not(target_arch = "wasm32"))]
612        let scale = 1.0;
613        let (x, y) = (position.x * scale, position.y * scale);
614        self.cursor_px = (x, y);
615        let px = physical_pos_from(x, y);
616        let Some(term) = self.terminal.as_mut() else {
617            return;
618        };
619        let (cell_w, cell_h) = term.backend().presenter().cell_size();
620        let pos = pixel_to_cell(x, y, cell_w, cell_h);
621        term.backend_mut().push_event(Event::Mouse(MouseEvent {
622            kind: MouseEventKind::Moved,
623            position: pos,
624            pixel_position: Some(px),
625            modifiers: self.current_modifiers,
626        }));
627    }
628
629    fn on_mouse_input(
630        &mut self,
631        state: winit::event::ElementState,
632        button: winit::event::MouseButton,
633    ) {
634        let Some(btn) = translate_mouse_button(button) else {
635            return;
636        };
637        let px = self.cursor_physical_pos();
638        let Some(term) = self.terminal.as_mut() else {
639            return;
640        };
641        let (cell_w, cell_h) = term.backend().presenter().cell_size();
642        let pos = pixel_to_cell(self.cursor_px.0, self.cursor_px.1, cell_w, cell_h);
643        let kind = if state.is_pressed() {
644            MouseEventKind::Down(btn)
645        } else {
646            MouseEventKind::Up(btn)
647        };
648        term.backend_mut().push_event(Event::Mouse(MouseEvent {
649            kind,
650            position: pos,
651            pixel_position: Some(px),
652            modifiers: self.current_modifiers,
653        }));
654    }
655
656    fn on_mouse_wheel(&mut self, delta: winit::event::MouseScrollDelta) {
657        let px = self.cursor_physical_pos();
658        let Some(term) = self.terminal.as_mut() else {
659            return;
660        };
661        let (cell_w, cell_h) = term.backend().presenter().cell_size();
662        let pos = pixel_to_cell(self.cursor_px.0, self.cursor_px.1, cell_w, cell_h);
663        let scroll_y = match delta {
664            winit::event::MouseScrollDelta::LineDelta(_, y) => f64::from(y),
665            winit::event::MouseScrollDelta::PixelDelta(p) => p.y,
666        };
667        let kind = if scroll_y > 0.0 {
668            MouseEventKind::ScrollUp
669        } else {
670            MouseEventKind::ScrollDown
671        };
672        term.backend_mut().push_event(Event::Mouse(MouseEvent {
673            kind,
674            position: pos,
675            pixel_position: Some(px),
676            modifiers: self.current_modifiers,
677        }));
678    }
679
680    /// Synthesize mouse events from a touch so tap/drag work out of the box.
681    ///
682    /// Mobile browsers (and native touchscreens) deliver touch input as
683    /// [`WindowEvent::Touch`], which has no `CursorMoved`/`MouseInput`
684    /// counterpart. Games shouldn't need a second input path for it, so the
685    /// first finger down becomes the pointer: its start is a `Moved` +
686    /// left-button `Down`, its motion is `Moved` (a drag), and its lift is
687    /// `Up`. Additional simultaneous fingers are ignored.
688    fn on_touch(&mut self, touch: winit::event::Touch) {
689        use winit::event::TouchPhase;
690
691        match touch.phase {
692            TouchPhase::Started => {
693                if self.active_touch.is_some() {
694                    return; // a second finger; keep tracking the first
695                }
696                self.active_touch = Some(touch.id);
697                self.on_cursor_moved(touch.location);
698                self.on_mouse_input(
699                    winit::event::ElementState::Pressed,
700                    winit::event::MouseButton::Left,
701                );
702            }
703            TouchPhase::Moved => {
704                if self.active_touch == Some(touch.id) {
705                    self.on_cursor_moved(touch.location);
706                }
707            }
708            TouchPhase::Ended | TouchPhase::Cancelled => {
709                if self.active_touch != Some(touch.id) {
710                    return;
711                }
712                self.active_touch = None;
713                self.on_cursor_moved(touch.location);
714                self.on_mouse_input(
715                    winit::event::ElementState::Released,
716                    winit::event::MouseButton::Left,
717                );
718            }
719        }
720    }
721
722    /// Convert the cached cursor pixel position to [`PhysicalPos`].
723    const fn cursor_physical_pos(&self) -> PhysicalPos {
724        physical_pos_from(self.cursor_px.0, self.cursor_px.1)
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use retroglyph_core::event::{MouseButton, MouseEvent, MouseEventKind};
732    use retroglyph_core::grid::{Pos, Size};
733    use retroglyph_core::tile::Tile;
734    use std::time::Duration;
735
736    // ── dpr_pointer_scale ─────────────────────────────────────────────────────
737
738    #[test]
739    fn dpr_pointer_scale_no_correction_below_cap() {
740        // Real DPR at or below the cap: pointer positions already match the
741        // (uncapped) backing store, no rescale needed.
742        assert!((dpr_pointer_scale(1.0, 1.5) - 1.0).abs() < 1e-9);
743        assert!((dpr_pointer_scale(1.5, 1.5) - 1.0).abs() < 1e-9);
744    }
745
746    #[test]
747    fn dpr_pointer_scale_corrects_above_cap() {
748        // Real DPR 3 against a 1.5 cap: the backing store is half the real
749        // resolution, so pointer positions must be halved to land on the
750        // right cell instead of drifting south-east of it.
751        assert!((dpr_pointer_scale(3.0, 1.5) - 0.5).abs() < 1e-9);
752        assert!((dpr_pointer_scale(2.0, 1.5) - 0.75).abs() < 1e-9);
753    }
754
755    /// A dependency-free [`Presenter`] with fixed 8x16 cells.
756    ///
757    /// The `WindowApp` tests only exercise event translation, cell math, and
758    /// the `WindowBackend` queue — no rasterization or surface is needed.
759    struct MockPresenter;
760
761    impl Presenter for MockPresenter {
762        type Error = core::convert::Infallible;
763        type SurfaceError = core::convert::Infallible;
764
765        fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
766        where
767            I: Iterator<Item = (Pos, &'a Tile)>,
768        {
769            Ok(())
770        }
771
772        fn draw_layers<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
773        where
774            I: Iterator<Item = (u8, Pos, &'a Tile)>,
775        {
776            Ok(())
777        }
778
779        fn flush(&mut self) -> Result<(), Self::Error> {
780            Ok(())
781        }
782
783        fn size(&self) -> Size {
784            Size {
785                width: 10,
786                height: 5,
787            }
788        }
789
790        fn clear(&mut self) -> Result<(), Self::Error> {
791            Ok(())
792        }
793
794        fn resize(&mut self, _size: Size) {}
795
796        fn init_surface(
797            &mut self,
798            _window: Arc<dyn crate::presenter::WindowHandle>,
799        ) -> Result<(), Self::SurfaceError> {
800            Ok(())
801        }
802
803        fn resize_surface(&mut self, _width: u32, _height: u32) {}
804
805        fn present(&mut self) -> Result<(), Self::SurfaceError> {
806            Ok(())
807        }
808
809        fn cell_size(&self) -> (u32, u32) {
810            (8, 16)
811        }
812    }
813
814    type MockApp = WindowApp<MockPresenter, fn(&mut Terminal<WindowBackend<MockPresenter>>)>;
815
816    fn test_window_app() -> MockApp {
817        let terminal = Terminal::new(WindowBackend::new(MockPresenter));
818        WindowApp {
819            terminal: Some(terminal),
820            app_loop: |_| {},
821            window: None,
822            title: String::new(),
823            init_size: InitWindowSize {
824                width: 80,
825                height: 80,
826            },
827            current_modifiers: KeyModifiers::NONE,
828            cursor_px: (0.0, 0.0),
829            active_touch: None,
830            #[cfg(not(target_arch = "wasm32"))]
831            frame_interval: None,
832            #[cfg(not(target_arch = "wasm32"))]
833            next_frame: std::time::Instant::now(),
834        }
835    }
836
837    fn poll(app: &mut MockApp) -> Option<Event> {
838        app.terminal
839            .as_mut()
840            .unwrap()
841            .backend_mut()
842            .poll_event(Duration::ZERO)
843    }
844
845    // ── WindowBackend queue ───────────────────────────────────────────────────
846
847    #[test]
848    fn mouse_event_round_trips_through_event_buffer() {
849        let mut backend = WindowBackend::new(MockPresenter);
850        let ev = Event::Mouse(MouseEvent {
851            kind: MouseEventKind::Down(MouseButton::Left),
852            position: Pos { x: 3, y: 1 },
853            pixel_position: None,
854            modifiers: KeyModifiers::NONE,
855        });
856        backend.push_event(ev);
857        assert_eq!(backend.poll_event(Duration::ZERO), Some(ev));
858        assert_eq!(backend.poll_event(Duration::ZERO), None);
859    }
860
861    #[test]
862    fn multiple_mouse_events_preserve_fifo_order() {
863        let mut backend = WindowBackend::new(MockPresenter);
864        let moved = Event::Mouse(MouseEvent {
865            kind: MouseEventKind::Moved,
866            position: Pos { x: 1, y: 2 },
867            pixel_position: None,
868            modifiers: KeyModifiers::NONE,
869        });
870        let clicked = Event::Mouse(MouseEvent {
871            kind: MouseEventKind::Down(MouseButton::Left),
872            position: Pos { x: 1, y: 2 },
873            pixel_position: None,
874            modifiers: KeyModifiers::NONE,
875        });
876        backend.push_event(moved);
877        backend.push_event(clicked);
878        assert_eq!(backend.poll_event(Duration::ZERO), Some(moved));
879        assert_eq!(backend.poll_event(Duration::ZERO), Some(clicked));
880    }
881
882    // ── handle_window_event ──────────────────────────────────────────────────
883
884    #[test]
885    fn cursor_moved_pushes_moved_event_at_correct_cell() {
886        // 8-wide × 16-tall cells; cursor at pixel (20, 32) → col 2, row 2.
887        let mut app = test_window_app();
888        app.handle_window_event(WindowEvent::CursorMoved {
889            device_id: winit::event::DeviceId::dummy(),
890            position: winit::dpi::PhysicalPosition::new(20.0_f64, 32.0_f64),
891        });
892        assert_eq!(
893            poll(&mut app),
894            Some(Event::Mouse(MouseEvent {
895                kind: MouseEventKind::Moved,
896                position: Pos { x: 2, y: 2 },
897                pixel_position: Some(PhysicalPos { x: 20, y: 32 }),
898                modifiers: KeyModifiers::NONE,
899            }))
900        );
901    }
902
903    #[test]
904    fn cursor_moved_caches_position_for_subsequent_click() {
905        // Move to pixel (16, 16) = col 2, row 1, then click — button event
906        // must reuse the cached position.
907        let mut app = test_window_app();
908        app.handle_window_event(WindowEvent::CursorMoved {
909            device_id: winit::event::DeviceId::dummy(),
910            position: winit::dpi::PhysicalPosition::new(16.0_f64, 16.0_f64),
911        });
912        let _ = poll(&mut app); // discard the Moved event
913        app.handle_window_event(WindowEvent::MouseInput {
914            device_id: winit::event::DeviceId::dummy(),
915            state: winit::event::ElementState::Pressed,
916            button: winit::event::MouseButton::Left,
917        });
918        assert_eq!(
919            poll(&mut app),
920            Some(Event::Mouse(MouseEvent {
921                kind: MouseEventKind::Down(MouseButton::Left),
922                position: Pos { x: 2, y: 1 },
923                pixel_position: Some(PhysicalPos { x: 16, y: 16 }),
924                modifiers: KeyModifiers::NONE,
925            }))
926        );
927    }
928
929    #[test]
930    fn mouse_button_release_produces_up_event() {
931        let mut app = test_window_app();
932        app.handle_window_event(WindowEvent::MouseInput {
933            device_id: winit::event::DeviceId::dummy(),
934            state: winit::event::ElementState::Released,
935            button: winit::event::MouseButton::Right,
936        });
937        assert_eq!(
938            poll(&mut app),
939            Some(Event::Mouse(MouseEvent {
940                kind: MouseEventKind::Up(MouseButton::Right),
941                position: Pos { x: 0, y: 0 },
942                pixel_position: Some(PhysicalPos { x: 0, y: 0 }),
943                modifiers: KeyModifiers::NONE,
944            }))
945        );
946    }
947
948    #[test]
949    fn unknown_mouse_button_produces_no_event() {
950        let mut app = test_window_app();
951        app.handle_window_event(WindowEvent::MouseInput {
952            device_id: winit::event::DeviceId::dummy(),
953            state: winit::event::ElementState::Pressed,
954            button: winit::event::MouseButton::Other(99),
955        });
956        assert_eq!(poll(&mut app), None);
957    }
958
959    fn touch(id: u64, phase: winit::event::TouchPhase, x: f64, y: f64) -> WindowEvent {
960        WindowEvent::Touch(winit::event::Touch {
961            device_id: winit::event::DeviceId::dummy(),
962            phase,
963            location: winit::dpi::PhysicalPosition::new(x, y),
964            force: None,
965            id,
966        })
967    }
968
969    #[test]
970    fn touch_tap_synthesizes_left_click() {
971        use winit::event::TouchPhase;
972        let mut app = test_window_app();
973        // MockPresenter cells are 8x16 px; a tap at (20, 18) lands on cell (2, 1).
974        app.handle_window_event(touch(7, TouchPhase::Started, 20.0, 18.0));
975        // Moved (from the synthesized cursor move) then Down.
976        assert!(matches!(
977            poll(&mut app),
978            Some(Event::Mouse(MouseEvent {
979                kind: MouseEventKind::Moved,
980                position: Pos { x: 2, y: 1 },
981                ..
982            }))
983        ));
984        assert!(matches!(
985            poll(&mut app),
986            Some(Event::Mouse(MouseEvent {
987                kind: MouseEventKind::Down(MouseButton::Left),
988                position: Pos { x: 2, y: 1 },
989                ..
990            }))
991        ));
992
993        app.handle_window_event(touch(7, TouchPhase::Ended, 20.0, 18.0));
994        assert!(matches!(
995            poll(&mut app),
996            Some(Event::Mouse(MouseEvent {
997                kind: MouseEventKind::Moved,
998                ..
999            }))
1000        ));
1001        assert!(matches!(
1002            poll(&mut app),
1003            Some(Event::Mouse(MouseEvent {
1004                kind: MouseEventKind::Up(MouseButton::Left),
1005                position: Pos { x: 2, y: 1 },
1006                ..
1007            }))
1008        ));
1009        assert_eq!(poll(&mut app), None);
1010    }
1011
1012    #[test]
1013    fn touch_drag_synthesizes_moves_between_down_and_up() {
1014        use winit::event::TouchPhase;
1015        let mut app = test_window_app();
1016        app.handle_window_event(touch(1, TouchPhase::Started, 0.0, 0.0));
1017        poll(&mut app); // Moved
1018        poll(&mut app); // Down
1019
1020        app.handle_window_event(touch(1, TouchPhase::Moved, 40.0, 32.0));
1021        assert!(matches!(
1022            poll(&mut app),
1023            Some(Event::Mouse(MouseEvent {
1024                kind: MouseEventKind::Moved,
1025                position: Pos { x: 5, y: 2 },
1026                ..
1027            }))
1028        ));
1029
1030        app.handle_window_event(touch(1, TouchPhase::Cancelled, 40.0, 32.0));
1031        poll(&mut app); // Moved
1032        assert!(matches!(
1033            poll(&mut app),
1034            Some(Event::Mouse(MouseEvent {
1035                kind: MouseEventKind::Up(MouseButton::Left),
1036                ..
1037            }))
1038        ));
1039    }
1040
1041    #[test]
1042    fn second_finger_is_ignored_while_first_is_down() {
1043        use winit::event::TouchPhase;
1044        let mut app = test_window_app();
1045        app.handle_window_event(touch(1, TouchPhase::Started, 0.0, 0.0));
1046        poll(&mut app); // Moved
1047        poll(&mut app); // Down
1048
1049        // A second finger goes down, moves, and lifts: all ignored.
1050        app.handle_window_event(touch(2, TouchPhase::Started, 80.0, 80.0));
1051        app.handle_window_event(touch(2, TouchPhase::Moved, 88.0, 80.0));
1052        app.handle_window_event(touch(2, TouchPhase::Ended, 88.0, 80.0));
1053        assert_eq!(poll(&mut app), None);
1054
1055        // The first finger still completes its gesture.
1056        app.handle_window_event(touch(1, TouchPhase::Ended, 8.0, 0.0));
1057        poll(&mut app); // Moved
1058        assert!(matches!(
1059            poll(&mut app),
1060            Some(Event::Mouse(MouseEvent {
1061                kind: MouseEventKind::Up(MouseButton::Left),
1062                position: Pos { x: 1, y: 0 },
1063                ..
1064            }))
1065        ));
1066    }
1067
1068    #[test]
1069    fn scroll_up_line_delta() {
1070        let mut app = test_window_app();
1071        app.handle_window_event(WindowEvent::MouseWheel {
1072            device_id: winit::event::DeviceId::dummy(),
1073            delta: winit::event::MouseScrollDelta::LineDelta(0.0, 1.0),
1074            phase: winit::event::TouchPhase::Moved,
1075        });
1076        let ev = poll(&mut app).unwrap();
1077        assert!(matches!(
1078            ev,
1079            Event::Mouse(MouseEvent {
1080                kind: MouseEventKind::ScrollUp,
1081                ..
1082            })
1083        ));
1084    }
1085
1086    #[test]
1087    fn scroll_down_line_delta() {
1088        let mut app = test_window_app();
1089        app.handle_window_event(WindowEvent::MouseWheel {
1090            device_id: winit::event::DeviceId::dummy(),
1091            delta: winit::event::MouseScrollDelta::LineDelta(0.0, -1.0),
1092            phase: winit::event::TouchPhase::Moved,
1093        });
1094        let ev = poll(&mut app).unwrap();
1095        assert!(matches!(
1096            ev,
1097            Event::Mouse(MouseEvent {
1098                kind: MouseEventKind::ScrollDown,
1099                ..
1100            })
1101        ));
1102    }
1103
1104    #[test]
1105    fn scroll_up_pixel_delta() {
1106        let mut app = test_window_app();
1107        app.handle_window_event(WindowEvent::MouseWheel {
1108            device_id: winit::event::DeviceId::dummy(),
1109            delta: winit::event::MouseScrollDelta::PixelDelta(winit::dpi::PhysicalPosition::new(
1110                0.0_f64, 15.0_f64,
1111            )),
1112            phase: winit::event::TouchPhase::Moved,
1113        });
1114        let ev = poll(&mut app).unwrap();
1115        assert!(matches!(
1116            ev,
1117            Event::Mouse(MouseEvent {
1118                kind: MouseEventKind::ScrollUp,
1119                ..
1120            })
1121        ));
1122    }
1123
1124    #[test]
1125    fn modifiers_propagate_to_mouse_event() {
1126        let mut app = test_window_app();
1127        // Simulate a ModifiersChanged before the click.
1128        app.handle_window_event(WindowEvent::ModifiersChanged(
1129            winit::event::Modifiers::from(winit::keyboard::ModifiersState::SHIFT),
1130        ));
1131        let _ = poll(&mut app); // no event emitted for modifiers
1132        app.handle_window_event(WindowEvent::MouseInput {
1133            device_id: winit::event::DeviceId::dummy(),
1134            state: winit::event::ElementState::Pressed,
1135            button: winit::event::MouseButton::Left,
1136        });
1137        let ev = poll(&mut app).unwrap();
1138        assert!(matches!(
1139            ev,
1140            Event::Mouse(MouseEvent {
1141                modifiers,
1142                ..
1143            }) if modifiers.contains(KeyModifiers::SHIFT)
1144        ));
1145    }
1146
1147    #[test]
1148    fn close_requested_pushes_close_event() {
1149        let mut app = test_window_app();
1150        app.handle_window_event(WindowEvent::CloseRequested);
1151        assert_eq!(poll(&mut app), Some(Event::Close));
1152    }
1153
1154    #[test]
1155    fn theme_changed_pushes_mapped_system_theme_event() {
1156        let mut app = test_window_app();
1157        app.handle_window_event(WindowEvent::ThemeChanged(winit::window::Theme::Light));
1158        assert_eq!(
1159            poll(&mut app),
1160            Some(Event::ThemeChanged(
1161                retroglyph_core::event::SystemTheme::Light
1162            ))
1163        );
1164
1165        app.handle_window_event(WindowEvent::ThemeChanged(winit::window::Theme::Dark));
1166        assert_eq!(
1167            poll(&mut app),
1168            Some(Event::ThemeChanged(
1169                retroglyph_core::event::SystemTheme::Dark
1170            ))
1171        );
1172    }
1173
1174    #[test]
1175    fn resized_pushes_resize_event_in_cells() {
1176        // 8x16 cells: 88x80 px -> 11 cols, 5 rows.
1177        let mut app = test_window_app();
1178        app.handle_window_event(WindowEvent::Resized(winit::dpi::PhysicalSize::new(88, 80)));
1179        assert_eq!(poll(&mut app), Some(Event::Resize(11, 5)));
1180    }
1181}