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