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    /// copy the game's frame to the clipboard on this key (`--copy-key`; None: off)
37    pub copy_key: Option<KeyCode>,
38    /// the window's icon, RGBA8 (`--icon`, the manifest's "icon"); macOS takes the app's
39    pub icon: Option<(Vec<u8>, u32, u32)>,
40    /// X11 WM_CLASS / Wayland app id (`--app-class`): desktops match it to a .desktop entry
41    pub app_class: Option<String>,
42}
43
44/// The splash screen, shown while the game's module compiles on another thread.
45struct Splash {
46    frame: u32,
47    next: Instant,
48    compiling: Option<std::thread::JoinHandle<Result<wasmtime::Module, Stop>>>,
49    module: Option<Result<wasmtime::Module, Stop>>,
50    /// a key or click: end it as soon as the game is ready
51    skip: bool,
52    /// what the game starts with once it's compiled
53    audio: Option<Box<dyn AudioOut>>,
54    gfx: Gfx,
55    gl: Option<crate::angle::Angle>,
56}
57
58/// The platform window handle ANGLE draws into.
59fn native_window(window: &Window) -> Result<NativeWindow, String> {
60    use winit::raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle};
61    let w = window.window_handle().map_err(|e| e.to_string())?.as_raw();
62    let d = window.display_handle().map_err(|e| e.to_string())?.as_raw();
63    Ok(match (w, d) {
64        (RawWindowHandle::AppKit(h), _) => NativeWindow::AppKit(h.ns_view.as_ptr()),
65        (RawWindowHandle::Win32(h), _) => NativeWindow::Win32(h.hwnd.get() as *mut std::ffi::c_void),
66        (RawWindowHandle::Xlib(h), RawDisplayHandle::Xlib(d)) => {
67            NativeWindow::Xlib(d.display.map_or(std::ptr::null_mut(), |p| p.as_ptr()), h.window)
68        }
69        (RawWindowHandle::Wayland(_), RawDisplayHandle::Wayland(d)) => NativeWindow::Wayland(d.display.as_ptr()),
70        _ => return Err("gasm:gl: this kind of window isn't supported".into()),
71    })
72}
73
74/// Open a window and play until the guest exits, traps or the player quits.
75/// Returns the guest's exit code (0 when the player quit).
76pub fn run(session: Session, opts: Options) -> Result<i32, String> {
77    let mut builder = EventLoop::builder();
78    // gasm:gl draws with ANGLE, which needs an X11 window here (XWayland on Wayland desktops)
79    #[cfg(all(unix, not(target_os = "macos")))]
80    if session.uses_gl() {
81        use winit::platform::x11::EventLoopBuilderExtX11;
82        builder.with_x11();
83    }
84    let event_loop = builder.build().map_err(|e| e.to_string())?;
85    // default title: the module file name without .wasm (set_title replaces it)
86    let default_title = host::static_title(&session.wasm).unwrap_or_else(|| {
87        std::path::Path::new(&session.name).file_stem().map_or(session.name.clone(), |s| s.to_string_lossy().into_owned())
88    });
89    let mut app = App {
90        title: default_title.clone(),
91        default_title,
92        fps: 0,
93        opts,
94        consent: session.consent.clone(),
95        asking: None,
96        swallowed: HashSet::new(),
97        session: Some(session),
98        window: None,
99        game: None,
100        keys: HashSet::new(),
101        typed: String::new(),
102        key_events: Vec::new(),
103        esc_down: None,
104        mouse: MouseState::default(),
105        applied_mode: 0,
106        pointer_flags: 0,
107        gilrs: Gilrs::new().ok(),
108        next: Instant::now(),
109        fps_t: Instant::now(),
110        fps_n: 0,
111        result: Ok(0),
112        frames_run: 0,
113        audio_stream: None,
114        splash: None,
115        copy_pending: false,
116        clipboard: None,
117        paste: None,
118    };
119    event_loop.run_app(&mut app).map_err(|e| e.to_string())?;
120    app.result
121}
122
123/// Gamepads in connection order, plus how many are connected.
124fn gamepad_pads(gilrs: &mut Gilrs) -> ([u32; 4], usize) {
125    const MAP: &[(Button, u32)] = &[
126        (Button::East, 1 << 0),
127        (Button::South, 1 << 1),
128        (Button::North, 1 << 2),
129        (Button::West, 1 << 3),
130        (Button::LeftTrigger, 1 << 4),
131        (Button::RightTrigger, 1 << 5),
132        (Button::Select, 1 << 6),
133        (Button::Start, 1 << 7),
134        (Button::DPadUp, 1 << 8),
135        (Button::DPadDown, 1 << 9),
136        (Button::DPadLeft, 1 << 10),
137        (Button::DPadRight, 1 << 11),
138    ];
139    while gilrs.next_event().is_some() {}
140    let mut pads = [0u32; 4];
141    let connected = gilrs.gamepads().count();
142    for (i, (_, pad)) in gilrs.gamepads().take(4).enumerate() {
143        pads[i] = MAP.iter().filter(|(b, _)| pad.is_pressed(*b)).fold(0, |m, (_, bit)| m | bit);
144        let (x, y) = (pad.value(gilrs::Axis::LeftStickX), pad.value(gilrs::Axis::LeftStickY));
145        if x < -0.5 { pads[i] |= 1 << 10 }
146        if x > 0.5 { pads[i] |= 1 << 11 }
147        if y > 0.5 { pads[i] |= 1 << 8 }
148        if y < -0.5 { pads[i] |= 1 << 9 }
149    }
150    (pads, connected)
151}
152
153/// Mouse input collected between frames (window pixels).
154#[derive(Default)]
155struct MouseState {
156    x: f32,
157    y: f32,
158    inside: bool,
159    buttons: u32,
160    /// buttons that went down / up since the last frame (a click inside one frame counts)
161    pressed: u32,
162    released: u32,
163    dx: f32,
164    dy: f32,
165    wheel_x: f32,
166    wheel_y: f32,
167}
168
169/// Hold Escape this long to quit (a tap goes to the game).
170const ESC_HOLD: Duration = Duration::from_millis(1000);
171
172fn mouse_bit(b: MouseButton) -> u32 {
173    match b {
174        MouseButton::Left => 1,
175        MouseButton::Right => 2,
176        MouseButton::Middle => 4,
177        MouseButton::Back => 8,
178        MouseButton::Forward => 16,
179        MouseButton::Other(_) => 0,
180    }
181}
182
183/// Raw gamepads (first 4 connected, the same order as the pads). Known
184/// controllers in the W3C standard layout; others raw, ordered by code.
185fn gamepads_raw(gilrs: &Gilrs) -> [Gamepad; 4] {
186    use gilrs::{Axis, MappingSource};
187    const BUTTONS: [Button; 17] = [
188        Button::South, Button::East, Button::West, Button::North, Button::LeftTrigger, Button::RightTrigger,
189        Button::LeftTrigger2, Button::RightTrigger2, Button::Select, Button::Start, Button::LeftThumb,
190        Button::RightThumb, Button::DPadUp, Button::DPadDown, Button::DPadLeft, Button::DPadRight, Button::Mode,
191    ];
192    let mut out: [Gamepad; 4] = Default::default();
193    for (slot, (_, pad)) in gilrs.gamepads().take(4).enumerate() {
194        let g = &mut out[slot];
195        g.connected = true;
196        g.name = pad.name().to_owned();
197        if pad.mapping_source() != MappingSource::None {
198            g.standard = true;
199            g.buttons = BUTTONS.iter().map(|b| pad.button_data(*b).map_or(0.0, |d| d.value())).collect();
200            // W3C: y axes point down
201            g.axes = vec![pad.value(Axis::LeftStickX), -pad.value(Axis::LeftStickY), pad.value(Axis::RightStickX), -pad.value(Axis::RightStickY)];
202        } else {
203            let mut b: Vec<_> = pad.state().buttons().map(|(c, d)| (c.into_u32(), d.value())).collect();
204            let mut a: Vec<_> = pad.state().axes().map(|(c, d)| (c.into_u32(), d.value())).collect();
205            b.sort_by_key(|x| x.0);
206            a.sort_by_key(|x| x.0);
207            g.buttons = b.into_iter().map(|x| x.1).take(host::GAMEPAD_BUTTONS).collect();
208            g.axes = a.into_iter().map(|x| x.1).take(host::GAMEPAD_AXES).collect();
209        }
210    }
211    out
212}
213
214struct App {
215    /// frames run so far (--window-screenshot)
216    frames_run: u64,
217    /// what the window shows before " — gasm": set_title, else `default_title`
218    title: String,
219    default_title: String,
220    /// guest frames in the last second
221    fps: u32,
222    opts: Options,
223    /// taken when the window opens
224    session: Option<Session>,
225    window: Option<Arc<Window>>,
226    game: Option<Game>,
227    keys: HashSet<KeyCode>,
228    /// text typed since the last frame (for text_input)
229    typed: String,
230    /// raw key presses/releases since the last frame
231    key_events: Vec<(u16, bool)>,
232    /// Escape: a tap goes to the game, holding it quits
233    esc_down: Option<Instant>,
234    mouse: MouseState,
235    /// cursor mode currently applied to the window (GASM_INPUT_POINTER_*)
236    applied_mode: u32,
237    /// GASM_POINTER_IS_HIDDEN / IS_LOCKED as actually achieved
238    pointer_flags: u32,
239    gilrs: Option<Gilrs>,
240    next: Instant,
241    fps_t: Instant,
242    fps_n: u32,
243    result: Result<i32, String>,
244    /// the audio device stream (kept alive while the game plays)
245    audio_stream: Option<AudioStream>,
246    splash: Option<Splash>,
247    /// the copy key was pressed: copy the next frame shown
248    copy_pending: bool,
249    /// kept open: on X11 and Wayland the copied image lives as long as its owner
250    clipboard: Option<arboard::Clipboard>,
251    /// the player pressed the paste key: the clipboard's text, for the next frame (gasm:clipboard)
252    paste: Option<String>,
253    /// the player's answers about hosts and saves (None: --no-ask)
254    consent: Option<crate::consent::Consent>,
255    /// the question on screen (the game waits meanwhile)
256    asking: Option<crate::consent::Subject>,
257    /// keys pressed to answer: their releases aren't the game's
258    swallowed: HashSet<KeyCode>,
259}
260
261/// The system clipboard, opened on first use.
262fn open_clipboard(clipboard: &mut Option<arboard::Clipboard>) -> Option<&mut arboard::Clipboard> {
263    if clipboard.is_none() {
264        match arboard::Clipboard::new() {
265            Ok(c) => *clipboard = Some(c),
266            Err(e) => eprintln!("[gasm] clipboard: unavailable ({e})"),
267        }
268    }
269    clipboard.as_mut()
270}
271
272/// Put a frame (RGBA8, top to bottom) on the system clipboard.
273fn copy_frame(clipboard: &mut Option<arboard::Clipboard>, w: u32, h: u32, mut rgba: Vec<u8>) {
274    // opaque, as the window shows it (GL frames can carry any alpha)
275    for px in rgba.chunks_exact_mut(4) {
276        px[3] = 255;
277    }
278    if open_clipboard(clipboard).is_none() {
279        return;
280    }
281    let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: rgba.into() };
282    match clipboard.as_mut().map(|c| c.set_image(img)) {
283        Some(Ok(())) => eprintln!("[gasm] copied the frame ({w}x{h}) to the clipboard"),
284        Some(Err(e)) => eprintln!("[gasm] copy: {e}"),
285        None => {}
286    }
287}
288
289impl App {
290
291    fn stop(&mut self, el: &ActiveEventLoop, result: Result<i32, String>) {
292        self.result = result;
293        // the event loop can tick again before it ends: never call into a guest
294        // that has exited or trapped
295        self.game = None;
296        el.exit();
297    }
298
299    /// Hide / lock the cursor as the guest asked (input_mode), when it changes.
300    /// The window title: the game's (set_title or the file name), then the runner's own part,
301    /// which the guest can't change.
302    fn show_title(&self) {
303        if let Some(w) = &self.window {
304            w.set_title(&format!("{} — gasm — {} fps", self.title, self.fps));
305        }
306    }
307
308    fn apply_input_mode(&mut self) {
309        let (Some(game), Some(w)) = (&mut self.game, &self.window) else { return };
310        let want = game.with_host(|h| h.input_mode) & 6;
311        if want == self.applied_mode {
312            return;
313        }
314        self.applied_mode = want;
315        let locked = want & 4 != 0
316            && w.set_cursor_grab(CursorGrabMode::Locked).or_else(|_| w.set_cursor_grab(CursorGrabMode::Confined)).is_ok();
317        if want & 4 == 0 {
318            let _ = w.set_cursor_grab(CursorGrabMode::None);
319        }
320        // locked implies hidden
321        w.set_cursor_visible(want == 0);
322        self.pointer_flags = if want != 0 { 2 } else { 0 } | if locked { 4 } else { 0 };
323    }
324
325    /// The player closed the window / held Esc: let the game save, then stop.
326    fn quit(&mut self, el: &ActiveEventLoop) {
327        if let Some(g) = &mut self.game {
328            g.exit();
329        }
330        self.stop(el, Ok(0));
331    }
332
333    /// One splash frame when it's due; starts the game when the splash is over and
334    /// the module compiled. Holds on the logo while it is still compiling.
335    fn tick_splash(&mut self, el: &ActiveEventLoop) {
336        let Some(sp) = &mut self.splash else { return };
337        if Instant::now() < sp.next {
338            return el.set_control_flow(ControlFlow::WaitUntil(sp.next));
339        }
340        if sp.compiling.as_ref().is_some_and(|t| t.is_finished()) {
341            let t = sp.compiling.take().expect("compile thread");
342            sp.module = Some(t.join().unwrap_or_else(|_| Err(Stop::Trap("the compiler thread panicked".into()))));
343        }
344        let ready = sp.module.is_some();
345        if sp.skip {
346            sp.frame = if ready { splash::FRAMES } else { sp.frame.max(splash::HOLD) };
347        }
348        if sp.frame >= splash::FRAMES && ready {
349            // the manifest's hosts and saves, and mods that want hosts: answered before the game starts
350            if let Some(q) = self.consent.as_ref().and_then(|c| c.lock().unwrap().question().cloned()) {
351                if self.asking.as_ref() != Some(&q) {
352                    let (wants, what) = crate::consent::describe(&q);
353                    eprintln!("[gasm] consent: {} {wants} {what}? (1: this time, 2: always, 3: not now, 4: never)", self.default_title);
354                }
355                let img = crate::prompt::image(&self.default_title, &q);
356                let (w, h) = (crate::prompt::W as u32, crate::prompt::H as u32);
357                match &sp.gl {
358                    Some(angle) => angle.show_image(&img, w, h),
359                    None => sp.gfx.present_video(&img, w, h, None),
360                }
361                self.asking = Some(q);
362                return el.set_control_flow(ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(50)));
363            }
364            self.asking = None;
365            let sp = self.splash.take().expect("splash");
366            return self.start_game(el, sp.module.expect("compiled"), sp.audio, sp.gfx, sp.gl);
367        }
368        let rgba = splash::frame(sp.frame);
369        match &sp.gl {
370            Some(angle) => angle.show_image(&rgba, splash::W as u32, splash::H as u32),
371            None => sp.gfx.present_video(&rgba, splash::W as u32, splash::H as u32, None),
372        }
373        if sp.frame != splash::HOLD || ready {
374            sp.frame += 1;
375        }
376        sp.next += Duration::from_secs_f64(1.0 / 60.0);
377        if sp.next < Instant::now() {
378            sp.next = Instant::now();
379        }
380        el.set_control_flow(ControlFlow::WaitUntil(sp.next));
381    }
382
383    /// The game begins: instantiate it (its init runs) with the window's GPU and audio.
384    fn start_game(&mut self, el: &ActiveEventLoop, module: Result<wasmtime::Module, Stop>, audio: Option<Box<dyn AudioOut>>, gfx: Gfx, gl: Option<crate::angle::Angle>) {
385        let Some(session) = self.session.take() else { return };
386        let started = match module {
387            Ok(m) => session.start_compiled(m, audio, gfx, gl),
388            Err(e) => Err(e),
389        };
390        match started {
391            Ok(g) => self.game = Some(g),
392            Err(Stop::Exit(code)) => return self.stop(el, Ok(code)),
393            Err(Stop::Trap(e)) => return self.stop(el, Err(e)),
394        }
395        // what the player pressed during the splash isn't the game's
396        self.typed.clear();
397        self.key_events.clear();
398        self.mouse.pressed = 0;
399        self.mouse.released = 0;
400        self.next = Instant::now();
401        el.set_control_flow(ControlFlow::WaitUntil(self.next));
402    }
403
404    fn tick(&mut self, el: &ActiveEventLoop) {
405        if self.splash.is_some() {
406            if self.esc_down.is_some_and(|t| t.elapsed() >= ESC_HOLD) {
407                return self.quit(el);
408            }
409            return self.tick_splash(el);
410        }
411        let Some(game) = &mut self.game else { return };
412        let period = Duration::from_secs_f64(1.0 / game.with_host(|h| h.frame_rate));
413        let now = Instant::now();
414        // a question for the player (a host, saving files): the game waits for the answer
415        if let Some(q) = self.consent.as_ref().and_then(|c| c.lock().unwrap().question().cloned()) {
416            if self.esc_down.is_some_and(|t| t.elapsed() >= ESC_HOLD) {
417                return self.quit(el);
418            }
419            if self.asking.as_ref() != Some(&q) {
420                let (wants, what) = crate::consent::describe(&q);
421                eprintln!("[gasm] consent: {} {wants} {what}? (1: this time, 2: always, 3: not now, 4: never)", self.default_title);
422            }
423            let img = crate::prompt::image(&self.default_title, &q);
424            let (w, h) = (crate::prompt::W as u32, crate::prompt::H as u32);
425            game.with_host(|host| {
426                if !host.gl.show_over_game(&img, w, h) {
427                    host.gfx.present_video(&img, w, h, None);
428                }
429            });
430            self.asking = Some(q);
431            self.next = now + period; // no catch-up for the time spent asking
432            return el.set_control_flow(ControlFlow::WaitUntil(now + Duration::from_millis(50)));
433        }
434        self.asking = None;
435        if now >= self.next {
436            // Fixed timestep: catch up at most 4 frames, only the last one is shown.
437            let behind = ((now - self.next).as_secs_f64() / period.as_secs_f64()) as u32 + 1;
438            let steps = behind.min(4);
439            // Escape held long enough: quit (a short tap went to the game as a key)
440            if self.esc_down.is_some_and(|t| t.elapsed() >= ESC_HOLD) {
441                return self.quit(el);
442            }
443            let mut keys = [0u8; KEY_STATE_BYTES];
444            for &c in &self.keys {
445                let k = keymap::gasm_key(c);
446                if k != 0 {
447                    keys[k as usize / 8] |= 1 << (k % 8);
448                }
449            }
450            // gamepads are sampled once per tick: pads and raw state agree within a batch
451            let (mut pads, gamepads) = self.gilrs.as_mut().map(gamepad_pads).unwrap_or_default();
452            let raw_pads = self.gilrs.as_ref().map(gamepads_raw).unwrap_or_default();
453            // a guest that reads the keyboard itself (KEYS_RAW) gets no keymap pads
454            if game.with_host(|h| h.input_mode) & 1 == 0 {
455                let kb = keymap::pads(&self.opts.keymap, &self.keys, gamepads);
456                for (p, k) in pads.iter_mut().zip(kb) {
457                    *p |= k;
458                }
459            }
460            let drawable = game.with_host(|h| h.gl.size().unwrap_or_else(|| h.gfx.size()));
461            for k in 0..steps {
462                game.with_host(|host| {
463                host.pads = pads;
464                // typed text, key events and relative motion go to the first frame of a catch-up batch
465                let first = k == 0;
466                host.text = Some(if first { std::mem::take(&mut self.typed) } else { String::new() });
467                let m = &mut self.mouse;
468                let pointer = Pointer {
469                    x: m.x,
470                    y: m.y,
471                    dx: if first { m.dx } else { 0.0 },
472                    dy: if first { m.dy } else { 0.0 },
473                    wheel_x: if first { m.wheel_x } else { 0.0 },
474                    wheel_y: if first { m.wheel_y } else { 0.0 },
475                    buttons: m.buttons,
476                    pressed: if first { m.pressed } else { 0 },
477                    released: if first { m.released } else { 0 },
478                    flags: m.inside as u32 | self.pointer_flags,
479                    drawable: (drawable.0 as f32, drawable.1 as f32),
480                    integer_scale: self.opts.present.integer_scale,
481                };
482                if first {
483                    (m.dx, m.dy, m.wheel_x, m.wheel_y, m.pressed, m.released) = (0.0, 0.0, 0.0, 0.0, 0, 0);
484                }
485                if first {
486                    host.input = RawInput { keys: Some(keys), key_events: std::mem::take(&mut self.key_events), pointer: Some(pointer), gamepads: Some(raw_pads.clone()) };
487                    // pasted text goes with the frame that carries the paste key press
488                    host.clipboard.pasted = self.paste.take();
489                } else {
490                    host.input.key_events.clear();
491                    host.clipboard.pasted = None;
492                    host.input.pointer = Some(pointer);
493                }
494                host.show_frame = k + 1 == steps;
495                host.catch_up = k + 1 < steps;
496                host.gfx.used = false;
497                });
498                let r = game.frame();
499                self.frames_run += 1;
500                let shot = self.opts.screenshot.as_ref().filter(|(n, _)| *n == self.frames_run).map(|(_, p)| p.clone());
501                let copy = std::mem::take(&mut self.copy_pending);
502                let (img, copied, text) = game.with_host(|h| {
503                    let img = shot.as_ref().and_then(|_| h.gl.read_frame());
504                    // the copy key: the GL frame, else the last 2D frame
505                    let copied = copy.then(|| h.gl.read_frame().or_else(|| (h.width > 0).then(|| (h.width as u32, h.height as u32, h.rgba.clone())))).flatten();
506                    let show = h.show_frame;
507                    h.gl.end_frame(show);
508                    (img, copied, h.clipboard.copied.take())
509                });
510                if let Some(t) = text {
511                    if let Some(Err(e)) = open_clipboard(&mut self.clipboard).map(|c| c.set_text(t)) {
512                        eprintln!("[gasm] clipboard: {e}");
513                    }
514                }
515                match copied {
516                    Some((w, h, rgba)) => copy_frame(&mut self.clipboard, w, h, rgba),
517                    None if copy => eprintln!("[gasm] copy: nothing to copy (gasm:gfx games can't be copied yet)"),
518                    None => {}
519                }
520                if let Some(path) = shot {
521                    let r = match img {
522                        Some((w, h, rgba)) => crate::headless::write_png(&path, w, h, &rgba).map(|_| eprintln!("[gasm] wrote {path}")),
523                        None => Err("--window-screenshot: only gasm:gl games".into()),
524                    };
525                    return match r {
526                        Ok(()) => self.quit(el),
527                        Err(e) => self.stop(el, Err(e)),
528                    };
529                }
530                match r {
531                    Ok(()) => {}
532                    Err(Stop::Exit(code)) => return self.stop(el, Ok(code)),
533                    Err(Stop::Trap(e)) => return self.stop(el, Err(e)),
534                }
535                self.next += period;
536                self.fps_n += 1;
537            }
538            if behind > 4 {
539                self.next = Instant::now() + period;
540            }
541            // 2D guests: show the last video_present frame.
542            let title = game.with_host(|host| {
543                if !host.gfx.used && host.width > 0 {
544                    let (w, h) = (host.width as u32, host.height as u32);
545                    let rgba = std::mem::take(&mut host.rgba);
546                    host.gfx.present_video(&rgba, w, h, host.aspect);
547                    host.rgba = rgba;
548                }
549                std::mem::take(&mut host.title_changed).then(|| host.title.clone())
550            });
551            if let Some(t) = title {
552                self.title = t.unwrap_or_else(|| self.default_title.clone());
553                self.show_title();
554            }
555        }
556        self.apply_input_mode();
557        if let Some(s) = &mut self.audio_stream {
558            s.check(); // a broken stream or a new output device: reopen
559        }
560        if self.fps_t.elapsed() >= Duration::from_secs(1) {
561            self.fps = self.fps_n;
562            self.show_title();
563            self.fps_n = 0;
564            self.fps_t = Instant::now();
565        }
566        el.set_control_flow(ControlFlow::WaitUntil(self.next));
567    }
568}
569
570impl ApplicationHandler for App {
571    fn resumed(&mut self, el: &ActiveEventLoop) {
572        if self.window.is_some() {
573            return;
574        }
575        let mut attrs = Window::default_attributes()
576            .with_title(format!("{} — gasm", self.title))
577            .with_inner_size(LogicalSize::new(self.opts.size.0, self.opts.size.1));
578        if let Some((rgba, w, h)) = &self.opts.icon {
579            match winit::window::Icon::from_rgba(rgba.clone(), *w, *h) {
580                Ok(icon) => attrs = attrs.with_window_icon(Some(icon)),
581                Err(e) => eprintln!("[gasm] icon: {e}"),
582            }
583        }
584        #[cfg(all(unix, not(target_os = "macos")))]
585        if let Some(class) = &self.opts.app_class {
586            use winit::platform::{wayland::WindowAttributesExtWayland, x11::WindowAttributesExtX11};
587            attrs = WindowAttributesExtX11::with_name(attrs, class.clone(), class.clone());
588            attrs = WindowAttributesExtWayland::with_name(attrs, class.clone(), class.clone());
589        }
590        let window = match el.create_window(attrs) {
591            Ok(w) => Arc::new(w),
592            Err(e) => return self.stop(el, Err(format!("cannot create window: {e}"))),
593        };
594        // gasm:gl games draw with ANGLE into the window; everything else with wgpu
595        let uses_gl = self.session.as_ref().is_some_and(|s| s.uses_gl());
596        let mut gl = None;
597        let gfx = if uses_gl {
598            let native = match native_window(&window) {
599                Ok(n) => n,
600                Err(e) => return self.stop(el, Err(e)),
601            };
602            match self.session.as_ref().map(|s| s.open_gl(Some(native), (0, 0))) {
603                Some(Ok(a)) => gl = Some(a),
604                Some(Err(e)) => return self.stop(el, Err(format!("cannot initialise OpenGL ES (gasm:gl): {e}"))),
605                None => return,
606            }
607            Gfx::null()
608        } else {
609            match Gfx::for_window(window.clone()) {
610                Ok(mut g) => {
611                    g.present = self.opts.present;
612                    g
613                }
614                Err(e) => return self.stop(el, Err(format!("cannot initialise GPU: {e}"))),
615            }
616        };
617        let audio: Option<Box<dyn AudioOut>> = if self.opts.mute {
618            None
619        } else {
620            match AudioSink::open() {
621                Ok((a, stream)) => {
622                    eprintln!("[gasm] audio: {} Hz", a.device_rate());
623                    self.audio_stream = Some(stream);
624                    Some(Box::new(a))
625                }
626                Err(e) => {
627                    eprintln!("[gasm] audio disabled: {e}");
628                    None
629                }
630            }
631        };
632        self.window = Some(window);
633        let Some(session) = self.session.as_ref() else { return };
634        // questions before the start (manifest, mods) go through the splash's path too, at its end
635        let asking = self.consent.as_ref().is_some_and(|c| c.lock().unwrap().asking());
636        if self.opts.splash || asking {
637            // the module compiles while the splash plays; the game starts after both
638            let compiling = Some(session.compile_in_background());
639            let next = Instant::now();
640            let frame = if self.opts.splash { 0 } else { splash::FRAMES };
641            self.splash = Some(Splash { frame, next, compiling, module: None, skip: false, audio, gfx, gl });
642            el.set_control_flow(ControlFlow::WaitUntil(next));
643        } else {
644            let module = crate::host::Game::compile(&session.wasm, session.load.allow_precompiled);
645            self.start_game(el, module, audio, gfx, gl);
646        }
647    }
648
649    fn window_event(&mut self, el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
650        match event {
651            WindowEvent::CloseRequested => self.quit(el),
652            WindowEvent::Resized(size) => {
653                if let Some(g) = &mut self.game {
654                    g.with_host(|h| h.gfx.resize(size.width, size.height));
655                }
656                if let Some(sp) = &mut self.splash {
657                    sp.gfx.resize(size.width, size.height);
658                }
659            }
660            WindowEvent::Focused(false) => {
661                // nothing stays held while another app has the input
662                for c in self.keys.drain() {
663                    let k = keymap::gasm_key(c);
664                    if k != 0 {
665                        self.key_events.push((k, false));
666                    }
667                }
668                self.esc_down = None;
669                self.mouse.inside = false;
670                self.mouse.released |= self.mouse.buttons;
671                self.mouse.buttons = 0;
672            }
673            WindowEvent::CursorMoved { position, .. } => {
674                self.mouse.x = position.x as f32;
675                self.mouse.y = position.y as f32;
676                self.mouse.inside = true;
677            }
678            WindowEvent::CursorEntered { .. } => self.mouse.inside = true,
679            WindowEvent::CursorLeft { .. } => self.mouse.inside = false,
680            WindowEvent::MouseInput { state, button, .. } => {
681                if let Some(sp) = &mut self.splash {
682                    sp.skip |= state == ElementState::Pressed;
683                }
684                let bit = mouse_bit(button);
685                match state {
686                    ElementState::Pressed => {
687                        self.mouse.buttons |= bit;
688                        self.mouse.pressed |= bit;
689                    }
690                    ElementState::Released => {
691                        self.mouse.buttons &= !bit;
692                        self.mouse.released |= bit;
693                    }
694                }
695            }
696            WindowEvent::MouseWheel { delta, .. } => {
697                // gasm: about 1 per wheel notch, y > 0 = down (W3C sign); winit's is the opposite
698                let (x, y) = match delta {
699                    MouseScrollDelta::LineDelta(x, y) => (x, y),
700                    MouseScrollDelta::PixelDelta(p) => (p.x as f32 / 100.0, p.y as f32 / 100.0),
701                };
702                self.mouse.wheel_x -= x;
703                self.mouse.wheel_y -= y;
704            }
705            WindowEvent::KeyboardInput { event, .. } => {
706                if let Some(sp) = &mut self.splash {
707                    sp.skip |= event.state == ElementState::Pressed;
708                }
709                // the consent question takes the keys: 1-4, Esc (holding Esc still quits)
710                if let PhysicalKey::Code(code) = event.physical_key {
711                    if event.state == ElementState::Released && self.swallowed.remove(&code) {
712                        if code == KeyCode::Escape {
713                            self.esc_down = None;
714                        }
715                        return;
716                    }
717                    if let (Some(q), ElementState::Pressed) = (self.asking.clone(), event.state) {
718                        use crate::consent::Answer;
719                        let answer = match code {
720                            KeyCode::Digit1 | KeyCode::Numpad1 => Some(Answer::ThisTime),
721                            KeyCode::Digit2 | KeyCode::Numpad2 => Some(Answer::Always),
722                            KeyCode::Digit3 | KeyCode::Numpad3 | KeyCode::Escape => Some(Answer::No),
723                            KeyCode::Digit4 | KeyCode::Numpad4 => Some(Answer::Never),
724                            _ => None,
725                        };
726                        if code == KeyCode::Escape && !event.repeat {
727                            self.esc_down = Some(Instant::now());
728                        }
729                        self.swallowed.insert(code);
730                        if let (Some(a), Some(c)) = (answer, &self.consent) {
731                            if !event.repeat {
732                                c.lock().unwrap().answer(&q, a);
733                            }
734                        }
735                        return;
736                    }
737                }
738                if let PhysicalKey::Code(code) = event.physical_key {
739                    if code == KeyCode::Escape && !event.repeat {
740                        self.esc_down = (event.state == ElementState::Pressed).then(Instant::now);
741                    }
742                    // the copy key also goes to the game
743                    if Some(code) == self.opts.copy_key && event.state == ElementState::Pressed && !event.repeat {
744                        self.copy_pending = true;
745                    }
746                    // the paste key (Ctrl+V, Cmd+V): the game may read the clipboard's text in the next frame
747                    let modifier = [KeyCode::ControlLeft, KeyCode::ControlRight, KeyCode::SuperLeft, KeyCode::SuperRight].iter().any(|k| self.keys.contains(k));
748                    if code == KeyCode::KeyV && modifier && event.state == ElementState::Pressed {
749                        self.paste = open_clipboard(&mut self.clipboard)
750                            .and_then(|c| c.get_text().ok())
751                            .filter(|t| t.len() <= host::CLIPBOARD_MAX);
752                    }
753                    let k = keymap::gasm_key(code);
754                    if !event.repeat && k != 0 {
755                        self.key_events.push((k, event.state == ElementState::Pressed));
756                    }
757                    match event.state {
758                        ElementState::Pressed => self.keys.insert(code),
759                        ElementState::Released => self.keys.remove(&code),
760                    };
761                }
762                if event.state == ElementState::Pressed {
763                    match &event.logical_key {
764                        Key::Named(NamedKey::Enter) => self.typed.push('\n'),
765                        Key::Named(NamedKey::Backspace) => self.typed.push('\u{8}'),
766                        _ => {
767                            if let Some(t) = &event.text {
768                                self.typed.extend(t.chars().filter(|c| !c.is_control()));
769                            }
770                        }
771                    }
772                }
773            }
774            WindowEvent::RedrawRequested => self.tick(el),
775            _ => {}
776        }
777    }
778
779    fn device_event(&mut self, _el: &ActiveEventLoop, _id: DeviceId, event: DeviceEvent) {
780        // raw (unaccelerated) motion, also while the cursor is locked
781        if let DeviceEvent::MouseMotion { delta } = event
782            && self.window.as_ref().is_some_and(|w| w.has_focus())
783        {
784            self.mouse.dx += delta.0 as f32;
785            self.mouse.dy += delta.1 as f32;
786        }
787    }
788
789    fn about_to_wait(&mut self, el: &ActiveEventLoop) {
790        self.tick(el);
791    }
792}
793
794
795#[cfg(test)]
796mod tests {
797    /// Needs a desktop session (CI has no clipboard): `cargo test -- --ignored copy_frame`
798    #[test]
799    #[ignore]
800    fn copy_frame_puts_the_image_on_the_clipboard() {
801        let rgba: Vec<u8> = (0..4 * 3 * 4).map(|i| (i * 7) as u8 | 3).collect();
802        let mut clipboard = None;
803        super::copy_frame(&mut clipboard, 4, 3, rgba.clone());
804        let img = clipboard.as_mut().unwrap().get_image().unwrap();
805        assert_eq!((img.width, img.height), (4, 3));
806        let opaque: Vec<u8> = rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2], 255]).collect();
807        assert_eq!(img.bytes.into_owned(), opaque);
808    }
809}