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// ------------------------------------------------------------- run_static
546
547/// Opens a window showing a message-free element view. The view is rebuilt
548/// on every redraw; scroll state persists in a [`FrameState`]. Blocks until
549/// the window closes.
550#[cfg(not(target_arch = "wasm32"))]
551pub fn run_static(
552    options: WindowOptions,
553    theme: Theme,
554    view: impl Fn(&Theme) -> Element<()> + 'static,
555) -> Result<(), ShellError> {
556    let event_loop = EventLoop::new().map_err(ShellError::EventLoop)?;
557    let background = theme.bg;
558    let mut fonts = Fonts::with_system();
559    for (role, data) in &options.fonts {
560        fonts.register(*role, data.clone());
561    }
562    let mut app = StaticApp {
563        shell: WindowShell::new(options, background),
564        theme,
565        fonts,
566        state: FrameState::new(),
567        view: Box::new(view),
568        cursor: Point::ORIGIN,
569        started: Instant::now(),
570        last_frame: None,
571    };
572    event_loop.run_app(&mut app).map_err(ShellError::EventLoop)
573}
574
575#[cfg(not(target_arch = "wasm32"))]
576struct StaticApp {
577    shell: WindowShell,
578    theme: Theme,
579    fonts: Fonts,
580    state: FrameState,
581    view: ViewFn,
582    /// Cursor position in logical coordinates.
583    cursor: Point,
584    started: Instant,
585    /// The frame from the last redraw, used to route input between frames.
586    last_frame: Option<fenestra_core::Frame>,
587}
588
589#[cfg(not(target_arch = "wasm32"))]
590impl StaticApp {
591    fn redraw(&mut self, event_loop: &ActiveEventLoop) {
592        let Some((lw, lh, scale)) = self.shell.logical_size() else {
593            return;
594        };
595        self.state.tick(self.started.elapsed().as_secs_f64());
596        let el = (self.view)(&self.theme);
597        #[expect(clippy::cast_possible_truncation, reason = "window sizes fit in f32")]
598        let frame = build_frame(
599            &el,
600            &self.theme,
601            &mut self.fonts,
602            &mut self.state,
603            (lw as f32, lh as f32),
604            scale,
605        );
606        // Live-window limitation: the swapchain path renders a single pass, so
607        // frosted glass shows its translucent tint without the CPU backdrop
608        // blur (which needs a read-back). Headless rendering — the golden source
609        // of truth — uses the two-pass `render_plan`. See ARCHITECTURE.md
610        // ("Real frosted-glass backdrop blur").
611        let scene = frame.paint(&mut self.fonts, &mut self.state);
612        self.shell.present(&scene);
613        if frame.animating {
614            event_loop.set_control_flow(ControlFlow::WaitUntil(
615                Instant::now() + Duration::from_millis(16),
616            ));
617        } else {
618            event_loop.set_control_flow(ControlFlow::Wait);
619        }
620        self.last_frame = Some(frame);
621    }
622}
623
624#[cfg(not(target_arch = "wasm32"))]
625impl ApplicationHandler for StaticApp {
626    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
627        self.shell.resumed(event_loop);
628    }
629
630    fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
631        self.shell.suspended();
632    }
633
634    fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
635        if matches!(cause, StartCause::ResumeTimeReached { .. })
636            && let Some(w) = self.shell.window()
637        {
638            w.request_redraw();
639        }
640    }
641
642    fn window_event(
643        &mut self,
644        event_loop: &ActiveEventLoop,
645        window_id: WindowId,
646        event: WindowEvent,
647    ) {
648        if self.shell.window().is_none_or(|w| w.id() != window_id) {
649            return;
650        }
651        match event {
652            WindowEvent::CloseRequested => event_loop.exit(),
653            WindowEvent::Resized(size) => self.shell.resized(size.width, size.height),
654            WindowEvent::ScaleFactorChanged { .. } => {
655                if let Some(w) = self.shell.window() {
656                    w.request_redraw();
657                }
658            }
659            WindowEvent::CursorMoved { position, .. } => {
660                let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
661                self.cursor = Point::new(position.x / scale, position.y / scale);
662            }
663            WindowEvent::MouseWheel { delta, .. } => {
664                let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
665                let (dx, dy) = wheel_deltas(delta, scale);
666                if let Some(frame) = &self.last_frame {
667                    let y_target = (dy.abs() > 1e-3)
668                        .then(|| frame.scrollable_y_at(self.cursor))
669                        .flatten();
670                    let x_target = (dx.abs() > 1e-3)
671                        .then(|| frame.scrollable_x_at(self.cursor))
672                        .flatten();
673                    #[expect(clippy::cast_possible_truncation, reason = "scroll deltas fit in f32")]
674                    {
675                        if let Some(id) = y_target {
676                            self.state.scroll_by(id, -dy as f32);
677                        }
678                        if let Some(id) = x_target {
679                            self.state.scroll_by_x(id, -dx as f32);
680                        }
681                    }
682                    if (y_target.is_some() || x_target.is_some())
683                        && let Some(w) = self.shell.window()
684                    {
685                        w.request_redraw();
686                    }
687                }
688            }
689            WindowEvent::RedrawRequested => self.redraw(event_loop),
690            _ => {}
691        }
692    }
693}
694
695// ------------------------------------------------------------- run_app
696
697/// User events crossing into the app runner's loop: type-erased app
698/// messages from a [`fenestra_core::Proxy`] (any thread), and AccessKit's
699/// activation/action events.
700enum RunnerEvent {
701    App(Box<dyn std::any::Any + Send>),
702    #[cfg(not(target_arch = "wasm32"))]
703    Access(accesskit_winit::Event),
704}
705
706#[cfg(not(target_arch = "wasm32"))]
707impl From<accesskit_winit::Event> for RunnerEvent {
708    fn from(event: accesskit_winit::Event) -> Self {
709        Self::Access(event)
710    }
711}
712
713/// Runs an [`App`]: the full Elm-shaped loop with hit testing, hover/active/
714/// focus, keyboard navigation, message dispatch, and event-driven repaint
715/// (animation frames only while something animates). Calls [`App::init`]
716/// with a [`fenestra_core::Proxy`] before the first frame; proxied messages
717/// wake the loop and repaint. Blocks until the window closes.
718pub fn run_app<A: App + 'static>(mut app: A, options: WindowOptions) -> Result<(), ShellError>
719where
720    A::Msg: Send,
721{
722    let event_loop = EventLoop::<RunnerEvent>::with_user_event()
723        .build()
724        .map_err(ShellError::EventLoop)?;
725    #[cfg(not(target_arch = "wasm32"))]
726    let access_proxy = event_loop.create_proxy();
727    let proxy = event_loop.create_proxy();
728    app.init(fenestra_core::Proxy::new(move |msg: A::Msg| {
729        // Dropped silently once the loop is gone (window closed).
730        let _ = proxy.send_event(RunnerEvent::App(Box::new(msg)));
731    }));
732    let background = app.theme().bg;
733    let mut fonts = Fonts::with_system();
734    for (role, data) in &options.fonts {
735        fonts.register(*role, data.clone());
736    }
737    #[cfg(target_arch = "wasm32")]
738    let state = FrameState::new();
739    #[cfg(not(target_arch = "wasm32"))]
740    let mut state = FrameState::new();
741    #[cfg(not(target_arch = "wasm32"))]
742    state.set_clipboard(Box::new(crate::OsClipboard::default()));
743    let runner = AppRunner {
744        shell: WindowShell::new(options, background),
745        app,
746        fonts,
747        state,
748        cursor: Point::ORIGIN,
749        started: Instant::now(),
750        last: None,
751        dirty: true,
752        cached_scene: None,
753        modifiers: winit::keyboard::ModifiersState::empty(),
754        #[cfg(not(target_arch = "wasm32"))]
755        adapter: None,
756        #[cfg(not(target_arch = "wasm32"))]
757        proxy: access_proxy,
758        #[cfg(not(target_arch = "wasm32"))]
759        secondary: std::collections::HashMap::new(),
760    };
761    #[cfg(not(target_arch = "wasm32"))]
762    {
763        let mut runner = runner;
764        event_loop
765            .run_app(&mut runner)
766            .map_err(ShellError::EventLoop)
767    }
768    #[cfg(target_arch = "wasm32")]
769    {
770        use winit::platform::web::EventLoopExtWebSys;
771        // Non-blocking on the web: the loop keeps running after main returns.
772        event_loop.spawn_app(runner);
773        Ok(())
774    }
775}
776
777struct AppRunner<A: App> {
778    shell: WindowShell,
779    app: A,
780    fonts: Fonts,
781    state: FrameState,
782    cursor: Point,
783    started: Instant,
784    /// View and frame from the last redraw, for input routing.
785    last: Option<(Element<A::Msg>, fenestra_core::Frame)>,
786    /// Anything changed since the last full frame? OS-driven redraws
787    /// (expose, un-occlude) re-present the cached scene when clean —
788    /// the whole build/layout/paint pipeline is skipped.
789    dirty: bool,
790    /// The last painted scene with its (logical w, h, scale) key.
791    cached_scene: Option<(Scene, (f64, f64, f64))>,
792    modifiers: winit::keyboard::ModifiersState,
793    /// The AccessKit adapter, created before the window first shows.
794    #[cfg(not(target_arch = "wasm32"))]
795    adapter: Option<accesskit_winit::Adapter>,
796    /// Loop proxy handed to the adapter for activation/action events.
797    #[cfg(not(target_arch = "wasm32"))]
798    proxy: EventLoopProxy<RunnerEvent>,
799    /// Secondary windows declared by [`App::windows`], keyed by their
800    /// stable key and reconciled after every update (native only).
801    #[cfg(not(target_arch = "wasm32"))]
802    secondary: std::collections::HashMap<String, SecondaryWindow<A>>,
803}
804
805/// One reconciled secondary window: its own surface, retained state, and
806/// accessibility adapter; app state and fonts are shared.
807#[cfg(not(target_arch = "wasm32"))]
808struct SecondaryWindow<A: App> {
809    shell: WindowShell,
810    state: FrameState,
811    cursor: Point,
812    last: Option<(Element<A::Msg>, fenestra_core::Frame)>,
813    on_close: A::Msg,
814    title: String,
815    adapter: Option<accesskit_winit::Adapter>,
816}
817
818impl<A: App> AppRunner<A> {
819    fn redraw(&mut self, event_loop: &ActiveEventLoop) {
820        self.shell.pump();
821        let Some((lw, lh, scale)) = self.shell.logical_size() else {
822            return;
823        };
824        // Clean frame at the same size: re-present the cached scene and
825        // skip build/layout/paint entirely (expose/un-occlude redraws).
826        if !self.dirty
827            && let Some((scene, key)) = &self.cached_scene
828            && *key == (lw, lh, scale)
829        {
830            self.shell.present(scene);
831            return;
832        }
833        let theme = self.app.theme();
834        self.shell.background = theme.bg;
835        self.state.tick(self.started.elapsed().as_secs_f64());
836        #[expect(clippy::cast_possible_truncation, reason = "window sizes fit in f32")]
837        let logical = (lw as f32, lh as f32);
838        let view = self.app.view_at(fenestra_core::MAIN_WINDOW, logical);
839        let frame = build_frame(
840            &view,
841            &theme,
842            &mut self.fonts,
843            &mut self.state,
844            logical,
845            scale,
846        );
847        // Single-pass live window: glass is tint-only here (see the `run_app`
848        // redraw note); two-pass blur is the headless golden path.
849        let scene = frame.paint(&mut self.fonts, &mut self.state);
850        self.shell.present(&scene);
851        // The frame is clean until something changes it; animation and
852        // hover refresh keep it dirty so the pipeline runs again.
853        self.cached_scene = Some((scene, (lw, lh, scale)));
854        self.dirty = frame.animating;
855        // Content may have moved under a stationary pointer (scroll,
856        // layout change): refresh hover and repaint once more if it did.
857        if refresh_hover(&view, &frame, &mut self.state)
858            && let Some(w) = self.shell.window()
859        {
860            self.dirty = true;
861            w.request_redraw();
862        }
863        if frame.animating {
864            #[cfg(not(target_arch = "wasm32"))]
865            event_loop.set_control_flow(ControlFlow::WaitUntil(
866                Instant::now() + Duration::from_millis(16),
867            ));
868            // The browser paces frames; just ask for the next one.
869            #[cfg(target_arch = "wasm32")]
870            if let Some(w) = self.shell.window() {
871                w.request_redraw();
872            }
873        } else {
874            #[cfg(not(target_arch = "wasm32"))]
875            let secondary_animating = self
876                .secondary
877                .values()
878                .any(|b| b.last.as_ref().is_some_and(|(_, f)| f.animating));
879            #[cfg(target_arch = "wasm32")]
880            let secondary_animating = false;
881            if !secondary_animating {
882                event_loop.set_control_flow(ControlFlow::Wait);
883            }
884        }
885        self.last = Some((view, frame));
886        // Anchor the IME candidate window to the focused caret.
887        if let Some(caret) = self.state.ime_caret()
888            && let Some(w) = self.shell.window()
889        {
890            w.set_ime_cursor_area(
891                winit::dpi::LogicalPosition::new(caret.x0, caret.y0),
892                winit::dpi::LogicalSize::new(1.0, caret.height()),
893            );
894        }
895        #[cfg(not(target_arch = "wasm32"))]
896        self.push_access_tree();
897    }
898
899    /// Pushes the current frame's accessibility projection to the platform
900    /// (no-op until assistive technology activates the tree).
901    #[cfg(not(target_arch = "wasm32"))]
902    fn push_access_tree(&mut self) {
903        let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
904        let focus = self.state.focused();
905        if let Some(adapter) = &mut self.adapter
906            && let Some((_, frame)) = &self.last
907        {
908            adapter.update_if_active(|| crate::access::tree_update(frame, focus, scale));
909        }
910    }
911
912    fn input(&mut self, event: InputEvent) -> bool {
913        let Some((view, frame)) = &self.last else {
914            return false;
915        };
916        let result = dispatch(view, frame, &mut self.state, &mut self.fonts, event);
917        if let Some(cursor) = result.cursor
918            && let Some(w) = self.shell.window()
919        {
920            w.set_cursor(winit::window::Cursor::Icon(map_cursor(cursor)));
921        }
922        let had_msgs = !result.msgs.is_empty();
923        for msg in result.msgs {
924            self.app.update(msg);
925        }
926        if result.redraw || had_msgs {
927            self.dirty = true;
928            if let Some(w) = self.shell.window() {
929                w.request_redraw();
930            }
931        }
932        had_msgs
933    }
934
935    /// Routes one input event into the main window, reconciling windows
936    /// afterwards if it produced messages.
937    fn input_main(&mut self, event_loop: &ActiveEventLoop, event: InputEvent) {
938        if self.input(event) {
939            self.after_update(event_loop);
940        }
941    }
942
943    /// Routes one input event into a secondary window, reconciling
944    /// windows afterwards if it produced messages.
945    #[cfg(not(target_arch = "wasm32"))]
946    fn secondary_input_main(&mut self, key: &str, event_loop: &ActiveEventLoop, event: InputEvent) {
947        if self.secondary_input(key, event) {
948            self.after_update(event_loop);
949        }
950    }
951
952    /// Reconciles secondary windows against [`App::windows`] and asks
953    /// every window for a repaint — called whenever messages were applied.
954    fn after_update(&mut self, event_loop: &ActiveEventLoop) {
955        self.dirty = true;
956        #[cfg(not(target_arch = "wasm32"))]
957        self.reconcile_windows(event_loop);
958        #[cfg(target_arch = "wasm32")]
959        let _ = event_loop;
960        if let Some(w) = self.shell.window() {
961            w.request_redraw();
962        }
963        #[cfg(not(target_arch = "wasm32"))]
964        for bundle in self.secondary.values() {
965            if let Some(w) = bundle.shell.window() {
966                w.request_redraw();
967            }
968        }
969    }
970
971    /// Opens, closes, and retitles secondary windows to match the app's
972    /// declared list.
973    #[cfg(not(target_arch = "wasm32"))]
974    fn reconcile_windows(&mut self, event_loop: &ActiveEventLoop) {
975        let desired = self.app.windows();
976        self.secondary
977            .retain(|key, _| desired.iter().any(|d| &d.key == key));
978        for desc in desired {
979            match self.secondary.get_mut(&desc.key) {
980                Some(bundle) => {
981                    bundle.on_close = desc.on_close;
982                    if bundle.title != desc.title {
983                        bundle.title.clone_from(&desc.title);
984                        if let Some(w) = bundle.shell.window() {
985                            w.set_title(&desc.title);
986                        }
987                    }
988                }
989                None => {
990                    let mut shell = WindowShell::new(
991                        WindowOptions::titled(desc.title.clone())
992                            .with_size(desc.size.0, desc.size.1),
993                        self.shell.background,
994                    );
995                    let proxy = self.proxy.clone();
996                    let mut adapter = None;
997                    shell.resumed_with(event_loop, |el, window| {
998                        adapter = Some(accesskit_winit::Adapter::with_event_loop_proxy(
999                            el, window, proxy,
1000                        ));
1001                    });
1002                    if let Some(w) = shell.window() {
1003                        w.set_ime_allowed(true);
1004                        w.request_redraw();
1005                    }
1006                    let mut state = FrameState::new();
1007                    state.set_clipboard(Box::new(crate::OsClipboard::default()));
1008                    self.secondary.insert(
1009                        desc.key.clone(),
1010                        SecondaryWindow {
1011                            shell,
1012                            state,
1013                            cursor: Point::ORIGIN,
1014                            last: None,
1015                            on_close: desc.on_close,
1016                            title: desc.title,
1017                            adapter,
1018                        },
1019                    );
1020                }
1021            }
1022        }
1023    }
1024
1025    /// Redraws one secondary window: the same pipeline as the main one,
1026    /// against its own retained state and `view_for(key)`.
1027    #[cfg(not(target_arch = "wasm32"))]
1028    fn secondary_redraw(&mut self, key: &str, event_loop: &ActiveEventLoop) {
1029        let theme = self.app.theme_for(key);
1030        let now = self.started.elapsed().as_secs_f64();
1031        let Some(bundle) = self.secondary.get_mut(key) else {
1032            return;
1033        };
1034        bundle.shell.pump();
1035        let Some((lw, lh, scale)) = bundle.shell.logical_size() else {
1036            return;
1037        };
1038        bundle.shell.background = theme.bg;
1039        bundle.state.tick(now);
1040        #[expect(clippy::cast_possible_truncation, reason = "window sizes fit in f32")]
1041        let logical = (lw as f32, lh as f32);
1042        let view = self.app.view_at(key, logical);
1043        let frame = build_frame(
1044            &view,
1045            &theme,
1046            &mut self.fonts,
1047            &mut bundle.state,
1048            logical,
1049            scale,
1050        );
1051        // Single-pass live window: glass is tint-only here (see the `run_app`
1052        // redraw note); two-pass blur is the headless golden path.
1053        let scene = frame.paint(&mut self.fonts, &mut bundle.state);
1054        bundle.shell.present(&scene);
1055        if refresh_hover(&view, &frame, &mut bundle.state)
1056            && let Some(w) = bundle.shell.window()
1057        {
1058            w.request_redraw();
1059        }
1060        if frame.animating {
1061            event_loop.set_control_flow(ControlFlow::WaitUntil(
1062                Instant::now() + Duration::from_millis(16),
1063            ));
1064        }
1065        if let Some(caret) = bundle.state.ime_caret()
1066            && let Some(w) = bundle.shell.window()
1067        {
1068            w.set_ime_cursor_area(
1069                winit::dpi::LogicalPosition::new(caret.x0, caret.y0),
1070                winit::dpi::LogicalSize::new(1.0, caret.height()),
1071            );
1072        }
1073        bundle.last = Some((view, frame));
1074        let focus = bundle.state.focused();
1075        if let Some(adapter) = &mut bundle.adapter
1076            && let Some((_, frame)) = &bundle.last
1077        {
1078            adapter.update_if_active(|| crate::access::tree_update(frame, focus, scale));
1079        }
1080    }
1081
1082    /// The full event handler for one secondary window — the same arms as
1083    /// the main window, against the bundle's own surface and state.
1084    #[cfg(not(target_arch = "wasm32"))]
1085    fn secondary_window_event(
1086        &mut self,
1087        key: &str,
1088        event_loop: &ActiveEventLoop,
1089        event: WindowEvent,
1090    ) {
1091        if let Some(bundle) = self.secondary.get_mut(key)
1092            && let Some(window) = bundle.shell.window()
1093            && let Some(adapter) = &mut bundle.adapter
1094        {
1095            adapter.process_event(window, &event);
1096        }
1097        match event {
1098            WindowEvent::CloseRequested => {
1099                if let Some(msg) = self.secondary.get(key).map(|b| b.on_close.clone()) {
1100                    self.app.update(msg);
1101                    self.after_update(event_loop);
1102                }
1103            }
1104            WindowEvent::Resized(size) => {
1105                if let Some(bundle) = self.secondary.get_mut(key) {
1106                    bundle.shell.resized(size.width, size.height);
1107                }
1108            }
1109            WindowEvent::ScaleFactorChanged { .. } | WindowEvent::Occluded(false) => {
1110                if let Some(w) = self.secondary.get(key).and_then(|b| b.shell.window()) {
1111                    w.request_redraw();
1112                }
1113            }
1114            WindowEvent::ModifiersChanged(mods) => {
1115                self.modifiers = mods.state();
1116                let m = self.modifiers;
1117                self.secondary_input_main(
1118                    key,
1119                    event_loop,
1120                    InputEvent::Modifiers {
1121                        shift: m.shift_key(),
1122                        ctrl: m.control_key(),
1123                        alt: m.alt_key(),
1124                        meta: m.super_key(),
1125                    },
1126                );
1127            }
1128            WindowEvent::DroppedFile(path) => {
1129                self.secondary_input_main(key, event_loop, InputEvent::FileDrop(path));
1130            }
1131            WindowEvent::CursorLeft { .. } => {
1132                self.secondary_input_main(key, event_loop, InputEvent::PointerLeave);
1133            }
1134            WindowEvent::CursorMoved { position, .. } => {
1135                let Some(bundle) = self.secondary.get_mut(key) else {
1136                    return;
1137                };
1138                let scale = bundle.shell.window().map_or(1.0, |w| w.scale_factor());
1139                bundle.cursor = Point::new(position.x / scale, position.y / scale);
1140                #[expect(clippy::cast_possible_truncation, reason = "positions fit in f32")]
1141                let (x, y) = (bundle.cursor.x as f32, bundle.cursor.y as f32);
1142                self.secondary_input_main(key, event_loop, InputEvent::PointerMove { x, y });
1143            }
1144            WindowEvent::MouseInput {
1145                state,
1146                button: winit::event::MouseButton::Left,
1147                ..
1148            } => {
1149                self.secondary_input_main(
1150                    key,
1151                    event_loop,
1152                    match state {
1153                        winit::event::ElementState::Pressed => InputEvent::PointerDown,
1154                        winit::event::ElementState::Released => InputEvent::PointerUp,
1155                    },
1156                );
1157            }
1158            WindowEvent::MouseInput {
1159                state,
1160                button: winit::event::MouseButton::Right,
1161                ..
1162            } => {
1163                self.secondary_input_main(
1164                    key,
1165                    event_loop,
1166                    match state {
1167                        winit::event::ElementState::Pressed => InputEvent::RightDown,
1168                        winit::event::ElementState::Released => InputEvent::RightUp,
1169                    },
1170                );
1171            }
1172            WindowEvent::MouseWheel { delta, .. } => {
1173                let scale = self
1174                    .secondary
1175                    .get(key)
1176                    .and_then(|b| b.shell.window())
1177                    .map_or(1.0, |w| w.scale_factor());
1178                let (dx, dy) = wheel_deltas(delta, scale);
1179                #[expect(clippy::cast_possible_truncation, reason = "deltas fit in f32")]
1180                self.secondary_input_main(
1181                    key,
1182                    event_loop,
1183                    InputEvent::Wheel {
1184                        dx: dx as f32,
1185                        dy: dy as f32,
1186                    },
1187                );
1188            }
1189            WindowEvent::KeyboardInput { event, .. }
1190                if event.state == winit::event::ElementState::Pressed =>
1191            {
1192                let mods = self.modifiers;
1193                let printable = !mods.control_key()
1194                    && !mods.super_key()
1195                    && event
1196                        .text
1197                        .as_ref()
1198                        .is_some_and(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()));
1199                if printable {
1200                    if let Some(t) = &event.text {
1201                        self.secondary_input_main(key, event_loop, InputEvent::Text(t.to_string()));
1202                    }
1203                } else if let Some(input) = map_key(&event, mods) {
1204                    self.secondary_input_main(key, event_loop, input);
1205                }
1206            }
1207            WindowEvent::Ime(ime) => match ime {
1208                winit::event::Ime::Preedit(text, cursor) => {
1209                    self.secondary_input_main(
1210                        key,
1211                        event_loop,
1212                        InputEvent::ImePreedit { text, cursor },
1213                    );
1214                }
1215                winit::event::Ime::Commit(text) => {
1216                    self.secondary_input_main(key, event_loop, InputEvent::Text(text));
1217                }
1218                winit::event::Ime::Enabled | winit::event::Ime::Disabled => {}
1219            },
1220            WindowEvent::RedrawRequested => self.secondary_redraw(key, event_loop),
1221            _ => {}
1222        }
1223    }
1224
1225    /// Dispatches one input event against a secondary window. Returns
1226    /// whether messages were applied (the caller then reconciles).
1227    #[cfg(not(target_arch = "wasm32"))]
1228    fn secondary_input(&mut self, key: &str, event: InputEvent) -> bool {
1229        let Some(bundle) = self.secondary.get_mut(key) else {
1230            return false;
1231        };
1232        let Some((view, frame)) = &bundle.last else {
1233            return false;
1234        };
1235        let result = dispatch(view, frame, &mut bundle.state, &mut self.fonts, event);
1236        if let Some(cursor) = result.cursor
1237            && let Some(w) = bundle.shell.window()
1238        {
1239            w.set_cursor(winit::window::Cursor::Icon(map_cursor(cursor)));
1240        }
1241        let had_msgs = !result.msgs.is_empty();
1242        if (result.redraw || had_msgs)
1243            && let Some(w) = bundle.shell.window()
1244        {
1245            w.request_redraw();
1246        }
1247        let msgs = result.msgs;
1248        for msg in msgs {
1249            self.app.update(msg);
1250        }
1251        had_msgs
1252    }
1253}
1254
1255pub(crate) fn map_cursor(cursor: fenestra_core::Cursor) -> winit::window::CursorIcon {
1256    match cursor {
1257        fenestra_core::Cursor::Default => winit::window::CursorIcon::Default,
1258        fenestra_core::Cursor::Pointer => winit::window::CursorIcon::Pointer,
1259        fenestra_core::Cursor::Text => winit::window::CursorIcon::Text,
1260        fenestra_core::Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
1261    }
1262}
1263
1264/// Translates a winit key event into a fenestra [`InputEvent`].
1265pub(crate) fn map_key(
1266    event: &winit::event::KeyEvent,
1267    mods: winit::keyboard::ModifiersState,
1268) -> Option<InputEvent> {
1269    use winit::keyboard::{Key as WKey, NamedKey};
1270    let key = match &event.logical_key {
1271        WKey::Named(NamedKey::Tab) => {
1272            return Some(if mods.shift_key() {
1273                InputEvent::ShiftTab
1274            } else {
1275                InputEvent::Tab
1276            });
1277        }
1278        WKey::Named(named) => match named {
1279            NamedKey::Enter => Key::Enter,
1280            NamedKey::Space => Key::Space,
1281            NamedKey::Escape => Key::Escape,
1282            NamedKey::ArrowLeft => Key::ArrowLeft,
1283            NamedKey::ArrowRight => Key::ArrowRight,
1284            NamedKey::ArrowUp => Key::ArrowUp,
1285            NamedKey::ArrowDown => Key::ArrowDown,
1286            NamedKey::Home => Key::Home,
1287            NamedKey::End => Key::End,
1288            NamedKey::Backspace => Key::Backspace,
1289            NamedKey::Delete => Key::Delete,
1290            NamedKey::PageUp => Key::PageUp,
1291            NamedKey::PageDown => Key::PageDown,
1292            _ => return None,
1293        },
1294        WKey::Character(s) => Key::Char(s.chars().next()?),
1295        _ => return None,
1296    };
1297    Some(InputEvent::Key(KeyInput {
1298        key,
1299        shift: mods.shift_key(),
1300        ctrl: mods.control_key(),
1301        alt: mods.alt_key(),
1302        meta: mods.super_key(),
1303    }))
1304}
1305
1306impl<A: App> ApplicationHandler<RunnerEvent> for AppRunner<A> {
1307    #[cfg(not(target_arch = "wasm32"))]
1308    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
1309        // Fresh surface, possibly fresh scale: rebuild rather than trust
1310        // a scene cached across the suspend.
1311        self.dirty = true;
1312        let adapter = &mut self.adapter;
1313        let proxy = self.proxy.clone();
1314        self.shell.resumed_with(event_loop, |el, window| {
1315            // The adapter must attach while the window is still hidden.
1316            if adapter.is_none() {
1317                *adapter = Some(accesskit_winit::Adapter::with_event_loop_proxy(
1318                    el, window, proxy,
1319                ));
1320            }
1321        });
1322        if let Some(w) = self.shell.window() {
1323            w.set_ime_allowed(true);
1324        }
1325        for bundle in self.secondary.values_mut() {
1326            bundle.shell.resumed(event_loop);
1327        }
1328        self.reconcile_windows(event_loop);
1329    }
1330
1331    #[cfg(target_arch = "wasm32")]
1332    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
1333        self.dirty = true;
1334        self.shell.resumed(event_loop);
1335        if let Some(w) = self.shell.window() {
1336            w.set_ime_allowed(true);
1337        }
1338    }
1339
1340    fn user_event(&mut self, event_loop: &ActiveEventLoop, event: RunnerEvent) {
1341        match event {
1342            RunnerEvent::App(msg) => {
1343                if let Ok(msg) = msg.downcast::<A::Msg>() {
1344                    self.app.update(*msg);
1345                    self.after_update(event_loop);
1346                }
1347            }
1348            #[cfg(not(target_arch = "wasm32"))]
1349            RunnerEvent::Access(ev) => {
1350                // Route by window id: `None` is the main window, `Some(key)`
1351                // a secondary one; unknown ids are stale events.
1352                let is_main = self.shell.window().is_some_and(|w| w.id() == ev.window_id);
1353                let skey = (!is_main)
1354                    .then(|| {
1355                        self.secondary
1356                            .iter()
1357                            .find(|(_, b)| b.shell.window().is_some_and(|w| w.id() == ev.window_id))
1358                            .map(|(k, _)| k.clone())
1359                    })
1360                    .flatten();
1361                if !is_main && skey.is_none() {
1362                    return;
1363                }
1364                match ev.window_event {
1365                    accesskit_winit::WindowEvent::InitialTreeRequested => match &skey {
1366                        None => {
1367                            if self.last.is_some() {
1368                                self.push_access_tree();
1369                            } else if let Some(w) = self.shell.window() {
1370                                w.request_redraw();
1371                            }
1372                        }
1373                        Some(key) => {
1374                            if let Some(bundle) = self.secondary.get_mut(key) {
1375                                let scale = bundle.shell.window().map_or(1.0, |w| w.scale_factor());
1376                                let focus = bundle.state.focused();
1377                                if let Some((_, frame)) = &bundle.last {
1378                                    if let Some(adapter) = &mut bundle.adapter {
1379                                        adapter.update_if_active(|| {
1380                                            crate::access::tree_update(frame, focus, scale)
1381                                        });
1382                                    }
1383                                } else if let Some(w) = bundle.shell.window() {
1384                                    w.request_redraw();
1385                                }
1386                            }
1387                        }
1388                    },
1389                    accesskit_winit::WindowEvent::ActionRequested(req) => {
1390                        let id = fenestra_core::WidgetId(req.target_node.0);
1391                        match req.action {
1392                            accesskit::Action::Click => {
1393                                let msg = match &skey {
1394                                    None => self.last.as_ref().and_then(|(view, frame)| {
1395                                        fenestra_core::click_msg_of(view, frame, &self.state, id)
1396                                    }),
1397                                    Some(key) => self.secondary.get(key).and_then(|bundle| {
1398                                        bundle.last.as_ref().and_then(|(view, frame)| {
1399                                            fenestra_core::click_msg_of(
1400                                                view,
1401                                                frame,
1402                                                &bundle.state,
1403                                                id,
1404                                            )
1405                                        })
1406                                    }),
1407                                };
1408                                if let Some(msg) = msg {
1409                                    self.app.update(msg);
1410                                    self.after_update(event_loop);
1411                                }
1412                            }
1413                            accesskit::Action::Focus => match &skey {
1414                                None => {
1415                                    self.state.set_focus(Some(id));
1416                                    self.dirty = true;
1417                                    if let Some(w) = self.shell.window() {
1418                                        w.request_redraw();
1419                                    }
1420                                }
1421                                Some(key) => {
1422                                    if let Some(bundle) = self.secondary.get_mut(key) {
1423                                        bundle.state.set_focus(Some(id));
1424                                        if let Some(w) = bundle.shell.window() {
1425                                            w.request_redraw();
1426                                        }
1427                                    }
1428                                }
1429                            },
1430                            _ => {}
1431                        }
1432                    }
1433                    accesskit_winit::WindowEvent::AccessibilityDeactivated => {}
1434                }
1435            }
1436        }
1437    }
1438
1439    fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
1440        self.shell.suspended();
1441        #[cfg(not(target_arch = "wasm32"))]
1442        for bundle in self.secondary.values_mut() {
1443            bundle.shell.suspended();
1444        }
1445    }
1446
1447    fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
1448        if !matches!(cause, StartCause::ResumeTimeReached { .. }) {
1449            return;
1450        }
1451        if let Some(w) = self.shell.window() {
1452            w.request_redraw();
1453        }
1454        #[cfg(not(target_arch = "wasm32"))]
1455        for bundle in self.secondary.values() {
1456            if let Some(w) = bundle.shell.window() {
1457                w.request_redraw();
1458            }
1459        }
1460    }
1461
1462    fn window_event(
1463        &mut self,
1464        event_loop: &ActiveEventLoop,
1465        window_id: WindowId,
1466        event: WindowEvent,
1467    ) {
1468        if self.shell.window().is_none_or(|w| w.id() != window_id) {
1469            #[cfg(not(target_arch = "wasm32"))]
1470            if let Some(key) = self
1471                .secondary
1472                .iter()
1473                .find(|(_, b)| b.shell.window().is_some_and(|w| w.id() == window_id))
1474                .map(|(k, _)| k.clone())
1475            {
1476                self.secondary_window_event(&key, event_loop, event);
1477            }
1478            return;
1479        }
1480        #[cfg(not(target_arch = "wasm32"))]
1481        if let Some(adapter) = &mut self.adapter
1482            && let Some(window) = self.shell.window()
1483        {
1484            adapter.process_event(window, &event);
1485        }
1486        match event {
1487            WindowEvent::CloseRequested => event_loop.exit(),
1488            WindowEvent::Resized(size) => {
1489                // The cache key also guards size, but coalesced resizes can
1490                // land back on the cached geometry mid-drag.
1491                self.dirty = true;
1492                self.shell.resized(size.width, size.height);
1493            }
1494            WindowEvent::ScaleFactorChanged { .. } => {
1495                self.dirty = true;
1496                if let Some(w) = self.shell.window() {
1497                    w.request_redraw();
1498                }
1499            }
1500            WindowEvent::ModifiersChanged(mods) => {
1501                self.modifiers = mods.state();
1502                let m = self.modifiers;
1503                self.input_main(
1504                    event_loop,
1505                    InputEvent::Modifiers {
1506                        shift: m.shift_key(),
1507                        ctrl: m.control_key(),
1508                        alt: m.alt_key(),
1509                        meta: m.super_key(),
1510                    },
1511                );
1512            }
1513            WindowEvent::Occluded(occluded) => {
1514                if !occluded && let Some(w) = self.shell.window() {
1515                    w.request_redraw();
1516                }
1517            }
1518            WindowEvent::DroppedFile(path) => {
1519                self.input_main(event_loop, InputEvent::FileDrop(path))
1520            }
1521            WindowEvent::CursorLeft { .. } => self.input_main(event_loop, InputEvent::PointerLeave),
1522            WindowEvent::CursorMoved { position, .. } => {
1523                let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
1524                self.cursor = Point::new(position.x / scale, position.y / scale);
1525                #[expect(clippy::cast_possible_truncation, reason = "positions fit in f32")]
1526                self.input_main(
1527                    event_loop,
1528                    InputEvent::PointerMove {
1529                        x: self.cursor.x as f32,
1530                        y: self.cursor.y as f32,
1531                    },
1532                );
1533            }
1534            WindowEvent::MouseInput {
1535                state,
1536                button: winit::event::MouseButton::Left,
1537                ..
1538            } => {
1539                self.input_main(
1540                    event_loop,
1541                    match state {
1542                        winit::event::ElementState::Pressed => InputEvent::PointerDown,
1543                        winit::event::ElementState::Released => InputEvent::PointerUp,
1544                    },
1545                );
1546            }
1547            WindowEvent::MouseInput {
1548                state,
1549                button: winit::event::MouseButton::Right,
1550                ..
1551            } => {
1552                self.input_main(
1553                    event_loop,
1554                    match state {
1555                        winit::event::ElementState::Pressed => InputEvent::RightDown,
1556                        winit::event::ElementState::Released => InputEvent::RightUp,
1557                    },
1558                );
1559            }
1560            WindowEvent::MouseWheel { delta, .. } => {
1561                let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
1562                let (dx, dy) = wheel_deltas(delta, scale);
1563                #[expect(clippy::cast_possible_truncation, reason = "deltas fit in f32")]
1564                self.input_main(
1565                    event_loop,
1566                    InputEvent::Wheel {
1567                        dx: dx as f32,
1568                        dy: dy as f32,
1569                    },
1570                );
1571            }
1572            WindowEvent::KeyboardInput { event, .. }
1573                if event.state == winit::event::ElementState::Pressed =>
1574            {
1575                {
1576                    let mods = self.modifiers;
1577                    // Printable input arrives as Text (it may be multi-char);
1578                    // named keys and shortcuts go through Key.
1579                    let printable = !mods.control_key()
1580                        && !mods.super_key()
1581                        && event
1582                            .text
1583                            .as_ref()
1584                            .is_some_and(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()));
1585                    if printable {
1586                        if let Some(t) = &event.text {
1587                            self.input_main(event_loop, InputEvent::Text(t.to_string()));
1588                        }
1589                    } else if let Some(input) = map_key(&event, mods) {
1590                        self.input_main(event_loop, input);
1591                    }
1592                }
1593            }
1594            WindowEvent::Ime(ime) => match ime {
1595                winit::event::Ime::Preedit(text, cursor) => {
1596                    self.input_main(event_loop, InputEvent::ImePreedit { text, cursor });
1597                }
1598                winit::event::Ime::Commit(text) => {
1599                    self.input_main(event_loop, InputEvent::Text(text));
1600                }
1601                winit::event::Ime::Enabled | winit::event::Ime::Disabled => {}
1602            },
1603            WindowEvent::RedrawRequested => self.redraw(event_loop),
1604            _ => {}
1605        }
1606    }
1607}