Skip to main content

pebble/wgpu/
window.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::{Arc, Mutex};
4use std::time::Duration;
5
6#[cfg(not(target_arch = "wasm32"))]
7use winit::dpi::PhysicalSize;
8use winit::{
9    event::{Event, Touch as WinitTouch, TouchPhase as WinitTouchPhase, WindowEvent},
10    event_loop::EventLoop,
11    // Aliased: this file also defines Pebble's own opaque `Window` wrapper
12    // around it, and having both named `Window` in the same file would be
13    // ambiguous.
14    window::{Fullscreen, Window as OsWindow, WindowBuilder},
15};
16use winit_input_helper::WinitInputHelper;
17
18use crate::ecs::plugin::Plugin;
19use crate::rendering::window::{PresentableWindow, WindowConfig, WindowProvider, WindowResource, WindowRunner};
20use crate::wgpu::cursor::{CursorGrabMode, CursorIcon};
21use crate::wgpu::keycode::{KeyCode, MouseButton};
22
23/// Mirrors `winit::event::TouchPhase`.
24#[derive(Copy, Clone, Debug, PartialEq, Eq)]
25pub enum TouchPhase {
26    Started,
27    Moved,
28    Ended,
29    Cancelled,
30}
31
32impl From<WinitTouchPhase> for TouchPhase {
33    fn from(value: WinitTouchPhase) -> Self {
34        match value {
35            WinitTouchPhase::Started => Self::Started,
36            WinitTouchPhase::Moved => Self::Moved,
37            WinitTouchPhase::Ended => Self::Ended,
38            WinitTouchPhase::Cancelled => Self::Cancelled,
39        }
40    }
41}
42
43/// One active finger on a touchscreen — `id` is stable for that finger's
44/// whole contact, from `Started` through `Ended`/`Cancelled`.
45#[derive(Copy, Clone, Debug)]
46pub struct TouchPoint {
47    pub id: u64,
48    pub position: (f32, f32),
49    pub phase: TouchPhase,
50}
51
52struct InputState {
53    helper: WinitInputHelper,
54    /// Currently active touches, keyed by finger id — scoped like raylib's
55    /// own touch API (current points only), not edge-triggered the way
56    /// keys/buttons are.
57    touches: HashMap<u64, TouchPoint>,
58}
59
60/// The frame's keyboard/mouse/touch/window input state.
61///
62/// A self-contained ECS resource — fetch it directly with `Res<Input>`,
63/// no need to go through `WindowResource<W>` or name a concrete backend
64/// type. Cheap to clone (an `Arc` internally), and every accessor locks
65/// internally and hands back a plain value, so there's no guard type to
66/// hold onto: `input.key_held(KeyCode::KeyW)` just returns `bool`.
67///
68/// State is refreshed once per step, before systems run, so every accessor
69/// below reflects that step's input.
70#[derive(Clone)]
71pub struct Input(Arc<Mutex<InputState>>);
72
73impl Input {
74    fn new() -> Self {
75        Self(Arc::new(Mutex::new(InputState { helper: WinitInputHelper::new(), touches: HashMap::new() })))
76    }
77
78    fn update(&self, event: &Event<()>) -> bool {
79        self.0.lock().unwrap().helper.update(event)
80    }
81
82    fn handle_touch(&self, touch: &WinitTouch) {
83        let mut state = self.0.lock().unwrap();
84        let point = TouchPoint {
85            id: touch.id,
86            position: (touch.location.x as f32, touch.location.y as f32),
87            phase: touch.phase.into(),
88        };
89        match touch.phase {
90            WinitTouchPhase::Started | WinitTouchPhase::Moved => {
91                state.touches.insert(touch.id, point);
92            }
93            WinitTouchPhase::Ended | WinitTouchPhase::Cancelled => {
94                state.touches.remove(&touch.id);
95            }
96        }
97    }
98
99    /// True the step a key goes from "not pressed" to "pressed". Uses
100    /// physical keys (layout-independent), so this is the one to reach for
101    /// game controls rather than text entry.
102    pub fn key_pressed(&self, key: KeyCode) -> bool {
103        self.0.lock().unwrap().helper.key_pressed(key.into())
104    }
105
106    /// True the step a key goes from "pressed" to "not pressed".
107    pub fn key_released(&self, key: KeyCode) -> bool {
108        self.0.lock().unwrap().helper.key_released(key.into())
109    }
110
111    /// True for every step the key remains pressed.
112    pub fn key_held(&self, key: KeyCode) -> bool {
113        self.0.lock().unwrap().helper.key_held(key.into())
114    }
115
116    /// True while either shift key is held.
117    pub fn held_shift(&self) -> bool {
118        self.0.lock().unwrap().helper.held_shift()
119    }
120
121    /// True while either control key is held.
122    pub fn held_control(&self) -> bool {
123        self.0.lock().unwrap().helper.held_control()
124    }
125
126    /// True while either alt key is held.
127    pub fn held_alt(&self) -> bool {
128        self.0.lock().unwrap().helper.held_alt()
129    }
130
131    /// True the step a mouse button goes from "not pressed" to "pressed".
132    pub fn mouse_pressed(&self, button: MouseButton) -> bool {
133        self.0.lock().unwrap().helper.mouse_pressed(button.into())
134    }
135
136    /// True the step a mouse button goes from "pressed" to "not pressed".
137    pub fn mouse_released(&self, button: MouseButton) -> bool {
138        self.0.lock().unwrap().helper.mouse_released(button.into())
139    }
140
141    /// True for every step the mouse button remains pressed.
142    pub fn mouse_held(&self, button: MouseButton) -> bool {
143        self.0.lock().unwrap().helper.mouse_held(button.into())
144    }
145
146    /// Cursor position in pixels, or `None` if the window isn't focused (or
147    /// the cursor is off-window and no button is held).
148    pub fn cursor(&self) -> Option<(f32, f32)> {
149        self.0.lock().unwrap().helper.cursor()
150    }
151
152    /// Change in cursor position since the last step. `(0.0, 0.0)` under the
153    /// same conditions [`Input::cursor`] returns `None`.
154    pub fn cursor_diff(&self) -> (f32, f32) {
155        self.0.lock().unwrap().helper.cursor_diff()
156    }
157
158    /// Change in raw mouse motion since the last step — driven by device
159    /// events rather than cursor position, so this is the one to reach for
160    /// a captured-mouse first-person camera.
161    pub fn mouse_diff(&self) -> (f32, f32) {
162        self.0.lock().unwrap().helper.mouse_diff()
163    }
164
165    /// Scroll wheel delta `(horizontal, vertical)` since the last step.
166    pub fn scroll_diff(&self) -> (f32, f32) {
167        self.0.lock().unwrap().helper.scroll_diff()
168    }
169
170    /// True if the OS requested the window close this step (e.g. the title
171    /// bar's close button).
172    pub fn close_requested(&self) -> bool {
173        self.0.lock().unwrap().helper.close_requested()
174    }
175
176    /// Current window resolution, or `None` before the first resize event.
177    pub fn resolution(&self) -> Option<(u32, u32)> {
178        self.0.lock().unwrap().helper.resolution()
179    }
180
181    /// Path of a file dropped onto the window this step, if any.
182    pub fn dropped_file(&self) -> Option<PathBuf> {
183        self.0.lock().unwrap().helper.dropped_file()
184    }
185
186    /// Time elapsed since the last step, or `None` while the first step is
187    /// still in progress.
188    pub fn delta_time(&self) -> Option<Duration> {
189        self.0.lock().unwrap().helper.delta_time()
190    }
191
192    /// Every finger currently touching the screen. Not edge-triggered the
193    /// way keys/mouse buttons are — this is a live snapshot of whatever's
194    /// active right now, the same shape as [`Input::cursor`].
195    pub fn touches(&self) -> Vec<TouchPoint> {
196        self.0.lock().unwrap().touches.values().copied().collect()
197    }
198
199    /// Number of fingers currently touching the screen.
200    pub fn touch_count(&self) -> usize {
201        self.0.lock().unwrap().touches.len()
202    }
203}
204
205/// Runtime control over the OS window — cursor, title, size, fullscreen,
206/// and the like.
207///
208/// A self-contained ECS resource — `Res<Window>`, same as [`Input`] — not
209/// `WindowResource<WinitWindow>::handle`, which is `Arc<winit::window::Window>`
210/// and every raw `winit` method that comes with it. Cheap to clone (an
211/// `Arc` internally); every method forwards straight to the OS window, no
212/// locking needed since none of this is polled state like [`Input`] is.
213#[derive(Clone)]
214pub struct Window(Arc<OsWindow>);
215
216impl Window {
217    fn new(handle: Arc<OsWindow>) -> Self {
218        Self(handle)
219    }
220
221    /// Set the title shown in the window's title bar.
222    pub fn set_title(&self, title: &str) {
223        self.0.set_title(title);
224    }
225
226    /// The window's current inner size, in physical pixels.
227    pub fn inner_size(&self) -> (u32, u32) {
228        let size = self.0.inner_size();
229        (size.width, size.height)
230    }
231
232    /// Request a new inner size. The OS may not grant it exactly (or at
233    /// all, e.g. a maximized/tiled window) — check [`Window::inner_size`]
234    /// afterward for whatever size actually resulted.
235    pub fn set_inner_size(&self, width: u32, height: u32) {
236        let _ = self.0.request_inner_size(PhysicalSize::new(width, height));
237    }
238
239    /// Lower bound on manual/OS resizing. `None` clears it.
240    pub fn set_min_inner_size(&self, size: Option<(u32, u32)>) {
241        self.0.set_min_inner_size(size.map(|(w, h)| PhysicalSize::new(w, h)));
242    }
243
244    /// Upper bound on manual/OS resizing. `None` clears it.
245    pub fn set_max_inner_size(&self, size: Option<(u32, u32)>) {
246        self.0.set_max_inner_size(size.map(|(w, h)| PhysicalSize::new(w, h)));
247    }
248
249    /// Whether the user can resize the window by dragging its edges.
250    pub fn set_resizable(&self, resizable: bool) {
251        self.0.set_resizable(resizable);
252    }
253
254    /// Show or hide the window entirely.
255    pub fn set_visible(&self, visible: bool) {
256        self.0.set_visible(visible);
257    }
258
259    /// Minimize or restore the window.
260    pub fn set_minimized(&self, minimized: bool) {
261        self.0.set_minimized(minimized);
262    }
263
264    /// Maximize or restore the window.
265    pub fn set_maximized(&self, maximized: bool) {
266        self.0.set_maximized(maximized);
267    }
268
269    /// Show or hide the title bar/border.
270    pub fn set_decorations(&self, decorations: bool) {
271        self.0.set_decorations(decorations);
272    }
273
274    /// Request OS input focus.
275    pub fn focus(&self) {
276        self.0.focus_window();
277    }
278
279    /// Toggle borderless fullscreen on the window's current monitor, or
280    /// return to windowed mode.
281    pub fn set_fullscreen(&self, fullscreen: bool) {
282        self.0.set_fullscreen(fullscreen.then_some(Fullscreen::Borderless(None)));
283    }
284
285    /// Whether the window is currently fullscreen.
286    pub fn is_fullscreen(&self) -> bool {
287        self.0.fullscreen().is_some()
288    }
289
290    /// Change the mouse cursor's icon.
291    pub fn set_cursor_icon(&self, icon: CursorIcon) {
292        self.0.set_cursor_icon(icon.into());
293    }
294
295    /// Show or hide the mouse cursor while it's over the window.
296    pub fn set_cursor_visible(&self, visible: bool) {
297        self.0.set_cursor_visible(visible);
298    }
299
300    /// Confine or lock the cursor (see [`CursorGrabMode`]) — the usual pair
301    /// with `set_cursor_visible(false)` for a captured-mouse camera.
302    /// Returns `false` instead of panicking if the platform doesn't support
303    /// the requested mode (see `CursorGrabMode`'s variant docs).
304    pub fn set_cursor_grab(&self, mode: CursorGrabMode) -> bool {
305        self.0.set_cursor_grab(mode.into()).is_ok()
306    }
307
308    /// Move the cursor to a position within the window, in physical pixels.
309    /// Returns `false` instead of panicking if the platform doesn't support
310    /// it.
311    pub fn set_cursor_position(&self, x: f64, y: f64) -> bool {
312        self.0.set_cursor_position(winit::dpi::PhysicalPosition::new(x, y)).is_ok()
313    }
314
315    /// Request that the window be redrawn on the next frame — rarely needed
316    /// directly (the render loop already drives this), but available for a
317    /// backend/window setup that needs to force a redraw out of band.
318    pub fn request_redraw(&self) {
319        self.0.request_redraw();
320    }
321}
322
323/// Inserts [`Window`] as a resource, wrapping the same handle already in
324/// `WindowResource<WinitWindow>`. `WGPUPlugin` adds this automatically,
325/// right after `WindowPlugin<WinitWindow>` — add it yourself only if you're
326/// composing `WindowPlugin<WinitWindow>` without going through `WGPUPlugin`
327/// (see the book's "Owning the graphics backend yourself").
328pub struct WindowControlPlugin;
329
330impl Plugin for WindowControlPlugin {
331    fn build(&self, app: &mut crate::prelude::App) {
332        let handle = app.get_resource::<WindowResource<WinitWindow>>().handle.clone();
333        app.add_resource(Window::new(handle));
334    }
335}
336
337pub struct WinitWindow {
338    window: Arc<OsWindow>,
339    event_loop: EventLoop<()>,
340    input: Input,
341}
342
343impl WindowProvider for WinitWindow {
344    type Handle = Arc<OsWindow>;
345    type Exposed = Input;
346
347    fn create(config: &WindowConfig) -> Self {
348        let event_loop = EventLoop::new().unwrap();
349        event_loop.set_control_flow(winit::event_loop::ControlFlow::Poll);
350
351        #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
352        let mut window_builder = WindowBuilder::new().with_title(config.title.clone());
353
354        #[cfg(not(target_arch = "wasm32"))]
355        {
356            window_builder =
357                window_builder.with_inner_size(PhysicalSize::new(config.width, config.height));
358        }
359
360        #[cfg(target_arch = "wasm32")]
361        let window = {
362            use wasm_bindgen::JsCast;
363            use winit::platform::web::WindowBuilderExtWebSys;
364
365            // Without this, a panic anywhere in the app — including the
366            // `.expect()`s a few lines below, which are exactly the ones
367            // most likely to fire on a real misconfiguration (no matching
368            // canvas element) — shows up in the browser console as an
369            // opaque, unhelpful trap instead of the actual message and a
370            // Rust-side stack trace. Idempotent, so it's safe to call even
371            // if something else already installed a hook first.
372            console_error_panic_hook::set_once();
373
374            let web_window = web_sys::window().expect("no global `window` exists");
375            let document = web_window
376                .document()
377                .expect("should have a document on window");
378            let canvas = document
379                .get_element_by_id("wgpu_canvas")
380                .expect("no element with id `wgpu_canvas` found — add <canvas id=\"wgpu_canvas\"></canvas> to index.html")
381                .unchecked_into::<web_sys::HtmlCanvasElement>();
382
383            let window = Arc::new(
384                window_builder
385                    .with_canvas(Some(canvas))
386                    .build(&event_loop)
387                    .unwrap(),
388            );
389
390            // winit doesn't track the browser viewport for a caller-supplied
391            // canvas, so the window (and canvas) would stay stuck at its
392            // initial size forever. Size it to the viewport now, then keep it
393            // in sync on every `resize` event.
394            let sync_size = {
395                let window = window.clone();
396                move || {
397                    let web_window = web_sys::window().expect("no global `window` exists");
398                    let width = web_window.inner_width().unwrap().as_f64().unwrap();
399                    let height = web_window.inner_height().unwrap().as_f64().unwrap();
400                    let _ = window.request_inner_size(winit::dpi::LogicalSize::new(width, height));
401                }
402            };
403            sync_size();
404
405            let closure =
406                wasm_bindgen::closure::Closure::<dyn FnMut()>::new(sync_size).into_js_value();
407            web_window
408                .add_event_listener_with_callback("resize", closure.unchecked_ref())
409                .expect("failed to add `resize` listener");
410
411            window
412        };
413
414        #[cfg(not(target_arch = "wasm32"))]
415        let window = Arc::new(window_builder.build(&event_loop).unwrap());
416
417        Self {
418            window,
419            event_loop,
420            input: Input::new(),
421        }
422    }
423
424    fn size(handle: &Self::Handle) -> (u32, u32) {
425        let s = handle.inner_size();
426        (s.width, s.height)
427    }
428
429    fn exposed(&self) -> Self::Exposed {
430        self.input.clone()
431    }
432
433    fn handle(&self) -> &Self::Handle {
434        &self.window
435    }
436}
437
438impl WindowRunner for WinitWindow {
439    fn run(self, mut on_frame: impl FnMut() + 'static) {
440        let Self {
441            window,
442            event_loop,
443            input,
444        } = self;
445
446        // On web, `ControlFlow::Poll` doesn't tie the loop to vsync — winit's
447        // web backend pumps `AboutToWait` (which `stepped` fires on) via an
448        // unthrottled task-scheduler loop, so driving frames off it runs the
449        // whole ECS tick + GPU submit hundreds of times a second, competing
450        // with the browser's compositor on the same thread. `RedrawRequested`
451        // is the one event winit paces via `requestAnimationFrame` on web, so
452        // drive frames from that instead and keep re-requesting it each time.
453        #[cfg(target_arch = "wasm32")]
454        window.request_redraw();
455
456        event_loop
457            .run(move |event, elwt| {
458                let stepped = input.update(&event);
459
460                match &event {
461                    Event::WindowEvent {
462                        event: WindowEvent::CloseRequested,
463                        ..
464                    } => elwt.exit(),
465                    Event::WindowEvent {
466                        event: WindowEvent::Touch(touch),
467                        ..
468                    } => input.handle_touch(touch),
469                    #[cfg(target_arch = "wasm32")]
470                    Event::WindowEvent {
471                        event: WindowEvent::RedrawRequested,
472                        ..
473                    } => {
474                        on_frame();
475                        window.request_redraw();
476                    }
477                    _ => {}
478                }
479
480                #[cfg(not(target_arch = "wasm32"))]
481                if stepped {
482                    on_frame();
483                    window.request_redraw();
484                }
485                #[cfg(target_arch = "wasm32")]
486                let _ = stepped;
487            })
488            .unwrap();
489    }
490}
491
492impl PresentableWindow for WinitWindow {}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    // `DeviceId::dummy()` is `unsafe` specifically because "passing this into
499    // a winit function will result in undefined behavior" — we never do
500    // that; it only ever flows into our own `handle_touch`, which reads
501    // `id`/`phase`/`location` and never touches `device_id` at all.
502    fn touch(id: u64, phase: WinitTouchPhase, x: f64, y: f64) -> WinitTouch {
503        WinitTouch {
504            device_id: unsafe { winit::event::DeviceId::dummy() },
505            phase,
506            location: winit::dpi::PhysicalPosition::new(x, y),
507            force: None,
508            id,
509        }
510    }
511
512    #[test]
513    fn a_started_touch_appears_in_touches() {
514        let input = Input::new();
515        input.handle_touch(&touch(1, WinitTouchPhase::Started, 10.0, 20.0));
516
517        assert_eq!(input.touch_count(), 1);
518        let points = input.touches();
519        assert_eq!(points[0].id, 1);
520        assert_eq!(points[0].position, (10.0, 20.0));
521        assert_eq!(points[0].phase, TouchPhase::Started);
522    }
523
524    #[test]
525    fn a_moved_touch_updates_the_same_id_in_place() {
526        let input = Input::new();
527        input.handle_touch(&touch(1, WinitTouchPhase::Started, 0.0, 0.0));
528        input.handle_touch(&touch(1, WinitTouchPhase::Moved, 5.0, 5.0));
529
530        assert_eq!(input.touch_count(), 1, "a moved touch must not create a second point");
531        assert_eq!(input.touches()[0].position, (5.0, 5.0));
532    }
533
534    #[test]
535    fn an_ended_touch_is_removed() {
536        let input = Input::new();
537        input.handle_touch(&touch(1, WinitTouchPhase::Started, 0.0, 0.0));
538        input.handle_touch(&touch(1, WinitTouchPhase::Ended, 0.0, 0.0));
539
540        assert_eq!(input.touch_count(), 0);
541    }
542
543    #[test]
544    fn a_cancelled_touch_is_removed() {
545        let input = Input::new();
546        input.handle_touch(&touch(1, WinitTouchPhase::Started, 0.0, 0.0));
547        input.handle_touch(&touch(1, WinitTouchPhase::Cancelled, 0.0, 0.0));
548
549        assert_eq!(input.touch_count(), 0);
550    }
551
552    #[test]
553    fn multiple_simultaneous_touches_are_tracked_independently() {
554        let input = Input::new();
555        input.handle_touch(&touch(1, WinitTouchPhase::Started, 0.0, 0.0));
556        input.handle_touch(&touch(2, WinitTouchPhase::Started, 100.0, 100.0));
557
558        assert_eq!(input.touch_count(), 2);
559        input.handle_touch(&touch(1, WinitTouchPhase::Ended, 0.0, 0.0));
560        assert_eq!(input.touch_count(), 1);
561        assert_eq!(input.touches()[0].id, 2);
562    }
563}