Skip to main content

gasm_host/
window.rs

1//! The windowed runner: winit window, wgpu surface, cpal audio, gilrs gamepads,
2//! keyboard layouts, fixed-timestep frames with catch-up, and the hold-Escape quit.
3
4use std::collections::HashSet;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use gilrs::{Button, Gilrs};
9use winit::application::ApplicationHandler;
10use winit::dpi::LogicalSize;
11use winit::event::{DeviceEvent, DeviceId, ElementState, MouseButton, MouseScrollDelta, WindowEvent};
12use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
13use winit::keyboard::{Key, KeyCode, NamedKey, PhysicalKey};
14use winit::window::{CursorGrabMode, Window, WindowId};
15
16use crate::audio::{AudioOut, AudioSink, AudioStream};
17use crate::angle::NativeWindow;
18use crate::gfx::Gfx;
19use crate::host::{self, Game, Gamepad, KEY_STATE_BYTES, Pointer, RawInput, Stop};
20use crate::keymap;
21use crate::present::Present;
22use crate::session::Session;
23use crate::splash;
24
25pub struct Options {
26    /// initial window size in logical pixels
27    pub size: (u32, u32),
28    pub keymap: keymap::Keymap,
29    pub mute: bool,
30    /// how 2D frames are shown
31    pub present: Present,
32    /// write frame N as the window shows it and quit (gasm:gl games; for tests)
33    pub screenshot: Option<(u64, String)>,
34    /// show the gasm splash screen while the game loads (`--no-splash`: off)
35    pub splash: bool,
36}
37
38/// The splash screen, shown while the game's module compiles on another thread.
39struct Splash {
40    frame: u32,
41    next: Instant,
42    compiling: Option<std::thread::JoinHandle<Result<wasmtime::Module, Stop>>>,
43    module: Option<Result<wasmtime::Module, Stop>>,
44    /// a key or click: end it as soon as the game is ready
45    skip: bool,
46    /// what the game starts with once it's compiled
47    audio: Option<Box<dyn AudioOut>>,
48    gfx: Gfx,
49    gl: Option<crate::angle::Angle>,
50}
51
52/// The platform window handle ANGLE draws into.
53fn native_window(window: &Window) -> Result<NativeWindow, String> {
54    use winit::raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle};
55    let w = window.window_handle().map_err(|e| e.to_string())?.as_raw();
56    let d = window.display_handle().map_err(|e| e.to_string())?.as_raw();
57    Ok(match (w, d) {
58        (RawWindowHandle::AppKit(h), _) => NativeWindow::AppKit(h.ns_view.as_ptr()),
59        (RawWindowHandle::Win32(h), _) => NativeWindow::Win32(h.hwnd.get() as *mut std::ffi::c_void),
60        (RawWindowHandle::Xlib(h), RawDisplayHandle::Xlib(d)) => {
61            NativeWindow::Xlib(d.display.map_or(std::ptr::null_mut(), |p| p.as_ptr()), h.window)
62        }
63        (RawWindowHandle::Wayland(_), RawDisplayHandle::Wayland(d)) => NativeWindow::Wayland(d.display.as_ptr()),
64        _ => return Err("gasm:gl: this kind of window isn't supported".into()),
65    })
66}
67
68/// Open a window and play until the guest exits, traps or the player quits.
69/// Returns the guest's exit code (0 when the player quit).
70pub fn run(session: Session, opts: Options) -> Result<i32, String> {
71    let mut builder = EventLoop::builder();
72    // gasm:gl draws with ANGLE, which needs an X11 window here (XWayland on Wayland desktops)
73    #[cfg(all(unix, not(target_os = "macos")))]
74    if session.uses_gl() {
75        use winit::platform::x11::EventLoopBuilderExtX11;
76        builder.with_x11();
77    }
78    let event_loop = builder.build().map_err(|e| e.to_string())?;
79    // default title: the module file name without .wasm (set_title replaces it)
80    let default_title = host::static_title(&session.wasm).unwrap_or_else(|| {
81        std::path::Path::new(&session.name).file_stem().map_or(session.name.clone(), |s| s.to_string_lossy().into_owned())
82    });
83    let mut app = App {
84        title: default_title.clone(),
85        default_title,
86        fps: 0,
87        opts,
88        session: Some(session),
89        window: None,
90        game: None,
91        keys: HashSet::new(),
92        typed: String::new(),
93        key_events: Vec::new(),
94        esc_down: None,
95        mouse: MouseState::default(),
96        applied_mode: 0,
97        pointer_flags: 0,
98        gilrs: Gilrs::new().ok(),
99        next: Instant::now(),
100        fps_t: Instant::now(),
101        fps_n: 0,
102        result: Ok(0),
103        frames_run: 0,
104        audio_stream: None,
105        splash: None,
106    };
107    event_loop.run_app(&mut app).map_err(|e| e.to_string())?;
108    app.result
109}
110
111/// Gamepads in connection order, plus how many are connected.
112fn gamepad_pads(gilrs: &mut Gilrs) -> ([u32; 4], usize) {
113    const MAP: &[(Button, u32)] = &[
114        (Button::East, 1 << 0),
115        (Button::South, 1 << 1),
116        (Button::North, 1 << 2),
117        (Button::West, 1 << 3),
118        (Button::LeftTrigger, 1 << 4),
119        (Button::RightTrigger, 1 << 5),
120        (Button::Select, 1 << 6),
121        (Button::Start, 1 << 7),
122        (Button::DPadUp, 1 << 8),
123        (Button::DPadDown, 1 << 9),
124        (Button::DPadLeft, 1 << 10),
125        (Button::DPadRight, 1 << 11),
126    ];
127    while gilrs.next_event().is_some() {}
128    let mut pads = [0u32; 4];
129    let connected = gilrs.gamepads().count();
130    for (i, (_, pad)) in gilrs.gamepads().take(4).enumerate() {
131        pads[i] = MAP.iter().filter(|(b, _)| pad.is_pressed(*b)).fold(0, |m, (_, bit)| m | bit);
132        let (x, y) = (pad.value(gilrs::Axis::LeftStickX), pad.value(gilrs::Axis::LeftStickY));
133        if x < -0.5 { pads[i] |= 1 << 10 }
134        if x > 0.5 { pads[i] |= 1 << 11 }
135        if y > 0.5 { pads[i] |= 1 << 8 }
136        if y < -0.5 { pads[i] |= 1 << 9 }
137    }
138    (pads, connected)
139}
140
141/// Mouse input collected between frames (window pixels).
142#[derive(Default)]
143struct MouseState {
144    x: f32,
145    y: f32,
146    inside: bool,
147    buttons: u32,
148    /// buttons that went down / up since the last frame (a click inside one frame counts)
149    pressed: u32,
150    released: u32,
151    dx: f32,
152    dy: f32,
153    wheel_x: f32,
154    wheel_y: f32,
155}
156
157/// Hold Escape this long to quit (a tap goes to the game).
158const ESC_HOLD: Duration = Duration::from_millis(1000);
159
160fn mouse_bit(b: MouseButton) -> u32 {
161    match b {
162        MouseButton::Left => 1,
163        MouseButton::Right => 2,
164        MouseButton::Middle => 4,
165        MouseButton::Back => 8,
166        MouseButton::Forward => 16,
167        MouseButton::Other(_) => 0,
168    }
169}
170
171/// Raw gamepads (first 4 connected, the same order as the pads). Known
172/// controllers in the W3C standard layout; others raw, ordered by code.
173fn gamepads_raw(gilrs: &Gilrs) -> [Gamepad; 4] {
174    use gilrs::{Axis, MappingSource};
175    const BUTTONS: [Button; 17] = [
176        Button::South, Button::East, Button::West, Button::North, Button::LeftTrigger, Button::RightTrigger,
177        Button::LeftTrigger2, Button::RightTrigger2, Button::Select, Button::Start, Button::LeftThumb,
178        Button::RightThumb, Button::DPadUp, Button::DPadDown, Button::DPadLeft, Button::DPadRight, Button::Mode,
179    ];
180    let mut out: [Gamepad; 4] = Default::default();
181    for (slot, (_, pad)) in gilrs.gamepads().take(4).enumerate() {
182        let g = &mut out[slot];
183        g.connected = true;
184        g.name = pad.name().to_owned();
185        if pad.mapping_source() != MappingSource::None {
186            g.standard = true;
187            g.buttons = BUTTONS.iter().map(|b| pad.button_data(*b).map_or(0.0, |d| d.value())).collect();
188            // W3C: y axes point down
189            g.axes = vec![pad.value(Axis::LeftStickX), -pad.value(Axis::LeftStickY), pad.value(Axis::RightStickX), -pad.value(Axis::RightStickY)];
190        } else {
191            let mut b: Vec<_> = pad.state().buttons().map(|(c, d)| (c.into_u32(), d.value())).collect();
192            let mut a: Vec<_> = pad.state().axes().map(|(c, d)| (c.into_u32(), d.value())).collect();
193            b.sort_by_key(|x| x.0);
194            a.sort_by_key(|x| x.0);
195            g.buttons = b.into_iter().map(|x| x.1).take(host::GAMEPAD_BUTTONS).collect();
196            g.axes = a.into_iter().map(|x| x.1).take(host::GAMEPAD_AXES).collect();
197        }
198    }
199    out
200}
201
202struct App {
203    /// frames run so far (--window-screenshot)
204    frames_run: u64,
205    /// what the window shows before " — gasm": set_title, else `default_title`
206    title: String,
207    default_title: String,
208    /// guest frames in the last second
209    fps: u32,
210    opts: Options,
211    /// taken when the window opens
212    session: Option<Session>,
213    window: Option<Arc<Window>>,
214    game: Option<Game>,
215    keys: HashSet<KeyCode>,
216    /// text typed since the last frame (for text_input)
217    typed: String,
218    /// raw key presses/releases since the last frame
219    key_events: Vec<(u16, bool)>,
220    /// Escape: a tap goes to the game, holding it quits
221    esc_down: Option<Instant>,
222    mouse: MouseState,
223    /// cursor mode currently applied to the window (GASM_INPUT_POINTER_*)
224    applied_mode: u32,
225    /// GASM_POINTER_IS_HIDDEN / IS_LOCKED as actually achieved
226    pointer_flags: u32,
227    gilrs: Option<Gilrs>,
228    next: Instant,
229    fps_t: Instant,
230    fps_n: u32,
231    result: Result<i32, String>,
232    /// the audio device stream (kept alive while the game plays)
233    audio_stream: Option<AudioStream>,
234    splash: Option<Splash>,
235}
236
237impl App {
238    fn stop(&mut self, el: &ActiveEventLoop, result: Result<i32, String>) {
239        self.result = result;
240        // the event loop can tick again before it ends: never call into a guest
241        // that has exited or trapped
242        self.game = None;
243        el.exit();
244    }
245
246    /// Hide / lock the cursor as the guest asked (input_mode), when it changes.
247    /// The window title: the game's (set_title or the file name), then the runner's own part,
248    /// which the guest can't change.
249    fn show_title(&self) {
250        if let Some(w) = &self.window {
251            w.set_title(&format!("{} — gasm — {} fps", self.title, self.fps));
252        }
253    }
254
255    fn apply_input_mode(&mut self) {
256        let (Some(game), Some(w)) = (&mut self.game, &self.window) else { return };
257        let want = game.with_host(|h| h.input_mode) & 6;
258        if want == self.applied_mode {
259            return;
260        }
261        self.applied_mode = want;
262        let locked = want & 4 != 0
263            && w.set_cursor_grab(CursorGrabMode::Locked).or_else(|_| w.set_cursor_grab(CursorGrabMode::Confined)).is_ok();
264        if want & 4 == 0 {
265            let _ = w.set_cursor_grab(CursorGrabMode::None);
266        }
267        // locked implies hidden
268        w.set_cursor_visible(want == 0);
269        self.pointer_flags = if want != 0 { 2 } else { 0 } | if locked { 4 } else { 0 };
270    }
271
272    /// The player closed the window / held Esc: let the game save, then stop.
273    fn quit(&mut self, el: &ActiveEventLoop) {
274        if let Some(g) = &mut self.game {
275            g.exit();
276        }
277        self.stop(el, Ok(0));
278    }
279
280    /// One splash frame when it's due; starts the game when the splash is over and
281    /// the module compiled. Holds on the logo while it is still compiling.
282    fn tick_splash(&mut self, el: &ActiveEventLoop) {
283        let Some(sp) = &mut self.splash else { return };
284        if Instant::now() < sp.next {
285            return el.set_control_flow(ControlFlow::WaitUntil(sp.next));
286        }
287        if sp.compiling.as_ref().is_some_and(|t| t.is_finished()) {
288            let t = sp.compiling.take().expect("compile thread");
289            sp.module = Some(t.join().unwrap_or_else(|_| Err(Stop::Trap("the compiler thread panicked".into()))));
290        }
291        let ready = sp.module.is_some();
292        if sp.skip {
293            sp.frame = if ready { splash::FRAMES } else { sp.frame.max(splash::HOLD) };
294        }
295        if sp.frame >= splash::FRAMES && ready {
296            let sp = self.splash.take().expect("splash");
297            return self.start_game(el, sp.module.expect("compiled"), sp.audio, sp.gfx, sp.gl);
298        }
299        let rgba = splash::frame(sp.frame);
300        match &sp.gl {
301            Some(angle) => angle.show_image(&rgba, splash::W as u32, splash::H as u32),
302            None => sp.gfx.present_video(&rgba, splash::W as u32, splash::H as u32, None),
303        }
304        if sp.frame != splash::HOLD || ready {
305            sp.frame += 1;
306        }
307        sp.next += Duration::from_secs_f64(1.0 / 60.0);
308        if sp.next < Instant::now() {
309            sp.next = Instant::now();
310        }
311        el.set_control_flow(ControlFlow::WaitUntil(sp.next));
312    }
313
314    /// The game begins: instantiate it (its init runs) with the window's GPU and audio.
315    fn start_game(&mut self, el: &ActiveEventLoop, module: Result<wasmtime::Module, Stop>, audio: Option<Box<dyn AudioOut>>, gfx: Gfx, gl: Option<crate::angle::Angle>) {
316        let Some(session) = self.session.take() else { return };
317        let started = match module {
318            Ok(m) => session.start_compiled(m, audio, gfx, gl),
319            Err(e) => Err(e),
320        };
321        match started {
322            Ok(g) => self.game = Some(g),
323            Err(Stop::Exit(code)) => return self.stop(el, Ok(code)),
324            Err(Stop::Trap(e)) => return self.stop(el, Err(e)),
325        }
326        // what the player pressed during the splash isn't the game's
327        self.typed.clear();
328        self.key_events.clear();
329        self.mouse.pressed = 0;
330        self.mouse.released = 0;
331        self.next = Instant::now();
332        el.set_control_flow(ControlFlow::WaitUntil(self.next));
333    }
334
335    fn tick(&mut self, el: &ActiveEventLoop) {
336        if self.splash.is_some() {
337            if self.esc_down.is_some_and(|t| t.elapsed() >= ESC_HOLD) {
338                return self.quit(el);
339            }
340            return self.tick_splash(el);
341        }
342        let Some(game) = &mut self.game else { return };
343        let period = Duration::from_secs_f64(1.0 / game.with_host(|h| h.frame_rate));
344        let now = Instant::now();
345        if now >= self.next {
346            // Fixed timestep: catch up at most 4 frames, only the last one is shown.
347            let behind = ((now - self.next).as_secs_f64() / period.as_secs_f64()) as u32 + 1;
348            let steps = behind.min(4);
349            // Escape held long enough: quit (a short tap went to the game as a key)
350            if self.esc_down.is_some_and(|t| t.elapsed() >= ESC_HOLD) {
351                return self.quit(el);
352            }
353            let mut keys = [0u8; KEY_STATE_BYTES];
354            for &c in &self.keys {
355                let k = keymap::gasm_key(c);
356                if k != 0 {
357                    keys[k as usize / 8] |= 1 << (k % 8);
358                }
359            }
360            // gamepads are sampled once per tick: pads and raw state agree within a batch
361            let (mut pads, gamepads) = self.gilrs.as_mut().map(gamepad_pads).unwrap_or_default();
362            let raw_pads = self.gilrs.as_ref().map(gamepads_raw).unwrap_or_default();
363            // a guest that reads the keyboard itself (KEYS_RAW) gets no keymap pads
364            if game.with_host(|h| h.input_mode) & 1 == 0 {
365                let kb = keymap::pads(&self.opts.keymap, &self.keys, gamepads);
366                for (p, k) in pads.iter_mut().zip(kb) {
367                    *p |= k;
368                }
369            }
370            let drawable = game.with_host(|h| h.gl.size().unwrap_or_else(|| h.gfx.size()));
371            for k in 0..steps {
372                game.with_host(|host| {
373                host.pads = pads;
374                // typed text, key events and relative motion go to the first frame of a catch-up batch
375                let first = k == 0;
376                host.text = Some(if first { std::mem::take(&mut self.typed) } else { String::new() });
377                let m = &mut self.mouse;
378                let pointer = Pointer {
379                    x: m.x,
380                    y: m.y,
381                    dx: if first { m.dx } else { 0.0 },
382                    dy: if first { m.dy } else { 0.0 },
383                    wheel_x: if first { m.wheel_x } else { 0.0 },
384                    wheel_y: if first { m.wheel_y } else { 0.0 },
385                    buttons: m.buttons,
386                    pressed: if first { m.pressed } else { 0 },
387                    released: if first { m.released } else { 0 },
388                    flags: m.inside as u32 | self.pointer_flags,
389                    drawable: (drawable.0 as f32, drawable.1 as f32),
390                    integer_scale: self.opts.present.integer_scale,
391                };
392                if first {
393                    (m.dx, m.dy, m.wheel_x, m.wheel_y, m.pressed, m.released) = (0.0, 0.0, 0.0, 0.0, 0, 0);
394                }
395                if first {
396                    host.input = RawInput { keys: Some(keys), key_events: std::mem::take(&mut self.key_events), pointer: Some(pointer), gamepads: Some(raw_pads.clone()) };
397                } else {
398                    host.input.key_events.clear();
399                    host.input.pointer = Some(pointer);
400                }
401                host.show_frame = k + 1 == steps;
402                host.catch_up = k + 1 < steps;
403                host.gfx.used = false;
404                });
405                let r = game.frame();
406                self.frames_run += 1;
407                let shot = self.opts.screenshot.as_ref().filter(|(n, _)| *n == self.frames_run).map(|(_, p)| p.clone());
408                let img = game.with_host(|h| {
409                    let img = shot.as_ref().and_then(|_| h.gl.read_frame());
410                    let show = h.show_frame;
411                    h.gl.end_frame(show);
412                    img
413                });
414                if let Some(path) = shot {
415                    let r = match img {
416                        Some((w, h, rgba)) => crate::headless::write_png(&path, w, h, &rgba).map(|_| eprintln!("[gasm] wrote {path}")),
417                        None => Err("--window-screenshot: only gasm:gl games".into()),
418                    };
419                    return match r {
420                        Ok(()) => self.quit(el),
421                        Err(e) => self.stop(el, Err(e)),
422                    };
423                }
424                match r {
425                    Ok(()) => {}
426                    Err(Stop::Exit(code)) => return self.stop(el, Ok(code)),
427                    Err(Stop::Trap(e)) => return self.stop(el, Err(e)),
428                }
429                self.next += period;
430                self.fps_n += 1;
431            }
432            if behind > 4 {
433                self.next = Instant::now() + period;
434            }
435            // 2D guests: show the last video_present frame.
436            let title = game.with_host(|host| {
437                if !host.gfx.used && host.width > 0 {
438                    let (w, h) = (host.width as u32, host.height as u32);
439                    let rgba = std::mem::take(&mut host.rgba);
440                    host.gfx.present_video(&rgba, w, h, host.aspect);
441                    host.rgba = rgba;
442                }
443                std::mem::take(&mut host.title_changed).then(|| host.title.clone())
444            });
445            if let Some(t) = title {
446                self.title = t.unwrap_or_else(|| self.default_title.clone());
447                self.show_title();
448            }
449        }
450        self.apply_input_mode();
451        if let Some(s) = &mut self.audio_stream {
452            s.check(); // a broken stream or a new output device: reopen
453        }
454        if self.fps_t.elapsed() >= Duration::from_secs(1) {
455            self.fps = self.fps_n;
456            self.show_title();
457            self.fps_n = 0;
458            self.fps_t = Instant::now();
459        }
460        el.set_control_flow(ControlFlow::WaitUntil(self.next));
461    }
462}
463
464impl ApplicationHandler for App {
465    fn resumed(&mut self, el: &ActiveEventLoop) {
466        if self.window.is_some() {
467            return;
468        }
469        let attrs = Window::default_attributes()
470            .with_title(format!("{} — gasm", self.title))
471            .with_inner_size(LogicalSize::new(self.opts.size.0, self.opts.size.1));
472        let window = match el.create_window(attrs) {
473            Ok(w) => Arc::new(w),
474            Err(e) => return self.stop(el, Err(format!("cannot create window: {e}"))),
475        };
476        // gasm:gl games draw with ANGLE into the window; everything else with wgpu
477        let uses_gl = self.session.as_ref().is_some_and(|s| s.uses_gl());
478        let mut gl = None;
479        let gfx = if uses_gl {
480            let native = match native_window(&window) {
481                Ok(n) => n,
482                Err(e) => return self.stop(el, Err(e)),
483            };
484            match self.session.as_ref().map(|s| s.open_gl(Some(native), (0, 0))) {
485                Some(Ok(a)) => gl = Some(a),
486                Some(Err(e)) => return self.stop(el, Err(format!("cannot initialise OpenGL ES (gasm:gl): {e}"))),
487                None => return,
488            }
489            Gfx::null()
490        } else {
491            match Gfx::for_window(window.clone()) {
492                Ok(mut g) => {
493                    g.present = self.opts.present;
494                    g
495                }
496                Err(e) => return self.stop(el, Err(format!("cannot initialise GPU: {e}"))),
497            }
498        };
499        let audio: Option<Box<dyn AudioOut>> = if self.opts.mute {
500            None
501        } else {
502            match AudioSink::open() {
503                Ok((a, stream)) => {
504                    eprintln!("[gasm] audio: {} Hz", a.device_rate());
505                    self.audio_stream = Some(stream);
506                    Some(Box::new(a))
507                }
508                Err(e) => {
509                    eprintln!("[gasm] audio disabled: {e}");
510                    None
511                }
512            }
513        };
514        self.window = Some(window);
515        let Some(session) = self.session.as_ref() else { return };
516        if self.opts.splash {
517            // the module compiles while the splash plays; the game starts after both
518            let compiling = Some(session.compile_in_background());
519            let next = Instant::now();
520            self.splash = Some(Splash { frame: 0, next, compiling, module: None, skip: false, audio, gfx, gl });
521            el.set_control_flow(ControlFlow::WaitUntil(next));
522        } else {
523            let module = crate::host::Game::compile(&session.wasm, session.load.allow_precompiled);
524            self.start_game(el, module, audio, gfx, gl);
525        }
526    }
527
528    fn window_event(&mut self, el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
529        match event {
530            WindowEvent::CloseRequested => self.quit(el),
531            WindowEvent::Resized(size) => {
532                if let Some(g) = &mut self.game {
533                    g.with_host(|h| h.gfx.resize(size.width, size.height));
534                }
535                if let Some(sp) = &mut self.splash {
536                    sp.gfx.resize(size.width, size.height);
537                }
538            }
539            WindowEvent::Focused(false) => {
540                // nothing stays held while another app has the input
541                for c in self.keys.drain() {
542                    let k = keymap::gasm_key(c);
543                    if k != 0 {
544                        self.key_events.push((k, false));
545                    }
546                }
547                self.esc_down = None;
548                self.mouse.inside = false;
549                self.mouse.released |= self.mouse.buttons;
550                self.mouse.buttons = 0;
551            }
552            WindowEvent::CursorMoved { position, .. } => {
553                self.mouse.x = position.x as f32;
554                self.mouse.y = position.y as f32;
555                self.mouse.inside = true;
556            }
557            WindowEvent::CursorEntered { .. } => self.mouse.inside = true,
558            WindowEvent::CursorLeft { .. } => self.mouse.inside = false,
559            WindowEvent::MouseInput { state, button, .. } => {
560                if let Some(sp) = &mut self.splash {
561                    sp.skip |= state == ElementState::Pressed;
562                }
563                let bit = mouse_bit(button);
564                match state {
565                    ElementState::Pressed => {
566                        self.mouse.buttons |= bit;
567                        self.mouse.pressed |= bit;
568                    }
569                    ElementState::Released => {
570                        self.mouse.buttons &= !bit;
571                        self.mouse.released |= bit;
572                    }
573                }
574            }
575            WindowEvent::MouseWheel { delta, .. } => {
576                // gasm: about 1 per wheel notch, y > 0 = down (W3C sign); winit's is the opposite
577                let (x, y) = match delta {
578                    MouseScrollDelta::LineDelta(x, y) => (x, y),
579                    MouseScrollDelta::PixelDelta(p) => (p.x as f32 / 100.0, p.y as f32 / 100.0),
580                };
581                self.mouse.wheel_x -= x;
582                self.mouse.wheel_y -= y;
583            }
584            WindowEvent::KeyboardInput { event, .. } => {
585                if let Some(sp) = &mut self.splash {
586                    sp.skip |= event.state == ElementState::Pressed;
587                }
588                if let PhysicalKey::Code(code) = event.physical_key {
589                    if code == KeyCode::Escape && !event.repeat {
590                        self.esc_down = (event.state == ElementState::Pressed).then(Instant::now);
591                    }
592                    let k = keymap::gasm_key(code);
593                    if !event.repeat && k != 0 {
594                        self.key_events.push((k, event.state == ElementState::Pressed));
595                    }
596                    match event.state {
597                        ElementState::Pressed => self.keys.insert(code),
598                        ElementState::Released => self.keys.remove(&code),
599                    };
600                }
601                if event.state == ElementState::Pressed {
602                    match &event.logical_key {
603                        Key::Named(NamedKey::Enter) => self.typed.push('\n'),
604                        Key::Named(NamedKey::Backspace) => self.typed.push('\u{8}'),
605                        _ => {
606                            if let Some(t) = &event.text {
607                                self.typed.extend(t.chars().filter(|c| !c.is_control()));
608                            }
609                        }
610                    }
611                }
612            }
613            WindowEvent::RedrawRequested => self.tick(el),
614            _ => {}
615        }
616    }
617
618    fn device_event(&mut self, _el: &ActiveEventLoop, _id: DeviceId, event: DeviceEvent) {
619        // raw (unaccelerated) motion, also while the cursor is locked
620        if let DeviceEvent::MouseMotion { delta } = event
621            && self.window.as_ref().is_some_and(|w| w.has_focus())
622        {
623            self.mouse.dx += delta.0 as f32;
624            self.mouse.dy += delta.1 as f32;
625        }
626    }
627
628    fn about_to_wait(&mut self, el: &ActiveEventLoop) {
629        self.tick(el);
630    }
631}
632