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