Skip to main content

pebble/graphics/
window.rs

1use std::sync::{Arc, Mutex};
2
3use winit::{
4    application::ApplicationHandler,
5    event::{DeviceEvent, DeviceId, WindowEvent},
6    event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
7    window::{Fullscreen, Window as OsWindow, WindowId},
8};
9use winit_input_helper::WinitInputHelper;
10
11use crate::{
12    ecs::plugin::Plugin,
13    graphics::types::{CursorGrabMode, CursorIcon, KeyCode, MouseButton},
14};
15
16/// Initial window title/size, passed to [`WindowPlugin::new`].
17pub struct WindowConfig {
18    pub title: String,
19    pub width: u32,
20    pub height: u32,
21}
22
23impl Default for WindowConfig {
24    fn default() -> Self {
25        Self {
26            title: "Pebble".to_string(),
27            width: 1280,
28            height: 720,
29        }
30    }
31}
32
33/// Runtime control over the OS window — inserted as a resource by
34/// [`WindowPlugin`]. No raw `winit` type appears in its public API.
35#[derive(Clone)]
36pub struct Window(Arc<OsWindow>);
37
38impl Window {
39    fn new(handle: Arc<OsWindow>) -> Self {
40        Self(handle)
41    }
42
43    pub(crate) fn raw(&self) -> Arc<OsWindow> {
44        self.0.clone()
45    }
46
47    pub fn set_title(&self, title: &str) {
48        self.0.set_title(title);
49    }
50
51    pub fn inner_size(&self) -> (u32, u32) {
52        let size = self.0.inner_size();
53        (size.width, size.height)
54    }
55
56    pub fn set_inner_size(&self, width: u32, height: u32) {
57        let _ = self
58            .0
59            .request_inner_size(winit::dpi::PhysicalSize::new(width, height));
60    }
61
62    pub fn set_resizable(&self, resizable: bool) {
63        self.0.set_resizable(resizable);
64    }
65
66    pub fn set_visible(&self, visible: bool) {
67        self.0.set_visible(visible);
68    }
69
70    pub fn set_minimized(&self, minimized: bool) {
71        self.0.set_minimized(minimized);
72    }
73
74    pub fn set_maximized(&self, maximized: bool) {
75        self.0.set_maximized(maximized);
76    }
77
78    pub fn set_decorations(&self, decorations: bool) {
79        self.0.set_decorations(decorations);
80    }
81
82    pub fn focus(&self) {
83        self.0.focus_window();
84    }
85
86    pub fn set_fullscreen(&self, fullscreen: bool) {
87        self.0
88            .set_fullscreen(fullscreen.then_some(Fullscreen::Borderless(None)));
89    }
90
91    pub fn is_fullscreen(&self) -> bool {
92        self.0.fullscreen().is_some()
93    }
94
95    pub fn set_cursor_icon(&self, icon: CursorIcon) {
96        self.0.set_cursor(winit::window::CursorIcon::from(icon));
97    }
98
99    pub fn set_cursor_visible(&self, visible: bool) {
100        self.0.set_cursor_visible(visible);
101    }
102
103    pub fn set_cursor_grab(&self, mode: CursorGrabMode) -> bool {
104        self.0.set_cursor_grab(mode.into()).is_ok()
105    }
106
107    pub fn request_redraw(&self) {
108        self.0.request_redraw();
109    }
110}
111
112struct InputState {
113    helper: WinitInputHelper,
114}
115
116/// Keyboard/mouse state for this tick — inserted as a resource by
117/// [`WindowPlugin`]. `key_pressed`/`mouse_pressed` are edge-triggered (true
118/// only the tick a key/button went down); `key_held`/`mouse_held` are
119/// level-triggered (true for as long as it's down).
120#[derive(Clone)]
121pub struct Input(Arc<Mutex<InputState>>);
122
123impl Input {
124    fn new() -> Self {
125        Self(Arc::new(Mutex::new(InputState {
126            helper: WinitInputHelper::new(),
127        })))
128    }
129
130    fn step(&self) {
131        self.0.lock().unwrap().helper.step();
132    }
133
134    fn process_window_event(&self, event: &WindowEvent) {
135        self.0.lock().unwrap().helper.process_window_event(event);
136    }
137
138    fn process_device_event(&self, event: &DeviceEvent) {
139        self.0.lock().unwrap().helper.process_device_event(event);
140    }
141
142    fn end_step(&self) {
143        self.0.lock().unwrap().helper.end_step();
144    }
145
146    pub fn key_pressed(&self, key: KeyCode) -> bool {
147        self.0.lock().unwrap().helper.key_pressed(key.into())
148    }
149
150    pub fn key_released(&self, key: KeyCode) -> bool {
151        self.0.lock().unwrap().helper.key_released(key.into())
152    }
153
154    pub fn key_held(&self, key: KeyCode) -> bool {
155        self.0.lock().unwrap().helper.key_held(key.into())
156    }
157
158    pub fn mouse_pressed(&self, button: MouseButton) -> bool {
159        self.0.lock().unwrap().helper.mouse_pressed(button.into())
160    }
161
162    pub fn mouse_released(&self, button: MouseButton) -> bool {
163        self.0.lock().unwrap().helper.mouse_released(button.into())
164    }
165
166    pub fn mouse_held(&self, button: MouseButton) -> bool {
167        self.0.lock().unwrap().helper.mouse_held(button.into())
168    }
169
170    /// Current cursor position in window coordinates, if it's inside the window.
171    pub fn cursor(&self) -> Option<(f32, f32)> {
172        self.0.lock().unwrap().helper.cursor()
173    }
174
175    /// Cursor movement since last tick.
176    pub fn cursor_diff(&self) -> (f32, f32) {
177        self.0.lock().unwrap().helper.cursor_diff()
178    }
179
180    /// Raw mouse motion since last tick — unlike [`cursor_diff`](Self::cursor_diff),
181    /// not clamped to the window (useful for a look/orbit camera).
182    pub fn mouse_diff(&self) -> (f32, f32) {
183        self.0.lock().unwrap().helper.mouse_diff()
184    }
185
186    pub fn scroll_diff(&self) -> (f32, f32) {
187        self.0.lock().unwrap().helper.scroll_diff()
188    }
189
190    /// True the tick the window's close button was pressed — you decide
191    /// whether/how to actually exit.
192    pub fn close_requested(&self) -> bool {
193        self.0.lock().unwrap().helper.close_requested()
194    }
195
196    /// The window's resolution, once known.
197    pub fn resolution(&self) -> Option<(u32, u32)> {
198        self.0.lock().unwrap().helper.resolution()
199    }
200}
201
202/// Installs a runner that opens a window (via `winit`) and inserts
203/// [`Window`]/[`Input`] as resources once the event loop resumes — see
204/// [`WinitApp`]. Functional on native and `wasm32-unknown-unknown`.
205pub struct WindowPlugin {
206    config: WindowConfig,
207}
208
209impl WindowPlugin {
210    pub fn new(config: WindowConfig) -> Self {
211        Self { config }
212    }
213}
214
215impl Default for WindowPlugin {
216    fn default() -> Self {
217        Self::new(WindowConfig::default())
218    }
219}
220
221/// Drives the `App` from `winit`'s `ApplicationHandler`, paced by
222/// `RedrawRequested` rather than the poll-loop's `AboutToWait` — the latter
223/// is an iteration boundary, not a frame boundary, and the two only line up
224/// by coincidence on native (where a vsync-blocking `present()` inside
225/// `App::update` happens to throttle it). On the web the poll loop iterates
226/// independently of `requestAnimationFrame`, so stepping input there instead
227/// fragments each displayed frame's input across several silent sub-steps —
228/// dropping press/release edges and diluting `mouse_diff`. `RedrawRequested`
229/// is rAF-aligned on every backend, so keying off it needs no platform cfg.
230///
231/// The window can only be created once the event loop actually resumes, so
232/// `config` is consumed there rather than up front (also doubles as a
233/// "window already created" guard for platforms that call `resumed` more
234/// than once). Every other window/device event is buffered and replayed
235/// into the input helper as one atomic step right before `RedrawRequested`
236/// is handled, so edge state and diffs span exactly one displayed frame.
237struct WinitApp {
238    config: Option<WindowConfig>,
239    app: crate::app::App,
240    input: Input,
241    window: Option<Arc<OsWindow>>,
242    pending_window_events: Vec<WindowEvent>,
243    pending_device_events: Vec<DeviceEvent>,
244}
245
246impl ApplicationHandler for WinitApp {
247    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
248        let Some(config) = self.config.take() else {
249            return;
250        };
251
252        #[allow(unused_mut)]
253        let mut attrs = OsWindow::default_attributes()
254            .with_title(config.title)
255            .with_inner_size(winit::dpi::PhysicalSize::new(config.width, config.height));
256
257        // winit doesn't insert the canvas into the page on its own — ask it
258        // to, so a window actually shows up without hand-rolled web_sys/DOM
259        // code
260        #[cfg(target_arch = "wasm32")]
261        {
262            use winit::platform::web::WindowAttributesExtWebSys;
263            attrs = attrs.with_append(true);
264        }
265
266        let os_window = Arc::new(event_loop.create_window(attrs).unwrap());
267        self.window = Some(os_window.clone());
268        let window = Window::new(os_window);
269
270        self.app = std::mem::take(&mut self.app)
271            .insert_resource(window)
272            .insert_resource(self.input.clone());
273
274        // Kick off the first frame — under `ControlFlow::Wait` nothing else
275        // will ever request one.
276        self.window.as_ref().unwrap().request_redraw();
277    }
278
279    fn window_event(&mut self, event_loop: &ActiveEventLoop, _window_id: WindowId, event: WindowEvent) {
280        match event {
281            WindowEvent::CloseRequested => {
282                event_loop.exit();
283            }
284            WindowEvent::RedrawRequested => {
285                self.input.step();
286                for pending in self.pending_window_events.drain(..) {
287                    self.input.process_window_event(&pending);
288                }
289                for pending in self.pending_device_events.drain(..) {
290                    self.input.process_device_event(&pending);
291                }
292                self.input.end_step();
293
294                self.app.update();
295                if self.app.should_exit() {
296                    event_loop.exit();
297                    return;
298                }
299
300                // `resumed` always runs before the first `window_event`, so
301                // the window is guaranteed to exist here.
302                self.window.as_ref().unwrap().request_redraw();
303            }
304            other => self.pending_window_events.push(other),
305        }
306    }
307
308    fn device_event(&mut self, _event_loop: &ActiveEventLoop, _device_id: DeviceId, event: DeviceEvent) {
309        self.pending_device_events.push(event);
310    }
311}
312
313impl Plugin for WindowPlugin {
314    fn build(self, app: crate::app::App) -> crate::app::App {
315        let event_loop = EventLoop::new().unwrap();
316        // The loop is paced by `request_redraw` (see `WinitApp`), not by
317        // spinning — `Wait` lets it actually sleep between frames instead of
318        // busy-polling.
319        event_loop.set_control_flow(ControlFlow::Wait);
320
321        app.set_runner(move |app| {
322            let handler = WinitApp {
323                config: Some(self.config),
324                app,
325                input: Input::new(),
326                window: None,
327                pending_window_events: Vec::new(),
328                pending_device_events: Vec::new(),
329            };
330
331            // `run_app` blocks forever natively; on wasm it only works via an
332            // internal exception-unwinding trick and isn't always
333            // available — `spawn_app` is the purpose-built non-blocking wasm
334            // equivalent, same handler, just returns immediately after
335            // registering it with the browser
336            #[cfg(not(target_arch = "wasm32"))]
337            {
338                let mut handler = handler;
339                event_loop.run_app(&mut handler).unwrap();
340            }
341
342            #[cfg(target_arch = "wasm32")]
343            {
344                use winit::platform::web::EventLoopExtWebSys;
345                event_loop.spawn_app(handler);
346            }
347        })
348    }
349}