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