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.
35const LINE_SCROLL_PX: f64 = 40.0;
36
37/// A raw paint callback: `(scene, logical_w, logical_h, background)`.
38#[cfg(not(target_arch = "wasm32"))]
39type PaintFn = Box<dyn FnMut(&mut Scene, f64, f64, Color)>;
40/// A message-free element view function.
41#[cfg(not(target_arch = "wasm32"))]
42type ViewFn = Box<dyn Fn(&Theme) -> Element<()>>;
43
44/// Options for the application window.
45#[derive(Debug, Clone)]
46pub struct WindowOptions {
47    /// Window title.
48    pub title: String,
49    /// Initial inner size in logical pixels.
50    pub inner_size: (f64, f64),
51}
52
53impl WindowOptions {
54    /// A window with the given title and the default 1024x768 logical size.
55    pub fn titled(title: impl Into<String>) -> Self {
56        Self {
57            title: title.into(),
58            inner_size: (1024.0, 768.0),
59        }
60    }
61
62    /// Sets the initial inner size in logical pixels.
63    pub fn with_size(mut self, width: f64, height: f64) -> Self {
64        self.inner_size = (width, height);
65        self
66    }
67}
68
69enum RenderState {
70    Active {
71        surface: Box<RenderSurface<'static>>,
72        valid_surface: bool,
73        window: Arc<Window>,
74    },
75    Suspended(Option<Arc<Window>>),
76    /// Window created; the async surface setup is in flight (web only).
77    #[cfg(target_arch = "wasm32")]
78    Pending(Arc<Window>),
79}
80
81/// Shared surface plumbing for every windowed runner.
82struct WindowShell {
83    context: RenderContext,
84    renderers: Vec<Option<Renderer>>,
85    state: RenderState,
86    scene: Scene,
87    options: WindowOptions,
88    background: Color,
89    /// Completed async surface setup, parked until the next [`Self::pump`]
90    /// (web only; the web is single-threaded so `Rc<RefCell>` suffices).
91    #[cfg(target_arch = "wasm32")]
92    ready: WasmReady,
93}
94
95/// The handoff slot for the web's async surface creation.
96#[cfg(target_arch = "wasm32")]
97type WasmReady =
98    std::rc::Rc<std::cell::RefCell<Option<(RenderContext, Box<RenderSurface<'static>>)>>>;
99
100impl WindowShell {
101    fn new(options: WindowOptions, background: Color) -> Self {
102        Self {
103            context: RenderContext::new(),
104            renderers: Vec::new(),
105            state: RenderState::Suspended(None),
106            scene: Scene::new(),
107            options,
108            background,
109            #[cfg(target_arch = "wasm32")]
110            ready: WasmReady::default(),
111        }
112    }
113
114    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
115        self.resumed_with(event_loop, |_, _| {});
116    }
117
118    /// Like [`Self::resumed`], but runs `before_visible` between window
119    /// creation and the first `set_visible(true)` — the AccessKit adapter
120    /// must attach while the window is still hidden.
121    fn resumed_with(
122        &mut self,
123        event_loop: &ActiveEventLoop,
124        before_visible: impl FnOnce(&ActiveEventLoop, &Arc<Window>),
125    ) {
126        let RenderState::Suspended(cached_window) = &mut self.state else {
127            return;
128        };
129        let window = cached_window.take().unwrap_or_else(|| {
130            let attrs = Window::default_attributes()
131                .with_title(self.options.title.clone())
132                .with_inner_size(LogicalSize::new(
133                    self.options.inner_size.0,
134                    self.options.inner_size.1,
135                ))
136                .with_visible(false);
137            #[cfg(target_arch = "wasm32")]
138            let attrs = {
139                use winit::platform::web::WindowAttributesExtWebSys;
140                // winit creates the canvas; have it inserted into the page.
141                attrs.with_append(true)
142            };
143            Arc::new(
144                event_loop
145                    .create_window(attrs)
146                    .expect("failed to create window"),
147            )
148        });
149        before_visible(event_loop, &window);
150        let was_hidden = window.is_visible() == Some(false);
151        self.activate(window.clone());
152        if was_hidden {
153            window.set_visible(true);
154        }
155    }
156
157    /// Builds (or rebuilds, after a lost surface) the swapchain for `window`
158    /// and enters the active state.
159    #[cfg(not(target_arch = "wasm32"))]
160    fn activate(&mut self, window: Arc<Window>) {
161        let size = window.inner_size();
162        let surface = pollster::block_on(self.context.create_surface(
163            window.clone(),
164            size.width.max(1),
165            size.height.max(1),
166            wgpu::PresentMode::AutoVsync,
167        ))
168        .expect("failed to create wgpu surface");
169
170        self.renderers
171            .resize_with(self.context.devices.len(), || None);
172        self.renderers[surface.dev_id].get_or_insert_with(|| {
173            Renderer::new(
174                &self.context.devices[surface.dev_id].device,
175                RendererOptions {
176                    use_cpu: false,
177                    antialiasing_support: AaSupport::area_only(),
178                    ..Default::default()
179                },
180            )
181            .expect("failed to create vello renderer")
182        });
183
184        self.state = RenderState::Active {
185            surface: Box::new(surface),
186            valid_surface: size.width != 0 && size.height != 0,
187            window,
188        };
189    }
190
191    /// Web: surface/device setup is async — kick it off and park in
192    /// `Pending`; [`Self::pump`] finishes the activation when it lands.
193    #[cfg(target_arch = "wasm32")]
194    fn activate(&mut self, window: Arc<Window>) {
195        let size = window.inner_size();
196        let ready = std::rc::Rc::clone(&self.ready);
197        let win = window.clone();
198        wasm_bindgen_futures::spawn_local(async move {
199            let mut context = RenderContext::new();
200            let surface = context
201                .create_surface(
202                    win.clone(),
203                    size.width.max(1),
204                    size.height.max(1),
205                    wgpu::PresentMode::AutoVsync,
206                )
207                .await
208                .expect("failed to create wgpu surface");
209            *ready.borrow_mut() = Some((context, Box::new(surface)));
210            win.request_redraw();
211        });
212        self.state = RenderState::Pending(window);
213    }
214
215    /// Completes a pending web activation once the async setup finished.
216    /// No-op on native and while nothing is pending.
217    fn pump(&mut self) {
218        #[cfg(target_arch = "wasm32")]
219        if let RenderState::Pending(window) = &self.state
220            && let Some((context, surface)) = self.ready.borrow_mut().take()
221        {
222            let window = window.clone();
223            self.context = context;
224            self.renderers.clear();
225            self.renderers
226                .resize_with(self.context.devices.len(), || None);
227            self.renderers[surface.dev_id].get_or_insert_with(|| {
228                Renderer::new(
229                    &self.context.devices[surface.dev_id].device,
230                    RendererOptions {
231                        use_cpu: false,
232                        antialiasing_support: AaSupport::area_only(),
233                        ..Default::default()
234                    },
235                )
236                .expect("failed to create vello renderer")
237            });
238            let size = window.inner_size();
239            self.state = RenderState::Active {
240                surface,
241                valid_surface: size.width != 0 && size.height != 0,
242                window,
243            };
244        }
245    }
246
247    fn suspended(&mut self) {
248        if let RenderState::Active { window, .. } = &self.state {
249            self.state = RenderState::Suspended(Some(window.clone()));
250        }
251    }
252
253    fn window(&self) -> Option<&Arc<Window>> {
254        match &self.state {
255            RenderState::Active { window, .. } => Some(window),
256            _ => None,
257        }
258    }
259
260    fn resized(&mut self, width: u32, height: u32) {
261        let RenderState::Active {
262            surface,
263            valid_surface,
264            window,
265        } = &mut self.state
266        else {
267            return;
268        };
269        if width != 0 && height != 0 {
270            self.context.resize_surface(surface, width, height);
271            *valid_surface = true;
272        } else {
273            *valid_surface = false;
274        }
275        window.request_redraw();
276    }
277
278    /// Logical size and scale factor of the active window.
279    fn logical_size(&self) -> Option<(f64, f64, f64)> {
280        match &self.state {
281            RenderState::Active {
282                surface, window, ..
283            } => {
284                let scale = window.scale_factor();
285                Some((
286                    f64::from(surface.config.width) / scale,
287                    f64::from(surface.config.height) / scale,
288                    scale,
289                ))
290            }
291            _ => None,
292        }
293    }
294
295    /// Scales the logical fragment to physical pixels and presents it.
296    fn present(&mut self, fragment: &Scene) {
297        let RenderState::Active {
298            surface,
299            valid_surface,
300            window,
301        } = &mut self.state
302        else {
303            return;
304        };
305        if !*valid_surface {
306            return;
307        }
308        let width = surface.config.width;
309        let height = surface.config.height;
310        let scale = window.scale_factor();
311
312        self.scene.reset();
313        self.scene
314            .append(fragment, Some(vello::kurbo::Affine::scale(scale)));
315
316        let handle = &self.context.devices[surface.dev_id];
317        self.renderers[surface.dev_id]
318            .as_mut()
319            .expect("renderer exists for surface device")
320            .render_to_texture(
321                &handle.device,
322                &handle.queue,
323                &self.scene,
324                &surface.target_view,
325                &RenderParams {
326                    base_color: self.background,
327                    width,
328                    height,
329                    antialiasing_method: AaConfig::Area,
330                },
331            )
332            .expect("vello render failed");
333
334        let surface_texture = match surface.surface.get_current_texture() {
335            CurrentSurfaceTexture::Success(texture) => texture,
336            CurrentSurfaceTexture::Outdated | CurrentSurfaceTexture::Suboptimal(_) => {
337                self.context.configure_surface(surface);
338                window.request_redraw();
339                return;
340            }
341            CurrentSurfaceTexture::Occluded => {
342                // Hidden window: skip the frame; WindowEvent::Occluded(false)
343                // requests the next redraw when it becomes visible again.
344                return;
345            }
346            CurrentSurfaceTexture::Timeout => {
347                window.request_redraw();
348                return;
349            }
350            CurrentSurfaceTexture::Lost => {
351                // Recoverable (GPU reset, driver update, display change):
352                // rebuild the swapchain on the same window and repaint.
353                let window = window.clone();
354                window.request_redraw();
355                self.activate(window);
356                return;
357            }
358            CurrentSurfaceTexture::Validation => {
359                panic!("validation error acquiring wgpu surface texture")
360            }
361        };
362
363        let mut encoder = handle
364            .device
365            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
366                label: Some("fenestra surface blit"),
367            });
368        surface.blitter.copy(
369            &handle.device,
370            &mut encoder,
371            &surface.target_view,
372            &surface_texture
373                .texture
374                .create_view(&wgpu::TextureViewDescriptor::default()),
375        );
376        handle.queue.submit([encoder.finish()]);
377        surface_texture.present();
378        handle.device.poll(wgpu::PollType::Poll).unwrap();
379    }
380}
381
382// ------------------------------------------------------------- run_scene
383
384/// Opens a window and repaints via `paint(scene, logical_w, logical_h, bg)`
385/// on every redraw. Blocks until the window closes. Low-level escape hatch;
386/// element views should prefer [`run_static`] (or the M4 `App` runner).
387#[cfg(not(target_arch = "wasm32"))]
388pub fn run_scene(
389    options: WindowOptions,
390    background: Color,
391    paint: impl FnMut(&mut Scene, f64, f64, Color) + 'static,
392) -> Result<(), ShellError> {
393    let event_loop = EventLoop::new().map_err(ShellError::EventLoop)?;
394    let mut app = SceneApp {
395        shell: WindowShell::new(options, background),
396        fragment: Scene::new(),
397        paint: Box::new(paint),
398    };
399    event_loop.run_app(&mut app).map_err(ShellError::EventLoop)
400}
401
402#[cfg(not(target_arch = "wasm32"))]
403struct SceneApp {
404    shell: WindowShell,
405    fragment: Scene,
406    paint: PaintFn,
407}
408
409#[cfg(not(target_arch = "wasm32"))]
410impl ApplicationHandler for SceneApp {
411    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
412        self.shell.resumed(event_loop);
413    }
414
415    fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
416        self.shell.suspended();
417    }
418
419    fn window_event(
420        &mut self,
421        event_loop: &ActiveEventLoop,
422        window_id: WindowId,
423        event: WindowEvent,
424    ) {
425        if self.shell.window().is_none_or(|w| w.id() != window_id) {
426            return;
427        }
428        match event {
429            WindowEvent::CloseRequested => event_loop.exit(),
430            WindowEvent::Resized(size) => self.shell.resized(size.width, size.height),
431            WindowEvent::ScaleFactorChanged { .. } => {
432                if let Some(w) = self.shell.window() {
433                    w.request_redraw();
434                }
435            }
436            WindowEvent::Occluded(occluded) => {
437                if !occluded && let Some(w) = self.shell.window() {
438                    w.request_redraw();
439                }
440            }
441            WindowEvent::RedrawRequested => {
442                let Some((lw, lh, _scale)) = self.shell.logical_size() else {
443                    return;
444                };
445                self.fragment.reset();
446                let bg = self.shell.background;
447                (self.paint)(&mut self.fragment, lw, lh, bg);
448                let fragment = std::mem::replace(&mut self.fragment, Scene::new());
449                self.shell.present(&fragment);
450                self.fragment = fragment;
451            }
452            _ => {}
453        }
454    }
455}
456
457// ------------------------------------------------------------- run_static
458
459/// Opens a window showing a message-free element view. The view is rebuilt
460/// on every redraw; scroll state persists in a [`FrameState`]. Blocks until
461/// the window closes.
462#[cfg(not(target_arch = "wasm32"))]
463pub fn run_static(
464    options: WindowOptions,
465    theme: Theme,
466    view: impl Fn(&Theme) -> Element<()> + 'static,
467) -> Result<(), ShellError> {
468    let event_loop = EventLoop::new().map_err(ShellError::EventLoop)?;
469    let background = theme.bg;
470    let mut app = StaticApp {
471        shell: WindowShell::new(options, background),
472        theme,
473        fonts: Fonts::with_system(),
474        state: FrameState::new(),
475        view: Box::new(view),
476        cursor: Point::ORIGIN,
477        started: Instant::now(),
478        last_frame: None,
479    };
480    event_loop.run_app(&mut app).map_err(ShellError::EventLoop)
481}
482
483#[cfg(not(target_arch = "wasm32"))]
484struct StaticApp {
485    shell: WindowShell,
486    theme: Theme,
487    fonts: Fonts,
488    state: FrameState,
489    view: ViewFn,
490    /// Cursor position in logical coordinates.
491    cursor: Point,
492    started: Instant,
493    /// The frame from the last redraw, used to route input between frames.
494    last_frame: Option<fenestra_core::Frame>,
495}
496
497#[cfg(not(target_arch = "wasm32"))]
498impl StaticApp {
499    fn redraw(&mut self, event_loop: &ActiveEventLoop) {
500        let Some((lw, lh, scale)) = self.shell.logical_size() else {
501            return;
502        };
503        self.state.tick(self.started.elapsed().as_secs_f64());
504        let el = (self.view)(&self.theme);
505        #[expect(clippy::cast_possible_truncation, reason = "window sizes fit in f32")]
506        let frame = build_frame(
507            &el,
508            &self.theme,
509            &mut self.fonts,
510            &mut self.state,
511            (lw as f32, lh as f32),
512            scale,
513        );
514        let scene = frame.paint(&mut self.fonts, &mut self.state);
515        self.shell.present(&scene);
516        if frame.animating {
517            event_loop.set_control_flow(ControlFlow::WaitUntil(
518                Instant::now() + Duration::from_millis(16),
519            ));
520        } else {
521            event_loop.set_control_flow(ControlFlow::Wait);
522        }
523        self.last_frame = Some(frame);
524    }
525}
526
527#[cfg(not(target_arch = "wasm32"))]
528impl ApplicationHandler for StaticApp {
529    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
530        self.shell.resumed(event_loop);
531    }
532
533    fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
534        self.shell.suspended();
535    }
536
537    fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
538        if matches!(cause, StartCause::ResumeTimeReached { .. })
539            && let Some(w) = self.shell.window()
540        {
541            w.request_redraw();
542        }
543    }
544
545    fn window_event(
546        &mut self,
547        event_loop: &ActiveEventLoop,
548        window_id: WindowId,
549        event: WindowEvent,
550    ) {
551        if self.shell.window().is_none_or(|w| w.id() != window_id) {
552            return;
553        }
554        match event {
555            WindowEvent::CloseRequested => event_loop.exit(),
556            WindowEvent::Resized(size) => self.shell.resized(size.width, size.height),
557            WindowEvent::ScaleFactorChanged { .. } => {
558                if let Some(w) = self.shell.window() {
559                    w.request_redraw();
560                }
561            }
562            WindowEvent::CursorMoved { position, .. } => {
563                let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
564                self.cursor = Point::new(position.x / scale, position.y / scale);
565            }
566            WindowEvent::MouseWheel { delta, .. } => {
567                let dy = match delta {
568                    MouseScrollDelta::LineDelta(_, y) => f64::from(y) * LINE_SCROLL_PX,
569                    MouseScrollDelta::PixelDelta(pos) => {
570                        let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
571                        pos.y / scale
572                    }
573                };
574                if let Some(frame) = &self.last_frame
575                    && let Some(id) = frame.scrollable_at(self.cursor)
576                {
577                    #[expect(
578                        clippy::cast_possible_truncation,
579                        reason = "scroll deltas fit in f32"
580                    )]
581                    self.state.scroll_by(id, -dy as f32);
582                    if let Some(w) = self.shell.window() {
583                        w.request_redraw();
584                    }
585                }
586            }
587            WindowEvent::RedrawRequested => self.redraw(event_loop),
588            _ => {}
589        }
590    }
591}
592
593// ------------------------------------------------------------- run_app
594
595/// User events crossing into the app runner's loop: type-erased app
596/// messages from a [`fenestra_core::Proxy`] (any thread), and AccessKit's
597/// activation/action events.
598enum RunnerEvent {
599    App(Box<dyn std::any::Any + Send>),
600    #[cfg(not(target_arch = "wasm32"))]
601    Access(accesskit_winit::Event),
602}
603
604#[cfg(not(target_arch = "wasm32"))]
605impl From<accesskit_winit::Event> for RunnerEvent {
606    fn from(event: accesskit_winit::Event) -> Self {
607        Self::Access(event)
608    }
609}
610
611/// Runs an [`App`]: the full Elm-shaped loop with hit testing, hover/active/
612/// focus, keyboard navigation, message dispatch, and event-driven repaint
613/// (animation frames only while something animates). Calls [`App::init`]
614/// with a [`fenestra_core::Proxy`] before the first frame; proxied messages
615/// wake the loop and repaint. Blocks until the window closes.
616pub fn run_app<A: App + 'static>(mut app: A, options: WindowOptions) -> Result<(), ShellError>
617where
618    A::Msg: Send,
619{
620    let event_loop = EventLoop::<RunnerEvent>::with_user_event()
621        .build()
622        .map_err(ShellError::EventLoop)?;
623    #[cfg(not(target_arch = "wasm32"))]
624    let access_proxy = event_loop.create_proxy();
625    let proxy = event_loop.create_proxy();
626    app.init(fenestra_core::Proxy::new(move |msg: A::Msg| {
627        // Dropped silently once the loop is gone (window closed).
628        let _ = proxy.send_event(RunnerEvent::App(Box::new(msg)));
629    }));
630    let background = app.theme().bg;
631    #[cfg(target_arch = "wasm32")]
632    let state = FrameState::new();
633    #[cfg(not(target_arch = "wasm32"))]
634    let mut state = FrameState::new();
635    #[cfg(not(target_arch = "wasm32"))]
636    state.set_clipboard(Box::new(crate::OsClipboard::default()));
637    let runner = AppRunner {
638        shell: WindowShell::new(options, background),
639        app,
640        fonts: Fonts::with_system(),
641        state,
642        cursor: Point::ORIGIN,
643        started: Instant::now(),
644        last: None,
645        modifiers: winit::keyboard::ModifiersState::empty(),
646        #[cfg(not(target_arch = "wasm32"))]
647        adapter: None,
648        #[cfg(not(target_arch = "wasm32"))]
649        proxy: access_proxy,
650    };
651    #[cfg(not(target_arch = "wasm32"))]
652    {
653        let mut runner = runner;
654        event_loop
655            .run_app(&mut runner)
656            .map_err(ShellError::EventLoop)
657    }
658    #[cfg(target_arch = "wasm32")]
659    {
660        use winit::platform::web::EventLoopExtWebSys;
661        // Non-blocking on the web: the loop keeps running after main returns.
662        event_loop.spawn_app(runner);
663        Ok(())
664    }
665}
666
667struct AppRunner<A: App> {
668    shell: WindowShell,
669    app: A,
670    fonts: Fonts,
671    state: FrameState,
672    cursor: Point,
673    started: Instant,
674    /// View and frame from the last redraw, for input routing.
675    last: Option<(Element<A::Msg>, fenestra_core::Frame)>,
676    modifiers: winit::keyboard::ModifiersState,
677    /// The AccessKit adapter, created before the window first shows.
678    #[cfg(not(target_arch = "wasm32"))]
679    adapter: Option<accesskit_winit::Adapter>,
680    /// Loop proxy handed to the adapter for activation/action events.
681    #[cfg(not(target_arch = "wasm32"))]
682    proxy: EventLoopProxy<RunnerEvent>,
683}
684
685impl<A: App> AppRunner<A> {
686    fn redraw(&mut self, event_loop: &ActiveEventLoop) {
687        self.shell.pump();
688        let Some((lw, lh, scale)) = self.shell.logical_size() else {
689            return;
690        };
691        let theme = self.app.theme();
692        self.shell.background = theme.bg;
693        self.state.tick(self.started.elapsed().as_secs_f64());
694        let view = self.app.view();
695        #[expect(clippy::cast_possible_truncation, reason = "window sizes fit in f32")]
696        let frame = build_frame(
697            &view,
698            &theme,
699            &mut self.fonts,
700            &mut self.state,
701            (lw as f32, lh as f32),
702            scale,
703        );
704        let scene = frame.paint(&mut self.fonts, &mut self.state);
705        self.shell.present(&scene);
706        // Content may have moved under a stationary pointer (scroll,
707        // layout change): refresh hover and repaint once more if it did.
708        if refresh_hover(&view, &frame, &mut self.state)
709            && let Some(w) = self.shell.window()
710        {
711            w.request_redraw();
712        }
713        if frame.animating {
714            #[cfg(not(target_arch = "wasm32"))]
715            event_loop.set_control_flow(ControlFlow::WaitUntil(
716                Instant::now() + Duration::from_millis(16),
717            ));
718            // The browser paces frames; just ask for the next one.
719            #[cfg(target_arch = "wasm32")]
720            if let Some(w) = self.shell.window() {
721                w.request_redraw();
722            }
723        } else {
724            event_loop.set_control_flow(ControlFlow::Wait);
725        }
726        self.last = Some((view, frame));
727        #[cfg(not(target_arch = "wasm32"))]
728        self.push_access_tree();
729    }
730
731    /// Pushes the current frame's accessibility projection to the platform
732    /// (no-op until assistive technology activates the tree).
733    #[cfg(not(target_arch = "wasm32"))]
734    fn push_access_tree(&mut self) {
735        let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
736        let focus = self.state.focused();
737        if let Some(adapter) = &mut self.adapter
738            && let Some((_, frame)) = &self.last
739        {
740            adapter.update_if_active(|| crate::access::tree_update(frame, focus, scale));
741        }
742    }
743
744    fn input(&mut self, event: InputEvent) {
745        let Some((view, frame)) = &self.last else {
746            return;
747        };
748        let result = dispatch(view, frame, &mut self.state, &mut self.fonts, event);
749        if let Some(cursor) = result.cursor
750            && let Some(w) = self.shell.window()
751        {
752            w.set_cursor(winit::window::Cursor::Icon(map_cursor(cursor)));
753        }
754        let had_msgs = !result.msgs.is_empty();
755        for msg in result.msgs {
756            self.app.update(msg);
757        }
758        if (result.redraw || had_msgs)
759            && let Some(w) = self.shell.window()
760        {
761            w.request_redraw();
762        }
763    }
764}
765
766fn map_cursor(cursor: fenestra_core::Cursor) -> winit::window::CursorIcon {
767    match cursor {
768        fenestra_core::Cursor::Default => winit::window::CursorIcon::Default,
769        fenestra_core::Cursor::Pointer => winit::window::CursorIcon::Pointer,
770        fenestra_core::Cursor::Text => winit::window::CursorIcon::Text,
771        fenestra_core::Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
772    }
773}
774
775/// Translates a winit key event into a fenestra [`InputEvent`].
776fn map_key(
777    event: &winit::event::KeyEvent,
778    mods: winit::keyboard::ModifiersState,
779) -> Option<InputEvent> {
780    use winit::keyboard::{Key as WKey, NamedKey};
781    let key = match &event.logical_key {
782        WKey::Named(NamedKey::Tab) => {
783            return Some(if mods.shift_key() {
784                InputEvent::ShiftTab
785            } else {
786                InputEvent::Tab
787            });
788        }
789        WKey::Named(named) => match named {
790            NamedKey::Enter => Key::Enter,
791            NamedKey::Space => Key::Space,
792            NamedKey::Escape => Key::Escape,
793            NamedKey::ArrowLeft => Key::ArrowLeft,
794            NamedKey::ArrowRight => Key::ArrowRight,
795            NamedKey::ArrowUp => Key::ArrowUp,
796            NamedKey::ArrowDown => Key::ArrowDown,
797            NamedKey::Home => Key::Home,
798            NamedKey::End => Key::End,
799            NamedKey::Backspace => Key::Backspace,
800            NamedKey::Delete => Key::Delete,
801            _ => return None,
802        },
803        WKey::Character(s) => Key::Char(s.chars().next()?),
804        _ => return None,
805    };
806    Some(InputEvent::Key(KeyInput {
807        key,
808        shift: mods.shift_key(),
809        ctrl: mods.control_key(),
810        alt: mods.alt_key(),
811        meta: mods.super_key(),
812    }))
813}
814
815impl<A: App> ApplicationHandler<RunnerEvent> for AppRunner<A> {
816    #[cfg(not(target_arch = "wasm32"))]
817    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
818        let adapter = &mut self.adapter;
819        let proxy = self.proxy.clone();
820        self.shell.resumed_with(event_loop, |el, window| {
821            // The adapter must attach while the window is still hidden.
822            if adapter.is_none() {
823                *adapter = Some(accesskit_winit::Adapter::with_event_loop_proxy(
824                    el, window, proxy,
825                ));
826            }
827        });
828        if let Some(w) = self.shell.window() {
829            w.set_ime_allowed(true);
830        }
831    }
832
833    #[cfg(target_arch = "wasm32")]
834    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
835        self.shell.resumed(event_loop);
836        if let Some(w) = self.shell.window() {
837            w.set_ime_allowed(true);
838        }
839    }
840
841    fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: RunnerEvent) {
842        match event {
843            RunnerEvent::App(msg) => {
844                if let Ok(msg) = msg.downcast::<A::Msg>() {
845                    self.app.update(*msg);
846                    if let Some(w) = self.shell.window() {
847                        w.request_redraw();
848                    }
849                }
850            }
851            #[cfg(not(target_arch = "wasm32"))]
852            RunnerEvent::Access(ev) => match ev.window_event {
853                accesskit_winit::WindowEvent::InitialTreeRequested => {
854                    if self.last.is_some() {
855                        self.push_access_tree();
856                    } else if let Some(w) = self.shell.window() {
857                        w.request_redraw();
858                    }
859                }
860                accesskit_winit::WindowEvent::ActionRequested(req) => {
861                    let id = fenestra_core::WidgetId(req.target_node.0);
862                    match req.action {
863                        accesskit::Action::Click => {
864                            if let Some((view, frame)) = &self.last
865                                && let Some(msg) =
866                                    fenestra_core::click_msg_of(view, frame, &self.state, id)
867                            {
868                                self.app.update(msg);
869                                if let Some(w) = self.shell.window() {
870                                    w.request_redraw();
871                                }
872                            }
873                        }
874                        accesskit::Action::Focus => {
875                            self.state.set_focus(Some(id));
876                            if let Some(w) = self.shell.window() {
877                                w.request_redraw();
878                            }
879                        }
880                        _ => {}
881                    }
882                }
883                accesskit_winit::WindowEvent::AccessibilityDeactivated => {}
884            },
885        }
886    }
887
888    fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
889        self.shell.suspended();
890    }
891
892    fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
893        if matches!(cause, StartCause::ResumeTimeReached { .. })
894            && let Some(w) = self.shell.window()
895        {
896            w.request_redraw();
897        }
898    }
899
900    fn window_event(
901        &mut self,
902        event_loop: &ActiveEventLoop,
903        window_id: WindowId,
904        event: WindowEvent,
905    ) {
906        if self.shell.window().is_none_or(|w| w.id() != window_id) {
907            return;
908        }
909        #[cfg(not(target_arch = "wasm32"))]
910        if let Some(adapter) = &mut self.adapter
911            && let Some(window) = self.shell.window()
912        {
913            adapter.process_event(window, &event);
914        }
915        match event {
916            WindowEvent::CloseRequested => event_loop.exit(),
917            WindowEvent::Resized(size) => self.shell.resized(size.width, size.height),
918            WindowEvent::ScaleFactorChanged { .. } => {
919                if let Some(w) = self.shell.window() {
920                    w.request_redraw();
921                }
922            }
923            WindowEvent::ModifiersChanged(mods) => self.modifiers = mods.state(),
924            WindowEvent::Occluded(occluded) => {
925                if !occluded && let Some(w) = self.shell.window() {
926                    w.request_redraw();
927                }
928            }
929            WindowEvent::CursorLeft { .. } => self.input(InputEvent::PointerLeave),
930            WindowEvent::CursorMoved { position, .. } => {
931                let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
932                self.cursor = Point::new(position.x / scale, position.y / scale);
933                #[expect(clippy::cast_possible_truncation, reason = "positions fit in f32")]
934                self.input(InputEvent::PointerMove {
935                    x: self.cursor.x as f32,
936                    y: self.cursor.y as f32,
937                });
938            }
939            WindowEvent::MouseInput {
940                state,
941                button: winit::event::MouseButton::Left,
942                ..
943            } => {
944                self.input(match state {
945                    winit::event::ElementState::Pressed => InputEvent::PointerDown,
946                    winit::event::ElementState::Released => InputEvent::PointerUp,
947                });
948            }
949            WindowEvent::MouseWheel { delta, .. } => {
950                let dy = match delta {
951                    MouseScrollDelta::LineDelta(_, y) => f64::from(y) * LINE_SCROLL_PX,
952                    MouseScrollDelta::PixelDelta(pos) => {
953                        let scale = self.shell.window().map_or(1.0, |w| w.scale_factor());
954                        pos.y / scale
955                    }
956                };
957                #[expect(clippy::cast_possible_truncation, reason = "deltas fit in f32")]
958                self.input(InputEvent::Wheel { dy: dy as f32 });
959            }
960            WindowEvent::KeyboardInput { event, .. }
961                if event.state == winit::event::ElementState::Pressed =>
962            {
963                {
964                    let mods = self.modifiers;
965                    // Printable input arrives as Text (it may be multi-char);
966                    // named keys and shortcuts go through Key.
967                    let printable = !mods.control_key()
968                        && !mods.super_key()
969                        && event
970                            .text
971                            .as_ref()
972                            .is_some_and(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()));
973                    if printable {
974                        if let Some(t) = &event.text {
975                            self.input(InputEvent::Text(t.to_string()));
976                        }
977                    } else if let Some(input) = map_key(&event, mods) {
978                        self.input(input);
979                    }
980                }
981            }
982            WindowEvent::Ime(ime) => match ime {
983                winit::event::Ime::Preedit(text, cursor) => {
984                    self.input(InputEvent::ImePreedit { text, cursor });
985                }
986                winit::event::Ime::Commit(text) => {
987                    self.input(InputEvent::Text(text));
988                }
989                winit::event::Ime::Enabled | winit::event::Ime::Disabled => {}
990            },
991            WindowEvent::RedrawRequested => self.redraw(event_loop),
992            _ => {}
993        }
994    }
995}