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