Skip to main content

fenestra_shell/
window.rs

1//! The windowed runners: winit event loop + wgpu surface + vello renderer.
2//!
3//! [`run_scene`] paints via a raw scene callback (no input). [`run_static`]
4//! runs an element view with scrolling and animation frames; the full `App`
5//! runner with messages arrives in M4 and builds on the same plumbing.
6
7use std::sync::Arc;
8#[cfg(not(target_arch = "wasm32"))]
9use std::time::{Duration, Instant};
10#[cfg(target_arch = "wasm32")]
11use web_time::Instant;
12
13#[cfg(not(target_arch = "wasm32"))]
14use fenestra_core::Theme;
15use fenestra_core::{
16    App, Element, Fonts, FrameState, InputEvent, Key, KeyInput, build_frame, dispatch,
17    refresh_hover,
18};
19use kurbo::Point;
20use vello::peniko::Color;
21use vello::util::{RenderContext, RenderSurface};
22use vello::wgpu::{self, CurrentSurfaceTexture};
23use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene};
24use winit::application::ApplicationHandler;
25use winit::dpi::LogicalSize;
26use winit::event::{MouseScrollDelta, StartCause, WindowEvent};
27#[cfg(not(target_arch = "wasm32"))]
28use winit::event_loop::EventLoopProxy;
29use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
30use winit::window::{Window, WindowId};
31
32use crate::ShellError;
33
34/// One wheel "line" in logical pixels.
35pub(crate) const LINE_SCROLL_PX: f64 = 40.0;
36
37/// A raw paint callback: `(scene, logical_w, logical_h, background)`.
38#[cfg(not(target_arch = "wasm32"))]
39type PaintFn = Box<dyn FnMut(&mut Scene, f64, f64, Color)>;
40/// A message-free element view function.
41#[cfg(not(target_arch = "wasm32"))]
42type ViewFn = Box<dyn Fn(&Theme) -> Element<()>>;
43
44/// Options for the application window.
45#[derive(Debug, Clone)]
46pub struct WindowOptions {
47    /// Window title.
48    pub title: String,
49    /// Initial inner size in logical pixels.
50    pub inner_size: (f64, f64),
51    /// Minimum inner size in logical pixels.
52    pub min_size: Option<(f64, f64)>,
53    /// Whether the window can be resized (true by default).
54    pub resizable: bool,
55    /// Open maximized.
56    pub maximized: bool,
57    /// Open borderless-fullscreen on the current monitor.
58    pub fullscreen: bool,
59    /// Window icon as straight-alpha RGBA8 `(width, height, pixels)`.
60    pub icon: Option<(u32, u32, Vec<u8>)>,
61    /// Custom faces registered on the runner's fonts before the first
62    /// frame: design languages work in windows, not just headlessly.
63    pub fonts: Vec<(fenestra_core::FamilyRole, Vec<u8>)>,
64}
65
66impl WindowOptions {
67    /// A window with the given title and the default 1024x768 logical size.
68    pub fn titled(title: impl Into<String>) -> Self {
69        Self {
70            title: title.into(),
71            inner_size: (1024.0, 768.0),
72            min_size: None,
73            resizable: true,
74            maximized: false,
75            fullscreen: false,
76            icon: None,
77            fonts: Vec::new(),
78        }
79    }
80
81    /// Sets the initial inner size in logical pixels.
82    pub fn with_size(mut self, width: f64, height: f64) -> Self {
83        self.inner_size = (width, height);
84        self
85    }
86
87    /// Sets the minimum inner size in logical pixels.
88    pub fn with_min_size(mut self, width: f64, height: f64) -> Self {
89        self.min_size = Some((width, height));
90        self
91    }
92
93    /// Allows or forbids resizing (allowed by default).
94    pub fn with_resizable(mut self, resizable: bool) -> Self {
95        self.resizable = resizable;
96        self
97    }
98
99    /// Opens the window maximized.
100    pub fn maximized(mut self) -> Self {
101        self.maximized = true;
102        self
103    }
104
105    /// Opens borderless-fullscreen on the current monitor.
106    pub fn fullscreen(mut self) -> Self {
107        self.fullscreen = true;
108        self
109    }
110
111    /// Sets the window icon from straight-alpha RGBA8 pixels (ignored on
112    /// platforms without window icons, including the web).
113    pub fn with_icon(mut self, width: u32, height: u32, rgba: Vec<u8>) -> Self {
114        self.icon = Some((width, height, rgba));
115        self
116    }
117
118    /// Registers a custom face under a family role for this window's
119    /// fonts (TTF/OTF bytes; see `Fonts::register`).
120    pub fn with_font(mut self, role: fenestra_core::FamilyRole, data: Vec<u8>) -> Self {
121        self.fonts.push((role, data));
122        self
123    }
124}
125
126enum RenderState {
127    Active {
128        surface: Box<RenderSurface<'static>>,
129        valid_surface: bool,
130        window: Arc<Window>,
131    },
132    Suspended(Option<Arc<Window>>),
133    /// Window created; the async surface setup is in flight (web only).
134    #[cfg(target_arch = "wasm32")]
135    Pending(Arc<Window>),
136}
137
138/// Shared surface plumbing for every windowed runner.
139struct WindowShell {
140    context: RenderContext,
141    renderers: Vec<Option<Renderer>>,
142    state: RenderState,
143    scene: Scene,
144    options: WindowOptions,
145    background: Color,
146    /// Completed async surface setup, parked until the next [`Self::pump`]
147    /// (web only; the web is single-threaded so `Rc<RefCell>` suffices).
148    #[cfg(target_arch = "wasm32")]
149    ready: WasmReady,
150}
151
152/// The handoff slot for the web's async surface creation.
153#[cfg(target_arch = "wasm32")]
154type WasmReady =
155    std::rc::Rc<std::cell::RefCell<Option<(RenderContext, Box<RenderSurface<'static>>)>>>;
156
157impl WindowShell {
158    fn new(options: WindowOptions, background: Color) -> Self {
159        Self {
160            context: RenderContext::new(),
161            renderers: Vec::new(),
162            state: RenderState::Suspended(None),
163            scene: Scene::new(),
164            options,
165            background,
166            #[cfg(target_arch = "wasm32")]
167            ready: WasmReady::default(),
168        }
169    }
170
171    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
172        self.resumed_with(event_loop, |_, _| {});
173    }
174
175    /// Like [`Self::resumed`], but runs `before_visible` between window
176    /// creation and the first `set_visible(true)` — the AccessKit adapter
177    /// must attach while the window is still hidden.
178    fn resumed_with(
179        &mut self,
180        event_loop: &ActiveEventLoop,
181        before_visible: impl FnOnce(&ActiveEventLoop, &Arc<Window>),
182    ) {
183        let RenderState::Suspended(cached_window) = &mut self.state else {
184            return;
185        };
186        let window = cached_window.take().unwrap_or_else(|| {
187            let attrs = Window::default_attributes()
188                .with_title(self.options.title.clone())
189                .with_inner_size(LogicalSize::new(
190                    self.options.inner_size.0,
191                    self.options.inner_size.1,
192                ))
193                .with_resizable(self.options.resizable)
194                .with_maximized(self.options.maximized)
195                .with_visible(false);
196            let attrs = match self.options.min_size {
197                Some((w, h)) => attrs.with_min_inner_size(LogicalSize::new(w, h)),
198                None => attrs,
199            };
200            let attrs = if self.options.fullscreen {
201                attrs.with_fullscreen(Some(winit::window::Fullscreen::Borderless(None)))
202            } else {
203                attrs
204            };
205            #[cfg(not(target_arch = "wasm32"))]
206            let attrs = match self.options.icon.clone() {
207                Some((w, h, rgba)) => match winit::window::Icon::from_rgba(rgba, w, h) {
208                    Ok(icon) => attrs.with_window_icon(Some(icon)),
209                    // Malformed icon data: open without one, never panic.
210                    Err(_) => attrs,
211                },
212                None => attrs,
213            };
214            #[cfg(target_arch = "wasm32")]
215            let attrs = {
216                use winit::platform::web::WindowAttributesExtWebSys;
217                // winit creates the canvas; have it inserted into the page.
218                attrs.with_append(true)
219            };
220            Arc::new(
221                event_loop
222                    .create_window(attrs)
223                    .expect("failed to create window"),
224            )
225        });
226        before_visible(event_loop, &window);
227        let was_hidden = window.is_visible() == Some(false);
228        self.activate(window.clone());
229        if was_hidden {
230            window.set_visible(true);
231        }
232    }
233
234    /// Builds (or rebuilds, after a lost surface) the swapchain for `window`
235    /// and enters the active state.
236    #[cfg(not(target_arch = "wasm32"))]
237    fn activate(&mut self, window: Arc<Window>) {
238        let size = window.inner_size();
239        let surface = pollster::block_on(self.context.create_surface(
240            window.clone(),
241            size.width.max(1),
242            size.height.max(1),
243            wgpu::PresentMode::AutoVsync,
244        ))
245        .expect("failed to create wgpu surface");
246
247        self.renderers
248            .resize_with(self.context.devices.len(), || None);
249        self.renderers[surface.dev_id].get_or_insert_with(|| {
250            Renderer::new(
251                &self.context.devices[surface.dev_id].device,
252                RendererOptions {
253                    use_cpu: false,
254                    antialiasing_support: AaSupport::area_only(),
255                    ..Default::default()
256                },
257            )
258            .expect("failed to create vello renderer")
259        });
260
261        self.state = RenderState::Active {
262            surface: Box::new(surface),
263            valid_surface: size.width != 0 && size.height != 0,
264            window,
265        };
266    }
267
268    /// Web: surface/device setup is async — kick it off and park in
269    /// `Pending`; [`Self::pump`] finishes the activation when it lands.
270    #[cfg(target_arch = "wasm32")]
271    fn activate(&mut self, window: Arc<Window>) {
272        let size = window.inner_size();
273        let ready = std::rc::Rc::clone(&self.ready);
274        let win = window.clone();
275        wasm_bindgen_futures::spawn_local(async move {
276            let mut context = RenderContext::new();
277            let surface = context
278                .create_surface(
279                    win.clone(),
280                    size.width.max(1),
281                    size.height.max(1),
282                    wgpu::PresentMode::AutoVsync,
283                )
284                .await
285                .expect("failed to create wgpu surface");
286            *ready.borrow_mut() = Some((context, Box::new(surface)));
287            win.request_redraw();
288        });
289        self.state = RenderState::Pending(window);
290    }
291
292    /// Completes a pending web activation once the async setup finished.
293    /// No-op on native and while nothing is pending.
294    fn pump(&mut self) {
295        #[cfg(target_arch = "wasm32")]
296        if let RenderState::Pending(window) = &self.state
297            && let Some((context, surface)) = self.ready.borrow_mut().take()
298        {
299            let window = window.clone();
300            self.context = context;
301            self.renderers.clear();
302            self.renderers
303                .resize_with(self.context.devices.len(), || None);
304            self.renderers[surface.dev_id].get_or_insert_with(|| {
305                Renderer::new(
306                    &self.context.devices[surface.dev_id].device,
307                    RendererOptions {
308                        use_cpu: false,
309                        antialiasing_support: AaSupport::area_only(),
310                        ..Default::default()
311                    },
312                )
313                .expect("failed to create vello renderer")
314            });
315            let size = window.inner_size();
316            self.state = RenderState::Active {
317                surface,
318                valid_surface: size.width != 0 && size.height != 0,
319                window,
320            };
321        }
322    }
323
324    fn suspended(&mut self) {
325        if let RenderState::Active { window, .. } = &self.state {
326            self.state = RenderState::Suspended(Some(window.clone()));
327        }
328    }
329
330    fn window(&self) -> Option<&Arc<Window>> {
331        match &self.state {
332            RenderState::Active { window, .. } => Some(window),
333            _ => None,
334        }
335    }
336
337    fn resized(&mut self, width: u32, height: u32) {
338        let RenderState::Active {
339            surface,
340            valid_surface,
341            window,
342        } = &mut self.state
343        else {
344            return;
345        };
346        if width != 0 && height != 0 {
347            self.context.resize_surface(surface, width, height);
348            *valid_surface = true;
349        } else {
350            *valid_surface = false;
351        }
352        window.request_redraw();
353    }
354
355    /// Logical size and scale factor of the active window.
356    fn logical_size(&self) -> Option<(f64, f64, f64)> {
357        match &self.state {
358            RenderState::Active {
359                surface, window, ..
360            } => {
361                let scale = window.scale_factor();
362                Some((
363                    f64::from(surface.config.width) / scale,
364                    f64::from(surface.config.height) / scale,
365                    scale,
366                ))
367            }
368            _ => None,
369        }
370    }
371
372    /// Scales the logical fragment to physical pixels and presents it.
373    fn present(&mut self, fragment: &Scene) {
374        let RenderState::Active {
375            surface,
376            valid_surface,
377            window,
378        } = &mut self.state
379        else {
380            return;
381        };
382        if !*valid_surface {
383            return;
384        }
385        let width = surface.config.width;
386        let height = surface.config.height;
387        let scale = window.scale_factor();
388
389        self.scene.reset();
390        self.scene
391            .append(fragment, Some(vello::kurbo::Affine::scale(scale)));
392
393        let handle = &self.context.devices[surface.dev_id];
394        self.renderers[surface.dev_id]
395            .as_mut()
396            .expect("renderer exists for surface device")
397            .render_to_texture(
398                &handle.device,
399                &handle.queue,
400                &self.scene,
401                &surface.target_view,
402                &RenderParams {
403                    base_color: self.background,
404                    width,
405                    height,
406                    antialiasing_method: AaConfig::Area,
407                },
408            )
409            .expect("vello render failed");
410
411        let surface_texture = match surface.surface.get_current_texture() {
412            CurrentSurfaceTexture::Success(texture) => texture,
413            CurrentSurfaceTexture::Outdated | CurrentSurfaceTexture::Suboptimal(_) => {
414                self.context.configure_surface(surface);
415                window.request_redraw();
416                return;
417            }
418            CurrentSurfaceTexture::Occluded => {
419                // Hidden window: skip the frame; WindowEvent::Occluded(false)
420                // requests the next redraw when it becomes visible again.
421                return;
422            }
423            CurrentSurfaceTexture::Timeout => {
424                window.request_redraw();
425                return;
426            }
427            CurrentSurfaceTexture::Lost => {
428                // Recoverable (GPU reset, driver update, display change):
429                // rebuild the swapchain on the same window and repaint.
430                let window = window.clone();
431                window.request_redraw();
432                self.activate(window);
433                return;
434            }
435            CurrentSurfaceTexture::Validation => {
436                panic!("validation error acquiring wgpu surface texture")
437            }
438        };
439
440        let mut encoder = handle
441            .device
442            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
443                label: Some("fenestra surface blit"),
444            });
445        surface.blitter.copy(
446            &handle.device,
447            &mut encoder,
448            &surface.target_view,
449            &surface_texture
450                .texture
451                .create_view(&wgpu::TextureViewDescriptor::default()),
452        );
453        handle.queue.submit([encoder.finish()]);
454        surface_texture.present();
455        handle.device.poll(wgpu::PollType::Poll).unwrap();
456    }
457}
458
459// ------------------------------------------------------------- run_scene
460
461/// Opens a window and repaints via `paint(scene, logical_w, logical_h, bg)`
462/// on every redraw. Blocks until the window closes. Low-level escape hatch;
463/// element views should prefer [`run_static`] (or the M4 `App` runner).
464#[cfg(not(target_arch = "wasm32"))]
465pub fn run_scene(
466    options: WindowOptions,
467    background: Color,
468    paint: impl FnMut(&mut Scene, f64, f64, Color) + 'static,
469) -> Result<(), ShellError> {
470    let event_loop = EventLoop::new().map_err(ShellError::EventLoop)?;
471    let mut app = SceneApp {
472        shell: WindowShell::new(options, background),
473        fragment: Scene::new(),
474        paint: Box::new(paint),
475    };
476    event_loop.run_app(&mut app).map_err(ShellError::EventLoop)
477}
478
479#[cfg(not(target_arch = "wasm32"))]
480struct SceneApp {
481    shell: WindowShell,
482    fragment: Scene,
483    paint: PaintFn,
484}
485
486#[cfg(not(target_arch = "wasm32"))]
487impl ApplicationHandler for SceneApp {
488    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
489        self.shell.resumed(event_loop);
490    }
491
492    fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
493        self.shell.suspended();
494    }
495
496    fn window_event(
497        &mut self,
498        event_loop: &ActiveEventLoop,
499        window_id: WindowId,
500        event: WindowEvent,
501    ) {
502        if self.shell.window().is_none_or(|w| w.id() != window_id) {
503            return;
504        }
505        match event {
506            WindowEvent::CloseRequested => event_loop.exit(),
507            WindowEvent::Resized(size) => self.shell.resized(size.width, size.height),
508            WindowEvent::ScaleFactorChanged { .. } => {
509                if let Some(w) = self.shell.window() {
510                    w.request_redraw();
511                }
512            }
513            WindowEvent::Occluded(occluded) => {
514                if !occluded && let Some(w) = self.shell.window() {
515                    w.request_redraw();
516                }
517            }
518            WindowEvent::RedrawRequested => {
519                let Some((lw, lh, _scale)) = self.shell.logical_size() else {
520                    return;
521                };
522                self.fragment.reset();
523                let bg = self.shell.background;
524                (self.paint)(&mut self.fragment, lw, lh, bg);
525                let fragment = std::mem::replace(&mut self.fragment, Scene::new());
526                self.shell.present(&fragment);
527                self.fragment = fragment;
528            }
529            _ => {}
530        }
531    }
532}
533
534// ------------------------------------------------------------- run_static
535
536/// Opens a window showing a message-free element view. The view is rebuilt
537/// on every redraw; scroll state persists in a [`FrameState`]. Blocks until
538/// the window closes.
539#[cfg(not(target_arch = "wasm32"))]
540pub fn run_static(
541    options: WindowOptions,
542    theme: Theme,
543    view: impl Fn(&Theme) -> Element<()> + 'static,
544) -> Result<(), ShellError> {
545    let event_loop = EventLoop::new().map_err(ShellError::EventLoop)?;
546    let background = theme.bg;
547    let mut fonts = Fonts::with_system();
548    for (role, data) in &options.fonts {
549        fonts.register(*role, data.clone());
550    }
551    let mut app = StaticApp {
552        shell: WindowShell::new(options, background),
553        theme,
554        fonts,
555        state: FrameState::new(),
556        view: Box::new(view),
557        cursor: Point::ORIGIN,
558        started: Instant::now(),
559        last_frame: None,
560    };
561    event_loop.run_app(&mut app).map_err(ShellError::EventLoop)
562}
563
564#[cfg(not(target_arch = "wasm32"))]
565struct StaticApp {
566    shell: WindowShell,
567    theme: Theme,
568    fonts: Fonts,
569    state: FrameState,
570    view: ViewFn,
571    /// Cursor position in logical coordinates.
572    cursor: Point,
573    started: Instant,
574    /// The frame from the last redraw, used to route input between frames.
575    last_frame: Option<fenestra_core::Frame>,
576}
577
578#[cfg(not(target_arch = "wasm32"))]
579impl StaticApp {
580    fn redraw(&mut self, event_loop: &ActiveEventLoop) {
581        let Some((lw, lh, scale)) = self.shell.logical_size() else {
582            return;
583        };
584        self.state.tick(self.started.elapsed().as_secs_f64());
585        let el = (self.view)(&self.theme);
586        #[expect(clippy::cast_possible_truncation, reason = "window sizes fit in f32")]
587        let frame = build_frame(
588            &el,
589            &self.theme,
590            &mut self.fonts,
591            &mut self.state,
592            (lw as f32, lh as f32),
593            scale,
594        );
595        let scene = frame.paint(&mut self.fonts, &mut self.state);
596        self.shell.present(&scene);
597        if frame.animating {
598            event_loop.set_control_flow(ControlFlow::WaitUntil(
599                Instant::now() + Duration::from_millis(16),
600            ));
601        } else {
602            event_loop.set_control_flow(ControlFlow::Wait);
603        }
604        self.last_frame = Some(frame);
605    }
606}
607
608#[cfg(not(target_arch = "wasm32"))]
609impl ApplicationHandler for StaticApp {
610    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
611        self.shell.resumed(event_loop);
612    }
613
614    fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
615        self.shell.suspended();
616    }
617
618    fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
619        if matches!(cause, StartCause::ResumeTimeReached { .. })
620            && let Some(w) = self.shell.window()
621        {
622            w.request_redraw();
623        }
624    }
625
626    fn window_event(
627        &mut self,
628        event_loop: &ActiveEventLoop,
629        window_id: WindowId,
630        event: WindowEvent,
631    ) {
632        if self.shell.window().is_none_or(|w| w.id() != window_id) {
633            return;
634        }
635        match event {
636            WindowEvent::CloseRequested => event_loop.exit(),
637            WindowEvent::Resized(size) => self.shell.resized(size.width, size.height),
638            WindowEvent::ScaleFactorChanged { .. } => {
639                if let Some(w) = self.shell.window() {
640                    w.request_redraw();
641                }
642            }
643            WindowEvent::CursorMoved { position, .. } => {
644                let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
645                self.cursor = Point::new(position.x / scale, position.y / scale);
646            }
647            WindowEvent::MouseWheel { delta, .. } => {
648                let dy = match delta {
649                    MouseScrollDelta::LineDelta(_, y) => f64::from(y) * LINE_SCROLL_PX,
650                    MouseScrollDelta::PixelDelta(pos) => {
651                        let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
652                        pos.y / scale
653                    }
654                };
655                if let Some(frame) = &self.last_frame
656                    && let Some(id) = frame.scrollable_at(self.cursor)
657                {
658                    #[expect(
659                        clippy::cast_possible_truncation,
660                        reason = "scroll deltas fit in f32"
661                    )]
662                    self.state.scroll_by(id, -dy as f32);
663                    if let Some(w) = self.shell.window() {
664                        w.request_redraw();
665                    }
666                }
667            }
668            WindowEvent::RedrawRequested => self.redraw(event_loop),
669            _ => {}
670        }
671    }
672}
673
674// ------------------------------------------------------------- run_app
675
676/// User events crossing into the app runner's loop: type-erased app
677/// messages from a [`fenestra_core::Proxy`] (any thread), and AccessKit's
678/// activation/action events.
679enum RunnerEvent {
680    App(Box<dyn std::any::Any + Send>),
681    #[cfg(not(target_arch = "wasm32"))]
682    Access(accesskit_winit::Event),
683}
684
685#[cfg(not(target_arch = "wasm32"))]
686impl From<accesskit_winit::Event> for RunnerEvent {
687    fn from(event: accesskit_winit::Event) -> Self {
688        Self::Access(event)
689    }
690}
691
692/// Runs an [`App`]: the full Elm-shaped loop with hit testing, hover/active/
693/// focus, keyboard navigation, message dispatch, and event-driven repaint
694/// (animation frames only while something animates). Calls [`App::init`]
695/// with a [`fenestra_core::Proxy`] before the first frame; proxied messages
696/// wake the loop and repaint. Blocks until the window closes.
697pub fn run_app<A: App + 'static>(mut app: A, options: WindowOptions) -> Result<(), ShellError>
698where
699    A::Msg: Send,
700{
701    let event_loop = EventLoop::<RunnerEvent>::with_user_event()
702        .build()
703        .map_err(ShellError::EventLoop)?;
704    #[cfg(not(target_arch = "wasm32"))]
705    let access_proxy = event_loop.create_proxy();
706    let proxy = event_loop.create_proxy();
707    app.init(fenestra_core::Proxy::new(move |msg: A::Msg| {
708        // Dropped silently once the loop is gone (window closed).
709        let _ = proxy.send_event(RunnerEvent::App(Box::new(msg)));
710    }));
711    let background = app.theme().bg;
712    let mut fonts = Fonts::with_system();
713    for (role, data) in &options.fonts {
714        fonts.register(*role, data.clone());
715    }
716    #[cfg(target_arch = "wasm32")]
717    let state = FrameState::new();
718    #[cfg(not(target_arch = "wasm32"))]
719    let mut state = FrameState::new();
720    #[cfg(not(target_arch = "wasm32"))]
721    state.set_clipboard(Box::new(crate::OsClipboard::default()));
722    let runner = AppRunner {
723        shell: WindowShell::new(options, background),
724        app,
725        fonts,
726        state,
727        cursor: Point::ORIGIN,
728        started: Instant::now(),
729        last: None,
730        dirty: true,
731        cached_scene: None,
732        modifiers: winit::keyboard::ModifiersState::empty(),
733        #[cfg(not(target_arch = "wasm32"))]
734        adapter: None,
735        #[cfg(not(target_arch = "wasm32"))]
736        proxy: access_proxy,
737        #[cfg(not(target_arch = "wasm32"))]
738        secondary: std::collections::HashMap::new(),
739    };
740    #[cfg(not(target_arch = "wasm32"))]
741    {
742        let mut runner = runner;
743        event_loop
744            .run_app(&mut runner)
745            .map_err(ShellError::EventLoop)
746    }
747    #[cfg(target_arch = "wasm32")]
748    {
749        use winit::platform::web::EventLoopExtWebSys;
750        // Non-blocking on the web: the loop keeps running after main returns.
751        event_loop.spawn_app(runner);
752        Ok(())
753    }
754}
755
756struct AppRunner<A: App> {
757    shell: WindowShell,
758    app: A,
759    fonts: Fonts,
760    state: FrameState,
761    cursor: Point,
762    started: Instant,
763    /// View and frame from the last redraw, for input routing.
764    last: Option<(Element<A::Msg>, fenestra_core::Frame)>,
765    /// Anything changed since the last full frame? OS-driven redraws
766    /// (expose, un-occlude) re-present the cached scene when clean —
767    /// the whole build/layout/paint pipeline is skipped.
768    dirty: bool,
769    /// The last painted scene with its (logical w, h, scale) key.
770    cached_scene: Option<(Scene, (f64, f64, f64))>,
771    modifiers: winit::keyboard::ModifiersState,
772    /// The AccessKit adapter, created before the window first shows.
773    #[cfg(not(target_arch = "wasm32"))]
774    adapter: Option<accesskit_winit::Adapter>,
775    /// Loop proxy handed to the adapter for activation/action events.
776    #[cfg(not(target_arch = "wasm32"))]
777    proxy: EventLoopProxy<RunnerEvent>,
778    /// Secondary windows declared by [`App::windows`], keyed by their
779    /// stable key and reconciled after every update (native only).
780    #[cfg(not(target_arch = "wasm32"))]
781    secondary: std::collections::HashMap<String, SecondaryWindow<A>>,
782}
783
784/// One reconciled secondary window: its own surface, retained state, and
785/// accessibility adapter; app state and fonts are shared.
786#[cfg(not(target_arch = "wasm32"))]
787struct SecondaryWindow<A: App> {
788    shell: WindowShell,
789    state: FrameState,
790    cursor: Point,
791    last: Option<(Element<A::Msg>, fenestra_core::Frame)>,
792    on_close: A::Msg,
793    title: String,
794    adapter: Option<accesskit_winit::Adapter>,
795}
796
797impl<A: App> AppRunner<A> {
798    fn redraw(&mut self, event_loop: &ActiveEventLoop) {
799        self.shell.pump();
800        let Some((lw, lh, scale)) = self.shell.logical_size() else {
801            return;
802        };
803        // Clean frame at the same size: re-present the cached scene and
804        // skip build/layout/paint entirely (expose/un-occlude redraws).
805        if !self.dirty
806            && let Some((scene, key)) = &self.cached_scene
807            && *key == (lw, lh, scale)
808        {
809            self.shell.present(scene);
810            return;
811        }
812        let theme = self.app.theme();
813        self.shell.background = theme.bg;
814        self.state.tick(self.started.elapsed().as_secs_f64());
815        let view = self.app.view_for(fenestra_core::MAIN_WINDOW);
816        #[expect(clippy::cast_possible_truncation, reason = "window sizes fit in f32")]
817        let frame = build_frame(
818            &view,
819            &theme,
820            &mut self.fonts,
821            &mut self.state,
822            (lw as f32, lh as f32),
823            scale,
824        );
825        let scene = frame.paint(&mut self.fonts, &mut self.state);
826        self.shell.present(&scene);
827        // The frame is clean until something changes it; animation and
828        // hover refresh keep it dirty so the pipeline runs again.
829        self.cached_scene = Some((scene, (lw, lh, scale)));
830        self.dirty = frame.animating;
831        // Content may have moved under a stationary pointer (scroll,
832        // layout change): refresh hover and repaint once more if it did.
833        if refresh_hover(&view, &frame, &mut self.state)
834            && let Some(w) = self.shell.window()
835        {
836            self.dirty = true;
837            w.request_redraw();
838        }
839        if frame.animating {
840            #[cfg(not(target_arch = "wasm32"))]
841            event_loop.set_control_flow(ControlFlow::WaitUntil(
842                Instant::now() + Duration::from_millis(16),
843            ));
844            // The browser paces frames; just ask for the next one.
845            #[cfg(target_arch = "wasm32")]
846            if let Some(w) = self.shell.window() {
847                w.request_redraw();
848            }
849        } else {
850            #[cfg(not(target_arch = "wasm32"))]
851            let secondary_animating = self
852                .secondary
853                .values()
854                .any(|b| b.last.as_ref().is_some_and(|(_, f)| f.animating));
855            #[cfg(target_arch = "wasm32")]
856            let secondary_animating = false;
857            if !secondary_animating {
858                event_loop.set_control_flow(ControlFlow::Wait);
859            }
860        }
861        self.last = Some((view, frame));
862        // Anchor the IME candidate window to the focused caret.
863        if let Some(caret) = self.state.ime_caret()
864            && let Some(w) = self.shell.window()
865        {
866            w.set_ime_cursor_area(
867                winit::dpi::LogicalPosition::new(caret.x0, caret.y0),
868                winit::dpi::LogicalSize::new(1.0, caret.height()),
869            );
870        }
871        #[cfg(not(target_arch = "wasm32"))]
872        self.push_access_tree();
873    }
874
875    /// Pushes the current frame's accessibility projection to the platform
876    /// (no-op until assistive technology activates the tree).
877    #[cfg(not(target_arch = "wasm32"))]
878    fn push_access_tree(&mut self) {
879        let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
880        let focus = self.state.focused();
881        if let Some(adapter) = &mut self.adapter
882            && let Some((_, frame)) = &self.last
883        {
884            adapter.update_if_active(|| crate::access::tree_update(frame, focus, scale));
885        }
886    }
887
888    fn input(&mut self, event: InputEvent) -> bool {
889        let Some((view, frame)) = &self.last else {
890            return false;
891        };
892        let result = dispatch(view, frame, &mut self.state, &mut self.fonts, event);
893        if let Some(cursor) = result.cursor
894            && let Some(w) = self.shell.window()
895        {
896            w.set_cursor(winit::window::Cursor::Icon(map_cursor(cursor)));
897        }
898        let had_msgs = !result.msgs.is_empty();
899        for msg in result.msgs {
900            self.app.update(msg);
901        }
902        if result.redraw || had_msgs {
903            self.dirty = true;
904            if let Some(w) = self.shell.window() {
905                w.request_redraw();
906            }
907        }
908        had_msgs
909    }
910
911    /// Routes one input event into the main window, reconciling windows
912    /// afterwards if it produced messages.
913    fn input_main(&mut self, event_loop: &ActiveEventLoop, event: InputEvent) {
914        if self.input(event) {
915            self.after_update(event_loop);
916        }
917    }
918
919    /// Routes one input event into a secondary window, reconciling
920    /// windows afterwards if it produced messages.
921    #[cfg(not(target_arch = "wasm32"))]
922    fn secondary_input_main(&mut self, key: &str, event_loop: &ActiveEventLoop, event: InputEvent) {
923        if self.secondary_input(key, event) {
924            self.after_update(event_loop);
925        }
926    }
927
928    /// Reconciles secondary windows against [`App::windows`] and asks
929    /// every window for a repaint — called whenever messages were applied.
930    fn after_update(&mut self, event_loop: &ActiveEventLoop) {
931        self.dirty = true;
932        #[cfg(not(target_arch = "wasm32"))]
933        self.reconcile_windows(event_loop);
934        #[cfg(target_arch = "wasm32")]
935        let _ = event_loop;
936        if let Some(w) = self.shell.window() {
937            w.request_redraw();
938        }
939        #[cfg(not(target_arch = "wasm32"))]
940        for bundle in self.secondary.values() {
941            if let Some(w) = bundle.shell.window() {
942                w.request_redraw();
943            }
944        }
945    }
946
947    /// Opens, closes, and retitles secondary windows to match the app's
948    /// declared list.
949    #[cfg(not(target_arch = "wasm32"))]
950    fn reconcile_windows(&mut self, event_loop: &ActiveEventLoop) {
951        let desired = self.app.windows();
952        self.secondary
953            .retain(|key, _| desired.iter().any(|d| &d.key == key));
954        for desc in desired {
955            match self.secondary.get_mut(&desc.key) {
956                Some(bundle) => {
957                    bundle.on_close = desc.on_close;
958                    if bundle.title != desc.title {
959                        bundle.title.clone_from(&desc.title);
960                        if let Some(w) = bundle.shell.window() {
961                            w.set_title(&desc.title);
962                        }
963                    }
964                }
965                None => {
966                    let mut shell = WindowShell::new(
967                        WindowOptions::titled(desc.title.clone())
968                            .with_size(desc.size.0, desc.size.1),
969                        self.shell.background,
970                    );
971                    let proxy = self.proxy.clone();
972                    let mut adapter = None;
973                    shell.resumed_with(event_loop, |el, window| {
974                        adapter = Some(accesskit_winit::Adapter::with_event_loop_proxy(
975                            el, window, proxy,
976                        ));
977                    });
978                    if let Some(w) = shell.window() {
979                        w.set_ime_allowed(true);
980                        w.request_redraw();
981                    }
982                    let mut state = FrameState::new();
983                    state.set_clipboard(Box::new(crate::OsClipboard::default()));
984                    self.secondary.insert(
985                        desc.key.clone(),
986                        SecondaryWindow {
987                            shell,
988                            state,
989                            cursor: Point::ORIGIN,
990                            last: None,
991                            on_close: desc.on_close,
992                            title: desc.title,
993                            adapter,
994                        },
995                    );
996                }
997            }
998        }
999    }
1000
1001    /// Redraws one secondary window: the same pipeline as the main one,
1002    /// against its own retained state and `view_for(key)`.
1003    #[cfg(not(target_arch = "wasm32"))]
1004    fn secondary_redraw(&mut self, key: &str, event_loop: &ActiveEventLoop) {
1005        let theme = self.app.theme_for(key);
1006        let now = self.started.elapsed().as_secs_f64();
1007        let Some(bundle) = self.secondary.get_mut(key) else {
1008            return;
1009        };
1010        bundle.shell.pump();
1011        let Some((lw, lh, scale)) = bundle.shell.logical_size() else {
1012            return;
1013        };
1014        bundle.shell.background = theme.bg;
1015        bundle.state.tick(now);
1016        let view = self.app.view_for(key);
1017        #[expect(clippy::cast_possible_truncation, reason = "window sizes fit in f32")]
1018        let frame = build_frame(
1019            &view,
1020            &theme,
1021            &mut self.fonts,
1022            &mut bundle.state,
1023            (lw as f32, lh as f32),
1024            scale,
1025        );
1026        let scene = frame.paint(&mut self.fonts, &mut bundle.state);
1027        bundle.shell.present(&scene);
1028        if refresh_hover(&view, &frame, &mut bundle.state)
1029            && let Some(w) = bundle.shell.window()
1030        {
1031            w.request_redraw();
1032        }
1033        if frame.animating {
1034            event_loop.set_control_flow(ControlFlow::WaitUntil(
1035                Instant::now() + Duration::from_millis(16),
1036            ));
1037        }
1038        if let Some(caret) = bundle.state.ime_caret()
1039            && let Some(w) = bundle.shell.window()
1040        {
1041            w.set_ime_cursor_area(
1042                winit::dpi::LogicalPosition::new(caret.x0, caret.y0),
1043                winit::dpi::LogicalSize::new(1.0, caret.height()),
1044            );
1045        }
1046        bundle.last = Some((view, frame));
1047        let focus = bundle.state.focused();
1048        if let Some(adapter) = &mut bundle.adapter
1049            && let Some((_, frame)) = &bundle.last
1050        {
1051            adapter.update_if_active(|| crate::access::tree_update(frame, focus, scale));
1052        }
1053    }
1054
1055    /// The full event handler for one secondary window — the same arms as
1056    /// the main window, against the bundle's own surface and state.
1057    #[cfg(not(target_arch = "wasm32"))]
1058    fn secondary_window_event(
1059        &mut self,
1060        key: &str,
1061        event_loop: &ActiveEventLoop,
1062        event: WindowEvent,
1063    ) {
1064        if let Some(bundle) = self.secondary.get_mut(key)
1065            && let Some(window) = bundle.shell.window()
1066            && let Some(adapter) = &mut bundle.adapter
1067        {
1068            adapter.process_event(window, &event);
1069        }
1070        match event {
1071            WindowEvent::CloseRequested => {
1072                if let Some(msg) = self.secondary.get(key).map(|b| b.on_close.clone()) {
1073                    self.app.update(msg);
1074                    self.after_update(event_loop);
1075                }
1076            }
1077            WindowEvent::Resized(size) => {
1078                if let Some(bundle) = self.secondary.get_mut(key) {
1079                    bundle.shell.resized(size.width, size.height);
1080                }
1081            }
1082            WindowEvent::ScaleFactorChanged { .. } | WindowEvent::Occluded(false) => {
1083                if let Some(w) = self.secondary.get(key).and_then(|b| b.shell.window()) {
1084                    w.request_redraw();
1085                }
1086            }
1087            WindowEvent::ModifiersChanged(mods) => {
1088                self.modifiers = mods.state();
1089                let m = self.modifiers;
1090                self.secondary_input_main(
1091                    key,
1092                    event_loop,
1093                    InputEvent::Modifiers {
1094                        shift: m.shift_key(),
1095                        ctrl: m.control_key(),
1096                        alt: m.alt_key(),
1097                        meta: m.super_key(),
1098                    },
1099                );
1100            }
1101            WindowEvent::DroppedFile(path) => {
1102                self.secondary_input_main(key, event_loop, InputEvent::FileDrop(path));
1103            }
1104            WindowEvent::CursorLeft { .. } => {
1105                self.secondary_input_main(key, event_loop, InputEvent::PointerLeave);
1106            }
1107            WindowEvent::CursorMoved { position, .. } => {
1108                let Some(bundle) = self.secondary.get_mut(key) else {
1109                    return;
1110                };
1111                let scale = bundle.shell.window().map_or(1.0, |w| w.scale_factor());
1112                bundle.cursor = Point::new(position.x / scale, position.y / scale);
1113                #[expect(clippy::cast_possible_truncation, reason = "positions fit in f32")]
1114                let (x, y) = (bundle.cursor.x as f32, bundle.cursor.y as f32);
1115                self.secondary_input_main(key, event_loop, InputEvent::PointerMove { x, y });
1116            }
1117            WindowEvent::MouseInput {
1118                state,
1119                button: winit::event::MouseButton::Left,
1120                ..
1121            } => {
1122                self.secondary_input_main(
1123                    key,
1124                    event_loop,
1125                    match state {
1126                        winit::event::ElementState::Pressed => InputEvent::PointerDown,
1127                        winit::event::ElementState::Released => InputEvent::PointerUp,
1128                    },
1129                );
1130            }
1131            WindowEvent::MouseInput {
1132                state,
1133                button: winit::event::MouseButton::Right,
1134                ..
1135            } => {
1136                self.secondary_input_main(
1137                    key,
1138                    event_loop,
1139                    match state {
1140                        winit::event::ElementState::Pressed => InputEvent::RightDown,
1141                        winit::event::ElementState::Released => InputEvent::RightUp,
1142                    },
1143                );
1144            }
1145            WindowEvent::MouseWheel { delta, .. } => {
1146                let dy = match delta {
1147                    MouseScrollDelta::LineDelta(_, y) => f64::from(y) * LINE_SCROLL_PX,
1148                    MouseScrollDelta::PixelDelta(pos) => {
1149                        let scale = self
1150                            .secondary
1151                            .get(key)
1152                            .and_then(|b| b.shell.window())
1153                            .map_or(1.0, |w| w.scale_factor());
1154                        pos.y / scale
1155                    }
1156                };
1157                #[expect(clippy::cast_possible_truncation, reason = "deltas fit in f32")]
1158                self.secondary_input_main(key, event_loop, InputEvent::Wheel { dy: dy as f32 });
1159            }
1160            WindowEvent::KeyboardInput { event, .. }
1161                if event.state == winit::event::ElementState::Pressed =>
1162            {
1163                let mods = self.modifiers;
1164                let printable = !mods.control_key()
1165                    && !mods.super_key()
1166                    && event
1167                        .text
1168                        .as_ref()
1169                        .is_some_and(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()));
1170                if printable {
1171                    if let Some(t) = &event.text {
1172                        self.secondary_input_main(key, event_loop, InputEvent::Text(t.to_string()));
1173                    }
1174                } else if let Some(input) = map_key(&event, mods) {
1175                    self.secondary_input_main(key, event_loop, input);
1176                }
1177            }
1178            WindowEvent::Ime(ime) => match ime {
1179                winit::event::Ime::Preedit(text, cursor) => {
1180                    self.secondary_input_main(
1181                        key,
1182                        event_loop,
1183                        InputEvent::ImePreedit { text, cursor },
1184                    );
1185                }
1186                winit::event::Ime::Commit(text) => {
1187                    self.secondary_input_main(key, event_loop, InputEvent::Text(text));
1188                }
1189                winit::event::Ime::Enabled | winit::event::Ime::Disabled => {}
1190            },
1191            WindowEvent::RedrawRequested => self.secondary_redraw(key, event_loop),
1192            _ => {}
1193        }
1194    }
1195
1196    /// Dispatches one input event against a secondary window. Returns
1197    /// whether messages were applied (the caller then reconciles).
1198    #[cfg(not(target_arch = "wasm32"))]
1199    fn secondary_input(&mut self, key: &str, event: InputEvent) -> bool {
1200        let Some(bundle) = self.secondary.get_mut(key) else {
1201            return false;
1202        };
1203        let Some((view, frame)) = &bundle.last else {
1204            return false;
1205        };
1206        let result = dispatch(view, frame, &mut bundle.state, &mut self.fonts, event);
1207        if let Some(cursor) = result.cursor
1208            && let Some(w) = bundle.shell.window()
1209        {
1210            w.set_cursor(winit::window::Cursor::Icon(map_cursor(cursor)));
1211        }
1212        let had_msgs = !result.msgs.is_empty();
1213        if (result.redraw || had_msgs)
1214            && let Some(w) = bundle.shell.window()
1215        {
1216            w.request_redraw();
1217        }
1218        let msgs = result.msgs;
1219        for msg in msgs {
1220            self.app.update(msg);
1221        }
1222        had_msgs
1223    }
1224}
1225
1226pub(crate) fn map_cursor(cursor: fenestra_core::Cursor) -> winit::window::CursorIcon {
1227    match cursor {
1228        fenestra_core::Cursor::Default => winit::window::CursorIcon::Default,
1229        fenestra_core::Cursor::Pointer => winit::window::CursorIcon::Pointer,
1230        fenestra_core::Cursor::Text => winit::window::CursorIcon::Text,
1231        fenestra_core::Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
1232    }
1233}
1234
1235/// Translates a winit key event into a fenestra [`InputEvent`].
1236pub(crate) fn map_key(
1237    event: &winit::event::KeyEvent,
1238    mods: winit::keyboard::ModifiersState,
1239) -> Option<InputEvent> {
1240    use winit::keyboard::{Key as WKey, NamedKey};
1241    let key = match &event.logical_key {
1242        WKey::Named(NamedKey::Tab) => {
1243            return Some(if mods.shift_key() {
1244                InputEvent::ShiftTab
1245            } else {
1246                InputEvent::Tab
1247            });
1248        }
1249        WKey::Named(named) => match named {
1250            NamedKey::Enter => Key::Enter,
1251            NamedKey::Space => Key::Space,
1252            NamedKey::Escape => Key::Escape,
1253            NamedKey::ArrowLeft => Key::ArrowLeft,
1254            NamedKey::ArrowRight => Key::ArrowRight,
1255            NamedKey::ArrowUp => Key::ArrowUp,
1256            NamedKey::ArrowDown => Key::ArrowDown,
1257            NamedKey::Home => Key::Home,
1258            NamedKey::End => Key::End,
1259            NamedKey::Backspace => Key::Backspace,
1260            NamedKey::Delete => Key::Delete,
1261            NamedKey::PageUp => Key::PageUp,
1262            NamedKey::PageDown => Key::PageDown,
1263            _ => return None,
1264        },
1265        WKey::Character(s) => Key::Char(s.chars().next()?),
1266        _ => return None,
1267    };
1268    Some(InputEvent::Key(KeyInput {
1269        key,
1270        shift: mods.shift_key(),
1271        ctrl: mods.control_key(),
1272        alt: mods.alt_key(),
1273        meta: mods.super_key(),
1274    }))
1275}
1276
1277impl<A: App> ApplicationHandler<RunnerEvent> for AppRunner<A> {
1278    #[cfg(not(target_arch = "wasm32"))]
1279    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
1280        // Fresh surface, possibly fresh scale: rebuild rather than trust
1281        // a scene cached across the suspend.
1282        self.dirty = true;
1283        let adapter = &mut self.adapter;
1284        let proxy = self.proxy.clone();
1285        self.shell.resumed_with(event_loop, |el, window| {
1286            // The adapter must attach while the window is still hidden.
1287            if adapter.is_none() {
1288                *adapter = Some(accesskit_winit::Adapter::with_event_loop_proxy(
1289                    el, window, proxy,
1290                ));
1291            }
1292        });
1293        if let Some(w) = self.shell.window() {
1294            w.set_ime_allowed(true);
1295        }
1296        for bundle in self.secondary.values_mut() {
1297            bundle.shell.resumed(event_loop);
1298        }
1299        self.reconcile_windows(event_loop);
1300    }
1301
1302    #[cfg(target_arch = "wasm32")]
1303    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
1304        self.dirty = true;
1305        self.shell.resumed(event_loop);
1306        if let Some(w) = self.shell.window() {
1307            w.set_ime_allowed(true);
1308        }
1309    }
1310
1311    fn user_event(&mut self, event_loop: &ActiveEventLoop, event: RunnerEvent) {
1312        match event {
1313            RunnerEvent::App(msg) => {
1314                if let Ok(msg) = msg.downcast::<A::Msg>() {
1315                    self.app.update(*msg);
1316                    self.after_update(event_loop);
1317                }
1318            }
1319            #[cfg(not(target_arch = "wasm32"))]
1320            RunnerEvent::Access(ev) => {
1321                // Route by window id: `None` is the main window, `Some(key)`
1322                // a secondary one; unknown ids are stale events.
1323                let is_main = self.shell.window().is_some_and(|w| w.id() == ev.window_id);
1324                let skey = (!is_main)
1325                    .then(|| {
1326                        self.secondary
1327                            .iter()
1328                            .find(|(_, b)| b.shell.window().is_some_and(|w| w.id() == ev.window_id))
1329                            .map(|(k, _)| k.clone())
1330                    })
1331                    .flatten();
1332                if !is_main && skey.is_none() {
1333                    return;
1334                }
1335                match ev.window_event {
1336                    accesskit_winit::WindowEvent::InitialTreeRequested => match &skey {
1337                        None => {
1338                            if self.last.is_some() {
1339                                self.push_access_tree();
1340                            } else if let Some(w) = self.shell.window() {
1341                                w.request_redraw();
1342                            }
1343                        }
1344                        Some(key) => {
1345                            if let Some(bundle) = self.secondary.get_mut(key) {
1346                                let scale = bundle.shell.window().map_or(1.0, |w| w.scale_factor());
1347                                let focus = bundle.state.focused();
1348                                if let Some((_, frame)) = &bundle.last {
1349                                    if let Some(adapter) = &mut bundle.adapter {
1350                                        adapter.update_if_active(|| {
1351                                            crate::access::tree_update(frame, focus, scale)
1352                                        });
1353                                    }
1354                                } else if let Some(w) = bundle.shell.window() {
1355                                    w.request_redraw();
1356                                }
1357                            }
1358                        }
1359                    },
1360                    accesskit_winit::WindowEvent::ActionRequested(req) => {
1361                        let id = fenestra_core::WidgetId(req.target_node.0);
1362                        match req.action {
1363                            accesskit::Action::Click => {
1364                                let msg = match &skey {
1365                                    None => self.last.as_ref().and_then(|(view, frame)| {
1366                                        fenestra_core::click_msg_of(view, frame, &self.state, id)
1367                                    }),
1368                                    Some(key) => self.secondary.get(key).and_then(|bundle| {
1369                                        bundle.last.as_ref().and_then(|(view, frame)| {
1370                                            fenestra_core::click_msg_of(
1371                                                view,
1372                                                frame,
1373                                                &bundle.state,
1374                                                id,
1375                                            )
1376                                        })
1377                                    }),
1378                                };
1379                                if let Some(msg) = msg {
1380                                    self.app.update(msg);
1381                                    self.after_update(event_loop);
1382                                }
1383                            }
1384                            accesskit::Action::Focus => match &skey {
1385                                None => {
1386                                    self.state.set_focus(Some(id));
1387                                    self.dirty = true;
1388                                    if let Some(w) = self.shell.window() {
1389                                        w.request_redraw();
1390                                    }
1391                                }
1392                                Some(key) => {
1393                                    if let Some(bundle) = self.secondary.get_mut(key) {
1394                                        bundle.state.set_focus(Some(id));
1395                                        if let Some(w) = bundle.shell.window() {
1396                                            w.request_redraw();
1397                                        }
1398                                    }
1399                                }
1400                            },
1401                            _ => {}
1402                        }
1403                    }
1404                    accesskit_winit::WindowEvent::AccessibilityDeactivated => {}
1405                }
1406            }
1407        }
1408    }
1409
1410    fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
1411        self.shell.suspended();
1412        #[cfg(not(target_arch = "wasm32"))]
1413        for bundle in self.secondary.values_mut() {
1414            bundle.shell.suspended();
1415        }
1416    }
1417
1418    fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
1419        if !matches!(cause, StartCause::ResumeTimeReached { .. }) {
1420            return;
1421        }
1422        if let Some(w) = self.shell.window() {
1423            w.request_redraw();
1424        }
1425        #[cfg(not(target_arch = "wasm32"))]
1426        for bundle in self.secondary.values() {
1427            if let Some(w) = bundle.shell.window() {
1428                w.request_redraw();
1429            }
1430        }
1431    }
1432
1433    fn window_event(
1434        &mut self,
1435        event_loop: &ActiveEventLoop,
1436        window_id: WindowId,
1437        event: WindowEvent,
1438    ) {
1439        if self.shell.window().is_none_or(|w| w.id() != window_id) {
1440            #[cfg(not(target_arch = "wasm32"))]
1441            if let Some(key) = self
1442                .secondary
1443                .iter()
1444                .find(|(_, b)| b.shell.window().is_some_and(|w| w.id() == window_id))
1445                .map(|(k, _)| k.clone())
1446            {
1447                self.secondary_window_event(&key, event_loop, event);
1448            }
1449            return;
1450        }
1451        #[cfg(not(target_arch = "wasm32"))]
1452        if let Some(adapter) = &mut self.adapter
1453            && let Some(window) = self.shell.window()
1454        {
1455            adapter.process_event(window, &event);
1456        }
1457        match event {
1458            WindowEvent::CloseRequested => event_loop.exit(),
1459            WindowEvent::Resized(size) => {
1460                // The cache key also guards size, but coalesced resizes can
1461                // land back on the cached geometry mid-drag.
1462                self.dirty = true;
1463                self.shell.resized(size.width, size.height);
1464            }
1465            WindowEvent::ScaleFactorChanged { .. } => {
1466                self.dirty = true;
1467                if let Some(w) = self.shell.window() {
1468                    w.request_redraw();
1469                }
1470            }
1471            WindowEvent::ModifiersChanged(mods) => {
1472                self.modifiers = mods.state();
1473                let m = self.modifiers;
1474                self.input_main(
1475                    event_loop,
1476                    InputEvent::Modifiers {
1477                        shift: m.shift_key(),
1478                        ctrl: m.control_key(),
1479                        alt: m.alt_key(),
1480                        meta: m.super_key(),
1481                    },
1482                );
1483            }
1484            WindowEvent::Occluded(occluded) => {
1485                if !occluded && let Some(w) = self.shell.window() {
1486                    w.request_redraw();
1487                }
1488            }
1489            WindowEvent::DroppedFile(path) => {
1490                self.input_main(event_loop, InputEvent::FileDrop(path))
1491            }
1492            WindowEvent::CursorLeft { .. } => self.input_main(event_loop, InputEvent::PointerLeave),
1493            WindowEvent::CursorMoved { position, .. } => {
1494                let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
1495                self.cursor = Point::new(position.x / scale, position.y / scale);
1496                #[expect(clippy::cast_possible_truncation, reason = "positions fit in f32")]
1497                self.input_main(
1498                    event_loop,
1499                    InputEvent::PointerMove {
1500                        x: self.cursor.x as f32,
1501                        y: self.cursor.y as f32,
1502                    },
1503                );
1504            }
1505            WindowEvent::MouseInput {
1506                state,
1507                button: winit::event::MouseButton::Left,
1508                ..
1509            } => {
1510                self.input_main(
1511                    event_loop,
1512                    match state {
1513                        winit::event::ElementState::Pressed => InputEvent::PointerDown,
1514                        winit::event::ElementState::Released => InputEvent::PointerUp,
1515                    },
1516                );
1517            }
1518            WindowEvent::MouseInput {
1519                state,
1520                button: winit::event::MouseButton::Right,
1521                ..
1522            } => {
1523                self.input_main(
1524                    event_loop,
1525                    match state {
1526                        winit::event::ElementState::Pressed => InputEvent::RightDown,
1527                        winit::event::ElementState::Released => InputEvent::RightUp,
1528                    },
1529                );
1530            }
1531            WindowEvent::MouseWheel { delta, .. } => {
1532                let dy = match delta {
1533                    MouseScrollDelta::LineDelta(_, y) => f64::from(y) * LINE_SCROLL_PX,
1534                    MouseScrollDelta::PixelDelta(pos) => {
1535                        let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
1536                        pos.y / scale
1537                    }
1538                };
1539                #[expect(clippy::cast_possible_truncation, reason = "deltas fit in f32")]
1540                self.input_main(event_loop, InputEvent::Wheel { dy: dy as f32 });
1541            }
1542            WindowEvent::KeyboardInput { event, .. }
1543                if event.state == winit::event::ElementState::Pressed =>
1544            {
1545                {
1546                    let mods = self.modifiers;
1547                    // Printable input arrives as Text (it may be multi-char);
1548                    // named keys and shortcuts go through Key.
1549                    let printable = !mods.control_key()
1550                        && !mods.super_key()
1551                        && event
1552                            .text
1553                            .as_ref()
1554                            .is_some_and(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()));
1555                    if printable {
1556                        if let Some(t) = &event.text {
1557                            self.input_main(event_loop, InputEvent::Text(t.to_string()));
1558                        }
1559                    } else if let Some(input) = map_key(&event, mods) {
1560                        self.input_main(event_loop, input);
1561                    }
1562                }
1563            }
1564            WindowEvent::Ime(ime) => match ime {
1565                winit::event::Ime::Preedit(text, cursor) => {
1566                    self.input_main(event_loop, InputEvent::ImePreedit { text, cursor });
1567                }
1568                winit::event::Ime::Commit(text) => {
1569                    self.input_main(event_loop, InputEvent::Text(text));
1570                }
1571                winit::event::Ime::Enabled | winit::event::Ime::Disabled => {}
1572            },
1573            WindowEvent::RedrawRequested => self.redraw(event_loop),
1574            _ => {}
1575        }
1576    }
1577}