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