Skip to main content

gapp_winit/
lib.rs

1#![deny(missing_docs)]
2
3/*!
4An abstract event loop library for winit-based applications with support for OpenGL (glutin) and wgpu backends.
5It cleanly separates input handling, updates, rendering, and presenting via traits and manages a fixed-timestep FPS loop.
6
7# Features
8
9- `opengl`: Enables OpenGL support.
10- `wgpu`: Enables wgpu support (recommended).
11
12# Usage
13
14Implement `WindowInput<I, R>`, `Present<R>`, `Render<R>` (from gapp), and `Update` (from gapp) for your app.
15Create a `Windows<R>` collection with your backend-specific `WindowData`, then call `run()`.
16
17See [the template](https://gitlab.com/porky11/gapp-template) for reference. It can be used as a starting point.
18*/
19
20use std::collections::HashMap;
21#[cfg(feature = "wgpu")]
22use std::sync::Arc;
23#[cfg(not(target_arch = "wasm32"))]
24use std::time::{Duration, Instant};
25#[cfg(target_arch = "wasm32")]
26use web_time::{Duration, Instant};
27
28pub use gapp::{Render, Update};
29
30#[cfg(feature = "opengl")]
31#[cfg(not(target_arch = "wasm32"))]
32use glutin::{
33    context::PossiblyCurrentContext,
34    prelude::*,
35    surface::{Surface, WindowSurface},
36};
37#[cfg(not(target_arch = "wasm32"))]
38use winit::dpi::PhysicalSize;
39use winit::{
40    event::{DeviceEvent, DeviceId, WindowEvent},
41    event_loop::ActiveEventLoop,
42    window::{Window, WindowId},
43};
44
45/// Trait for custom input handling.
46///
47/// Called for **every** `WindowEvent`, **after** possible internal handling.
48/// Handle events like `CloseRequested`, `KeyboardInput`, etc. here.
49/// Call `event_loop.exit()` to quit the app.
50/// The `windows` parameter provides mutable access to all managed windows and their renderers.
51///
52/// `KeyboardInput` is withheld for a brief grace period after the window regains focus, so the
53/// keystroke of an OS window-switch shortcut (e.g. Alt-Tab) doesn't leak into the app.
54pub trait WindowInput<C, R> {
55    /// Processes a `WindowEvent`.
56    ///
57    /// # Arguments
58    ///
59    /// - `window_id`: The ID of the window that received the event.
60    /// - `event`: The incoming `WindowEvent`.
61    /// - `event_loop`: Reference to the active event loop for control (e.g., `exit()`).
62    /// - `context`: Custom context to represent input state.
63    /// - `windows`: Mutable access to all managed windows and their renderers.
64    fn input(
65        &mut self,
66        window_id: WindowId,
67        event: &WindowEvent,
68        event_loop: &ActiveEventLoop,
69        context: &mut C,
70        windows: &mut Windows<R>,
71    );
72
73    /// Processes a raw `DeviceEvent` (e.g. relative mouse motion for camera/look controls).
74    ///
75    /// Called for every `DeviceEvent`, unfiltered. Default implementation does nothing, so
76    /// existing `WindowInput` implementors are unaffected unless they opt in.
77    ///
78    /// # Arguments
79    ///
80    /// - `device_id`: The ID of the device that produced the event.
81    /// - `event`: The incoming `DeviceEvent`.
82    /// - `event_loop`: Reference to the active event loop for control (e.g., `exit()`).
83    /// - `context`: Custom context to represent input state.
84    /// - `windows`: Mutable access to all managed windows and their renderers.
85    fn device_input(
86        &mut self,
87        device_id: DeviceId,
88        event: &DeviceEvent,
89        event_loop: &ActiveEventLoop,
90        context: &mut C,
91        windows: &mut Windows<R>,
92    ) {
93        let _ = (device_id, event, event_loop, context, windows);
94    }
95}
96
97/// Platform- and backend-specific window data for OpenGL.
98#[cfg(feature = "opengl")]
99pub struct WindowData {
100    /// The winit window.
101    pub window: Window,
102    /// The GL context (glutin).
103    #[cfg(not(target_arch = "wasm32"))]
104    pub context: PossiblyCurrentContext,
105    /// The GL surface (glutin).
106    #[cfg(not(target_arch = "wasm32"))]
107    pub surface: Surface<WindowSurface>,
108}
109
110#[cfg(feature = "opengl")]
111#[cfg(not(target_arch = "wasm32"))]
112impl WindowData {
113    /// Creates a window with a current OpenGL context and surface via glutin.
114    ///
115    /// Picks a config with an alpha channel, preferring transparency and multisampling, then makes
116    /// the context current on the new surface. Build your GL renderer afterward from
117    /// `window_data.context.display()` (e.g. via `get_proc_address`). All fields are public, so
118    /// callers needing different config selection can construct `WindowData` manually instead.
119    #[allow(clippy::expect_used)]
120    pub fn new_opengl(
121        event_loop: &ActiveEventLoop,
122        attributes: winit::window::WindowAttributes,
123    ) -> Result<Self, Box<dyn std::error::Error>> {
124        use glutin::{
125            config::ConfigTemplateBuilder,
126            context::{ContextApi, ContextAttributesBuilder},
127            display::GetGlDisplay,
128            surface::SurfaceAttributesBuilder,
129        };
130        use glutin_winit::DisplayBuilder;
131        use raw_window_handle::HasWindowHandle;
132        use std::num::NonZeroU32;
133
134        let template = ConfigTemplateBuilder::new().with_alpha_size(8);
135        let display_builder = DisplayBuilder::new().with_window_attributes(Some(attributes));
136
137        let (window, gl_config) = display_builder.build(event_loop, template, |configs| {
138            configs
139                .reduce(|accum, config| {
140                    let prefer_config = config.supports_transparency().unwrap_or(false)
141                        & !accum.supports_transparency().unwrap_or(false);
142                    if prefer_config || config.num_samples() > accum.num_samples() {
143                        config
144                    } else {
145                        accum
146                    }
147                })
148                .expect("no OpenGL configurations available")
149        })?;
150
151        let window = window.ok_or("display builder returned no window")?;
152        let raw_handle = window.window_handle()?.as_raw();
153        let gl_display = gl_config.display();
154
155        let context_attributes = ContextAttributesBuilder::new().build(Some(raw_handle));
156        let fallback_attributes = ContextAttributesBuilder::new()
157            .with_context_api(ContextApi::Gles(None))
158            .build(Some(raw_handle));
159
160        let context = unsafe {
161            gl_display
162                .create_context(&gl_config, &context_attributes)
163                .or_else(|_| gl_display.create_context(&gl_config, &fallback_attributes))?
164        };
165
166        let size = window.inner_size();
167        let width = NonZeroU32::new(size.width.max(1)).ok_or("zero window width")?;
168        let height = NonZeroU32::new(size.height.max(1)).ok_or("zero window height")?;
169        let surface_attributes =
170            SurfaceAttributesBuilder::<WindowSurface>::new().build(raw_handle, width, height);
171        let surface = unsafe { gl_display.create_window_surface(&gl_config, &surface_attributes)? };
172
173        let context = context.make_current(&surface)?;
174
175        Ok(Self {
176            window,
177            context,
178            surface,
179        })
180    }
181}
182
183/// Platform- and backend-specific window data for wgpu.
184#[cfg(feature = "wgpu")]
185pub struct WindowData {
186    /// The wgpu device.
187    pub device: wgpu::Device,
188    /// The wgpu queue.
189    pub queue: wgpu::Queue,
190    /// Current surface configuration (updated on resize).
191    pub surface_config: wgpu::SurfaceConfiguration,
192    /// The wgpu surface (with `'static` lifetime for compatibility).
193    pub surface: wgpu::Surface<'static>,
194    /// Arc-wrapped winit window (thread-safe).
195    pub window: Arc<Window>,
196}
197
198#[cfg(feature = "wgpu")]
199impl WindowData {
200    /// Creates a window and its wgpu device, queue, surface, and configuration.
201    ///
202    /// Picks the instance backends and adapter from the environment, requests a device with
203    /// WebGL2-downlevel limits (portable across native and `wasm32`), and configures the surface
204    /// with a non-sRGB format. All fields are public, so callers can adjust the result afterward
205    /// (e.g. add `TextureUsages::COPY_SRC` for screenshots, then reconfigure the surface).
206    ///
207    /// Intended for native targets: call it inside the [`run`] window factory and drive the future
208    /// with a blocking executor (e.g. `pollster::block_on`). On `wasm32` it cannot be used through
209    /// [`run`] — the factory is synchronous, blocking executors deadlock the single-threaded JS
210    /// event loop, and the browser surface is built from a canvas rather than a created window. On
211    /// the web, set up the device and surface in an `async` context before calling [`run`] and
212    /// construct `WindowData` by hand in the factory.
213    pub async fn new_wgpu(
214        event_loop: &ActiveEventLoop,
215        attributes: winit::window::WindowAttributes,
216    ) -> Result<Self, Box<dyn std::error::Error>> {
217        let window = Arc::new(event_loop.create_window(attributes)?);
218
219        let instance = wgpu::util::new_instance_with_webgpu_detection(wgpu::InstanceDescriptor {
220            backends: wgpu::Backends::from_env().unwrap_or_default(),
221            flags: wgpu::InstanceFlags::from_build_config().with_env(),
222            ..wgpu::InstanceDescriptor::new_without_display_handle()
223        })
224        .await;
225
226        let surface = instance.create_surface(window.clone())?;
227
228        let adapter =
229            wgpu::util::initialize_adapter_from_env_or_default(&instance, Some(&surface)).await?;
230
231        let (device, queue) = adapter
232            .request_device(&wgpu::DeviceDescriptor {
233                required_limits: wgpu::Limits::downlevel_webgl2_defaults()
234                    .using_resolution(adapter.limits()),
235                ..Default::default()
236            })
237            .await?;
238
239        let size = window.inner_size();
240        let mut surface_config = surface
241            .get_default_config(&adapter, size.width.max(1), size.height.max(1))
242            .ok_or("surface is not supported by the adapter")?;
243
244        let capabilities = surface.get_capabilities(&adapter);
245        surface_config.format = capabilities
246            .formats
247            .iter()
248            .find(|format| !format.is_srgb())
249            .copied()
250            .unwrap_or(capabilities.formats[0]);
251        surface.configure(&device, &surface_config);
252
253        Ok(Self {
254            device,
255            queue,
256            surface_config,
257            surface,
258            window,
259        })
260    }
261}
262
263/// Collection of windows with their associated renderers.
264pub struct Windows<R> {
265    map: HashMap<WindowId, (WindowData, R)>,
266}
267
268impl<R> Windows<R> {
269    /// Creates an empty window collection.
270    pub fn new() -> Self {
271        Self {
272            map: HashMap::new(),
273        }
274    }
275
276    /// Inserts a window and its renderer. Returns the `WindowId`.
277    pub fn insert(&mut self, window_data: WindowData, renderer: R) -> WindowId {
278        let id = window_data.window.id();
279        self.map.insert(id, (window_data, renderer));
280        id
281    }
282
283    /// Removes a window by ID, returning its data and renderer if present.
284    pub fn remove(&mut self, id: &WindowId) -> Option<(WindowData, R)> {
285        self.map.remove(id)
286    }
287
288    /// Returns a reference to the window data and renderer for the given ID.
289    pub fn get(&self, id: &WindowId) -> Option<&(WindowData, R)> {
290        self.map.get(id)
291    }
292
293    /// Returns a mutable reference to the window data and renderer for the given ID.
294    pub fn get_mut(&mut self, id: &WindowId) -> Option<&mut (WindowData, R)> {
295        self.map.get_mut(id)
296    }
297
298    /// Returns `true` if there are no windows.
299    pub fn is_empty(&self) -> bool {
300        self.map.is_empty()
301    }
302
303    /// Returns the number of windows.
304    pub fn len(&self) -> usize {
305        self.map.len()
306    }
307
308    /// Iterates over all windows.
309    pub fn iter(&self) -> impl Iterator<Item = (&WindowId, &(WindowData, R))> {
310        self.map.iter()
311    }
312
313    /// Iterates mutably over all windows.
314    pub fn iter_mut(&mut self) -> impl Iterator<Item = (&WindowId, &mut (WindowData, R))> {
315        self.map.iter_mut()
316    }
317}
318
319impl<R> Default for Windows<R> {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325/// Trait for presenting the rendered frame.
326///
327/// Called after `application.render()` in `RedrawRequested` events.
328pub trait Present<R> {
329    /// Presents the frame to the surface.
330    ///
331    /// # Arguments
332    ///
333    /// - `renderer`: Mutable renderer (e.g., for swapchain submit).
334    /// - `window_data`: Backend-specific data (surface/context).
335    fn present(&self, renderer: &mut R, window_data: &WindowData);
336}
337
338type WindowFactory<R> = Box<dyn FnOnce(&ActiveEventLoop) -> Windows<R>>;
339
340struct AppState<I, R, E> {
341    application: E,
342    timestep: Duration,
343    create_windows: Option<WindowFactory<R>>,
344    windows: Option<Windows<R>>,
345    input_context: I,
346    prev_time: Instant,
347    focus_grace_until: Option<Instant>,
348}
349
350// ponytail: fixed grace window swallowing the window-switch shortcut's residual
351// keystroke; bump if a platform still leaks input past this.
352const FOCUS_GRACE: Duration = Duration::from_millis(50);
353
354impl<I: 'static, R: 'static, E> winit::application::ApplicationHandler for AppState<I, R, E>
355where
356    E: WindowInput<I, R> + Present<R> + Render<R> + Update,
357{
358    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
359        if self.windows.is_some() {
360            return;
361        }
362        if let Some(create_windows) = self.create_windows.take() {
363            self.windows = Some(create_windows(event_loop));
364            self.prev_time = Instant::now();
365        }
366    }
367
368    fn window_event(
369        &mut self,
370        event_loop: &ActiveEventLoop,
371        window_id: WindowId,
372        event: WindowEvent,
373    ) {
374        let Some(windows) = &mut self.windows else {
375            return;
376        };
377
378        if let WindowEvent::Focused(focused) = &event {
379            self.focus_grace_until = focused.then(|| Instant::now() + FOCUS_GRACE);
380        }
381        if matches!(event, WindowEvent::KeyboardInput { .. })
382            && self
383                .focus_grace_until
384                .is_some_and(|until| Instant::now() < until)
385        {
386            return;
387        }
388
389        #[cfg(not(target_arch = "wasm32"))]
390        if let WindowEvent::Resized(PhysicalSize { width, height }) = &event {
391            let width = (*width).max(1);
392            let height = (*height).max(1);
393            if let Some((window_data, _)) = windows.get_mut(&window_id) {
394                #[cfg(feature = "opengl")]
395                if let (Ok(non_zero_width), Ok(non_zero_height)) =
396                    (width.try_into(), height.try_into())
397                {
398                    window_data.surface.resize(
399                        &window_data.context,
400                        non_zero_width,
401                        non_zero_height,
402                    );
403                }
404                #[cfg(feature = "wgpu")]
405                {
406                    window_data.surface_config.width = width;
407                    window_data.surface_config.height = height;
408                    window_data
409                        .surface
410                        .configure(&window_data.device, &window_data.surface_config);
411                }
412            }
413        }
414
415        self.application.input(
416            window_id,
417            &event,
418            event_loop,
419            &mut self.input_context,
420            windows,
421        );
422
423        if matches!(event, WindowEvent::RedrawRequested)
424            && let Some((window_data, renderer)) = windows.get_mut(&window_id)
425        {
426            self.application.render(renderer);
427            self.application.present(renderer, window_data);
428        }
429    }
430
431    fn device_event(
432        &mut self,
433        event_loop: &ActiveEventLoop,
434        device_id: DeviceId,
435        event: DeviceEvent,
436    ) {
437        let Some(windows) = &mut self.windows else {
438            return;
439        };
440
441        self.application.device_input(
442            device_id,
443            &event,
444            event_loop,
445            &mut self.input_context,
446            windows,
447        );
448    }
449
450    fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
451        let Some(windows) = &self.windows else {
452            return;
453        };
454
455        self.application.update(self.timestep.as_secs_f32());
456
457        #[cfg(not(target_arch = "wasm32"))]
458        {
459            let frame_duration = self.prev_time.elapsed();
460            if frame_duration < self.timestep {
461                std::thread::sleep(self.timestep - frame_duration);
462            }
463        }
464
465        for (_, (window_data, _)) in windows.iter() {
466            window_data.window.request_redraw();
467        }
468
469        self.prev_time = Instant::now();
470    }
471
472    fn exiting(&mut self, _event_loop: &ActiveEventLoop) {}
473}
474
475/// Starts the main event loop with a fixed timestep.
476///
477/// Manages winit events and calls app traits in the correct order:
478/// - `input()` after every `WindowEvent` (after internal resize/redraw handling).
479/// - `update(timestep)` with fixed timestep in `about_to_wait`.
480/// - `render()` + `present()` on `RedrawRequested` (per window).
481///
482/// **Important notes:**
483/// - App (`E`) and states must be `'static` (moved into closure).
484/// - Automatic resize handling (clamped to min 1px size).
485/// - No automatic close handling: implement in `input()`.
486/// - Redraw is requested for all windows on each frame.
487/// - The `create_windows` closure is called once in `resumed()`.
488///
489/// # Type Parameters
490///
491/// - `I`: Input context type (for `WindowInput`).
492/// - `R`: Renderer type.
493/// - `E`: App type (must implement `WindowInput<I,R> + Present<R> + Render<R> + Update + 'static`).
494pub fn run<
495    I: 'static,
496    R: 'static,
497    E: WindowInput<I, R> + Present<R> + Render<R> + Update + 'static,
498>(
499    application: E,
500    event_loop: winit::event_loop::EventLoop<()>,
501    fps: u64,
502    create_windows: impl FnOnce(&ActiveEventLoop) -> Windows<R> + 'static,
503    input_context: I,
504) {
505    let timestep = Duration::from_nanos(1_000_000_000 / fps);
506
507    let mut state = AppState {
508        application,
509        timestep,
510        create_windows: Some(Box::new(create_windows)),
511        windows: None,
512        input_context,
513        prev_time: Instant::now(),
514        focus_grace_until: None,
515    };
516
517    let _ = event_loop.run_app(&mut state);
518}