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