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