Skip to main content

telar_platform_desktop/
platform.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use platform_core::{
5    Event, EventHandler, FullscreenMode, MultiSurfacePlatform, Platform, PlatformError, SurfaceId,
6    Window, WindowConfig, WindowPosition,
7};
8use winit::application::ApplicationHandler;
9use winit::event::{StartCause, WindowEvent};
10use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
11use winit::window::{Fullscreen, WindowAttributes, WindowId, WindowLevel};
12
13use platform_winit::{SurfaceIntent, WinitWindow, map_window_event};
14
15// Winit user-event payloads injected from background threads (via EventLoopProxy) to wake the loop.
16enum UserEvent {
17    // A screen reader asked for the tree, asked to act on a node, or went away. Routed through the loop
18    // rather than answered where it arrives: AccessKit calls its handlers on a platform thread, and the UI
19    // that has to answer is `!Send` — it lives on this one.
20    Accessibility(accesskit_winit::Event),
21    // The OS color-scheme flipped; carries the new dark (`true`) / light preference. Linux only: it is the
22    // portal watch that sends it, where winit reports nothing. Elsewhere winit delivers `ThemeChanged` itself.
23    #[cfg(target_os = "linux")]
24    ColorScheme(bool),
25    // A background thread asked to wake the UI (via the app redraw waker); redraw every live surface so each
26    // one's `on_frame` runs and drains its channels — wherever the waking app's content currently lives.
27    Wake,
28}
29
30impl From<accesskit_winit::Event> for UserEvent {
31    fn from(event: accesskit_winit::Event) -> Self {
32        UserEvent::Accessibility(event)
33    }
34}
35
36pub struct WinitPlatform {
37    event_loop: EventLoop<UserEvent>,
38}
39
40impl WinitPlatform {
41    pub fn try_new() -> Result<Self, PlatformError> {
42        Ok(Self {
43            event_loop: EventLoop::<UserEvent>::with_user_event()
44                .build()
45                .map_err(|e| PlatformError(e.to_string()))?,
46        })
47    }
48}
49
50struct WinitRunner<H: EventHandler<WinitWindow>> {
51    handler: H,
52    window: Option<WinitWindow>,
53    config: WindowConfig,
54    cursor_position: (f64, f64),
55    scale_factor: f64,
56    modifiers: platform_core::ModifiersState,
57    // True only on WaitUntil timer expiry; gates keepalive request_redraw() so it doesn't fire on every event queue drain.
58    timer_has_fired: bool,
59    // Built at resume, before the window is shown, which the adapter requires. The tree behind it is only ever
60    // assembled while an assistive technology is attached — which `update_if_active` is the whole point of.
61    a11y: Option<accesskit_winit::Adapter>,
62    a11y_proxy: winit::event_loop::EventLoopProxy<UserEvent>,
63    // The nodes last published, so a request naming one can be mapped back to the control it means.
64    a11y_nodes: Vec<platform_core::AccessNode>,
65}
66
67impl<H: EventHandler<WinitWindow>> WinitRunner<H> {
68    /// Answers a screen reader, on the thread the UI actually lives on.
69    fn on_accessibility(&mut self, event: accesskit_winit::WindowEvent) {
70        use accesskit_winit::WindowEvent as AkEvent;
71        match event {
72            AkEvent::InitialTreeRequested => self.publish_accessibility(),
73            AkEvent::ActionRequested(request) => {
74                let Some((id, activate)) =
75                    crate::accessibility::requested_focus_id(&request, &self.a11y_nodes)
76                else {
77                    return;
78                };
79                // Through the same door the keyboard uses. A reader activating a button takes the path a press
80                // takes, or the two grow apart and it is always the untested one that rots.
81                self.handler.on_accessibility_action(id, activate);
82                self.publish_accessibility();
83            }
84            // Nothing to tear down: the tree is only ever built on demand, so ceasing to be asked is the whole
85            // of stopping.
86            AkEvent::AccessibilityDeactivated => self.a11y_nodes.clear(),
87        }
88    }
89
90    /// Hands the current tree over, and only if something is listening — which is what keeps the cost of all
91    /// this at nothing for the overwhelmingly common case of nobody being.
92    fn publish_accessibility(&mut self) {
93        let Some(adapter) = &mut self.a11y else {
94            return;
95        };
96        let nodes = self.handler.accessibility();
97        let title = self.config.title.clone();
98        adapter.update_if_active(|| crate::accessibility::tree_update(&nodes, &title));
99        self.a11y_nodes = nodes;
100    }
101}
102
103impl<H: EventHandler<WinitWindow>> ApplicationHandler<UserEvent> for WinitRunner<H> {
104    fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UserEvent) {
105        match event {
106            UserEvent::Accessibility(event) => self.on_accessibility(event.window_event),
107            #[cfg(target_os = "linux")]
108            UserEvent::ColorScheme(dark) => {
109                if let Some(window) = &self.window {
110                    self.handler
111                        .on_event(Event::ColorSchemeChanged { dark }, window);
112                }
113            }
114            UserEvent::Wake => {
115                if let Some(window) = &self.window {
116                    window.request_redraw();
117                }
118            }
119        }
120    }
121
122    fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
123        self.timer_has_fired = matches!(cause, StartCause::ResumeTimeReached { .. });
124        self.handler.new_events();
125    }
126
127    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
128        if let Some(d) = self.handler.about_to_wait() {
129            // Only request_redraw() on timer expiry, not every drain; reactive changes call it themselves via flush_notify.
130            if self.timer_has_fired {
131                if let Some(window) = &self.window {
132                    window.request_redraw();
133                }
134            }
135            event_loop.set_control_flow(ControlFlow::WaitUntil(std::time::Instant::now() + d));
136        } else {
137            event_loop.set_control_flow(ControlFlow::Wait);
138        }
139    }
140
141    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
142        let Some(window) = create_window_from_config(event_loop, &self.config) else {
143            return;
144        };
145        // Deliver the OS light/dark preference before the tree mounts, so its first layout uses the
146        // right theme. winit reports it on Windows/macOS; on Linux fall back to the freedesktop portal.
147        if let Some(dark) = initial_prefers_dark(&window) {
148            self.handler
149                .on_event(Event::ColorSchemeChanged { dark }, &window);
150        }
151        if !self.handler.on_resume(&window) {
152            event_loop.exit();
153            return;
154        }
155        // Attached before the window is ever visible, which the adapter requires — hence the window being
156        // created hidden. The proxy is what routes a reader's requests back onto this thread.
157        self.a11y = Some(accesskit_winit::Adapter::with_event_loop_proxy(
158            event_loop,
159            &window.0,
160            self.a11y_proxy.clone(),
161        ));
162        window.0.set_visible(true);
163        window.request_redraw();
164        self.window = Some(window);
165    }
166
167    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
168        // Clone (a cheap Arc bump) so the immutable window borrow doesn't conflict with the mutable
169        // self.handler/cursor/scale/modifiers borrows the shared dispatcher takes.
170        let Some(window) = self.window.clone() else {
171            return;
172        };
173        // The adapter tracks the window itself — focus, size, scale — so it sees the event before anything
174        // consumes it.
175        if let Some(adapter) = &mut self.a11y {
176            adapter.process_event(&window.0, &event);
177        }
178        let redrawn = matches!(event, WindowEvent::RedrawRequested);
179        let outcome = dispatch_window_event(
180            &mut self.handler,
181            &window,
182            &mut self.cursor_position,
183            &mut self.scale_factor,
184            &mut self.modifiers,
185            event,
186        );
187        // After the frame rather than before: what is announced is then the frame that was drawn.
188        if redrawn {
189            self.publish_accessibility();
190        }
191        // A custom title-bar close button sets the handler's exit request during dispatch; honor it alongside
192        // the OS close (window manager X / Alt-F4).
193        if matches!(outcome, WindowEventOutcome::CloseRequested) || self.handler.take_exit_request()
194        {
195            event_loop.exit();
196        }
197    }
198}
199
200enum WindowEventOutcome {
201    Continue,
202    CloseRequested,
203}
204
205// Builds a winit window from a `WindowConfig`. Shared by the single- and multi-surface runners.
206fn create_window_from_config(
207    event_loop: &ActiveEventLoop,
208    config: &WindowConfig,
209) -> Option<WinitWindow> {
210    let mut attributes = WindowAttributes::default()
211        // Shown once the accessibility adapter is attached, which has to happen before the window is ever
212        // visible. Creating it hidden and revealing it a moment later is what that costs.
213        .with_visible(false)
214        .with_title(config.title.as_str())
215        .with_inner_size(winit::dpi::LogicalSize::new(config.width, config.height))
216        .with_resizable(config.is_resizable)
217        .with_decorations(config.has_decorations)
218        .with_transparent(config.is_transparent);
219
220    if let Some((w, h)) = config.min_size {
221        attributes = attributes.with_min_inner_size(winit::dpi::LogicalSize::new(w, h));
222    }
223    if let Some((w, h)) = config.max_size {
224        attributes = attributes.with_max_inner_size(winit::dpi::LogicalSize::new(w, h));
225    }
226    match config.fullscreen {
227        FullscreenMode::Disabled => {}
228        // Exclusive requires a concrete video mode; fall back to borderless for now.
229        FullscreenMode::Borderless | FullscreenMode::Exclusive => {
230            attributes = attributes.with_fullscreen(Some(Fullscreen::Borderless(None)));
231        }
232    }
233    if let WindowPosition::At(x, y) = config.position {
234        attributes = attributes.with_position(winit::dpi::PhysicalPosition::new(x, y));
235    }
236    if config.is_always_on_top {
237        attributes = attributes.with_window_level(WindowLevel::AlwaysOnTop);
238    }
239
240    match event_loop.create_window(attributes) {
241        Ok(w) => Some(WinitWindow(std::sync::Arc::new(w))),
242        Err(e) => {
243            tracing::error!(error = %e, "failed to create window");
244            None
245        }
246    }
247}
248
249// Applies one winit `WindowEvent` to a single surface's [`EventHandler`] on the same thread. Returns whether
250// the surface requested close.
251fn dispatch_window_event<H: EventHandler<WinitWindow>>(
252    handler: &mut H,
253    window: &WinitWindow,
254    cursor_position: &mut (f64, f64),
255    scale_factor: &mut f64,
256    modifiers: &mut platform_core::ModifiersState,
257    event: WindowEvent,
258) -> WindowEventOutcome {
259    match map_window_event(event, cursor_position, scale_factor, modifiers) {
260        SurfaceIntent::Event(e) => handler.on_event(e, window),
261        SurfaceIntent::Resized(e) => {
262            handler.on_event(e, window);
263            window.request_redraw();
264        }
265        SurfaceIntent::Redraw => handler.on_redraw(window),
266        SurfaceIntent::Close(e) => {
267            handler.on_event(e, window);
268            return WindowEventOutcome::CloseRequested;
269        }
270        SurfaceIntent::Ignore => {}
271    }
272    WindowEventOutcome::Continue
273}
274
275impl Platform for WinitPlatform {
276    type Window = WinitWindow;
277
278    fn run<H: EventHandler<Self::Window>>(
279        self,
280        config: WindowConfig,
281        handler: H,
282    ) -> Result<(), PlatformError> {
283        let mut runner = WinitRunner {
284            handler,
285            window: None,
286            config,
287            cursor_position: (0.0, 0.0),
288            scale_factor: 1.0,
289            modifiers: platform_core::ModifiersState::default(),
290            timer_has_fired: false,
291            a11y: None,
292            a11y_proxy: self.event_loop.create_proxy(),
293            a11y_nodes: Vec::new(),
294        };
295        // The app-facing redraw waker (handed to background threads) wakes the loop through this proxy, not by
296        // holding a window — so caching it can't pin a window open.
297        let wake_proxy = self.event_loop.create_proxy();
298        platform_core::set_loop_waker(std::sync::Arc::new(move || {
299            let _ = wake_proxy.send_event(UserEvent::Wake);
300        }));
301        // Live OS color-scheme changes: winit has no Linux integration, so a portal watch thread pushes them
302        // back through the loop via a proxy. Elsewhere winit delivers WindowEvent::ThemeChanged natively.
303        #[cfg(target_os = "linux")]
304        {
305            let proxy = self.event_loop.create_proxy();
306            crate::color_scheme::spawn_watch(move |dark| {
307                let _ = proxy.send_event(UserEvent::ColorScheme(dark));
308            });
309        }
310        self.event_loop
311            .run_app(&mut runner)
312            .map_err(|e| PlatformError(e.to_string()))
313    }
314}
315
316// The initial OS light/dark preference at window creation: winit's native answer (Windows/macOS), falling
317// back to the freedesktop portal on Linux where winit always reports `None`.
318fn initial_prefers_dark(window: &WinitWindow) -> Option<bool> {
319    let winit = window.prefers_dark();
320    #[cfg(target_os = "linux")]
321    {
322        winit.or_else(crate::color_scheme::portal_prefers_dark)
323    }
324    #[cfg(not(target_os = "linux"))]
325    {
326        winit
327    }
328}
329
330// ---- Multi-surface (multi-window) backend --------------------------------------------------------------
331//
332// M3: every surface shares this one UI thread and one reactive runtime. winit already creates windows and
333// pumps their events on the main thread; each surface's `EventHandler` — built here by the factory, carrying
334// its own `Surface` world — runs directly on the main thread too. The handler activates its surface around
335// every lifecycle call, so the surfaces stay isolated without a thread apiece, and a signal shared between
336// them re-runs each surface's effects under its own context. The hardware backend still presents on its own
337// per-surface render thread (as in single-window).
338//
339// Each dispatch is bracketed by the handler's own `new_events`/`about_to_wait` (begin/end of the reactive
340// batch), so batch_depth always returns to 0 within one callback — no cross-callback bookkeeping, and a
341// surface created in `resumed` (after the iteration's `new_events`) can never leave the batch unbalanced.
342
343// A dynamically-opened surface (`open_surface`) awaiting creation by the running runner. Enqueued from app
344// code deep inside an event handler — where `&ActiveEventLoop` (needed to create a winit window) is not
345// available — and drained by the runner on its next `about_to_wait`.
346struct DynamicRequest {
347    config: WindowConfig,
348    handler: Box<dyn EventHandler<WinitWindow>>,
349    close: Arc<std::sync::atomic::AtomicBool>,
350}
351
352thread_local! {
353    static DYNAMIC_QUEUE: std::cell::RefCell<Vec<DynamicRequest>> =
354        const { std::cell::RefCell::new(Vec::new()) };
355}
356
357/// Requests a new top-level window rendering `handler`, created on the next event-loop iteration by the
358/// running multi-surface runner (which shares this thread and the one reactive runtime). Returns a flag the
359/// caller flips to close the surface. rsx's winit `SurfaceHost` uses this to implement `open_surface` without
360/// a per-surface thread. If no multi-surface runner is running, the request simply sits undrained.
361pub fn request_dynamic_surface(
362    config: WindowConfig,
363    handler: Box<dyn EventHandler<WinitWindow>>,
364) -> Arc<std::sync::atomic::AtomicBool> {
365    let close = Arc::new(std::sync::atomic::AtomicBool::new(false));
366    DYNAMIC_QUEUE.with(|q| {
367        q.borrow_mut().push(DynamicRequest {
368            config,
369            handler,
370            close: Arc::clone(&close),
371        })
372    });
373    close
374}
375
376fn drain_dynamic_requests() -> Vec<DynamicRequest> {
377    DYNAMIC_QUEUE.with(|q| std::mem::take(&mut *q.borrow_mut()))
378}
379
380// Per-surface main-thread state: the handler plus that surface's input state (needed to translate winit
381// events), its last frame-pacing deadline, and — for a dynamically-opened surface — the flag its
382// `SurfaceControl` flips to request close.
383struct SurfaceRunner {
384    handler: Box<dyn EventHandler<WinitWindow>>,
385    window: WinitWindow,
386    cursor_position: (f64, f64),
387    scale_factor: f64,
388    modifiers: platform_core::ModifiersState,
389    pace: Option<std::time::Duration>,
390    // `None` for a statically-declared surface; `Some` for one opened via `open_surface`.
391    close_flag: Option<Arc<std::sync::atomic::AtomicBool>>,
392    // A dynamically-opened window defers on_resume until its first event, when the compositor has given it
393    // its real size (a tiling WM may override the requested size); rendering before that would size the
394    // surface and the layout differently. `false` until resumed.
395    resumed: bool,
396    // As in the single-window runner: built before the window is shown, and the tree behind it assembled only while something is listening.
397    a11y: Option<accesskit_winit::Adapter>,
398    a11y_nodes: Vec<platform_core::AccessNode>,
399    title: String,
400}
401
402impl SurfaceRunner {
403    /// Hands this surface's tree over, if something is listening. The multi-surface counterpart of
404    /// [`WinitRunner::publish_accessibility`] — a detached tab is a window like any other, and a reader that
405    /// can see the first one and not the second is reporting the shell's plumbing rather than the app.
406    fn publish_accessibility(&mut self) {
407        let Some(adapter) = &mut self.a11y else {
408            return;
409        };
410        let nodes = self.handler.accessibility();
411        let title = self.title.clone();
412        adapter.update_if_active(|| crate::accessibility::tree_update(&nodes, &title));
413        self.a11y_nodes = nodes;
414    }
415}
416
417// Brings a surface up: build under a panic guard (T-4.2), so a build that fails/panics returns `false` and
418// the caller drops it without disturbing the others. Reads the window's *current* size, so calling it once
419// the compositor has configured the window keeps the layout and the render surface the same size.
420fn resume_surface(surface: &mut SurfaceRunner) -> bool {
421    let window = surface.window.clone();
422    surface.handler.new_events();
423    let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
424        if let Some(dark) = initial_prefers_dark(&window) {
425            surface
426                .handler
427                .on_event(Event::ColorSchemeChanged { dark }, &window);
428        }
429        surface.handler.on_resume(&window)
430    }));
431    surface.pace = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
432        surface.handler.about_to_wait()
433    }))
434    .unwrap_or(None);
435    matches!(built, Ok(true))
436}
437
438// The runner is non-generic over the handler type: both the statically-declared surfaces (boxed from the
439// factory) and the dynamically-opened ones share one `Box<dyn EventHandler<WinitWindow>>` map.
440type BoxedFactory = Box<dyn Fn(SurfaceId) -> Box<dyn EventHandler<WinitWindow>>>;
441
442struct WinitMultiRunner {
443    factory: BoxedFactory,
444    pending: Vec<(SurfaceId, WindowConfig)>,
445    surfaces: HashMap<WindowId, SurfaceRunner>,
446    created: bool,
447    a11y_proxy: winit::event_loop::EventLoopProxy<UserEvent>,
448    // True only on WaitUntil timer expiry; gates keepalive request_redraw so it fires only on timer ticks.
449    timer_has_fired: bool,
450}
451
452impl WinitMultiRunner {
453    // Creates a window for `handler` and inserts it into the live surface map. `resume_now` brings it up
454    // immediately (the initial surfaces, created in `resumed`, whose window winit has already configured);
455    // a dynamically-opened surface passes `false` and is resumed on its first event instead (see
456    // `window_event`), once the compositor has given it its real size.
457    fn spawn_surface(
458        &mut self,
459        event_loop: &ActiveEventLoop,
460        config: WindowConfig,
461        handler: Box<dyn EventHandler<WinitWindow>>,
462        close_flag: Option<Arc<std::sync::atomic::AtomicBool>>,
463        resume_now: bool,
464    ) {
465        // Creating the window creates its `wl_surface`; hold the GPU lifecycle lock so it can't race another
466        // window's render thread present/acquire on the shared Wayland/driver connection. Scoped tightly so
467        // `resume_surface` below (which builds the renderer under its own lifecycle lock) isn't nested under it.
468        let Some(window) = ({
469            let _gpu = renderer_core::gpu_sync::lifecycle_guard();
470            create_window_from_config(event_loop, &config)
471        }) else {
472            return;
473        };
474        let window_id = window.0.id();
475        // Attached here, while the window is still invisible, because that is what the adapter requires.
476        let a11y = accesskit_winit::Adapter::with_event_loop_proxy(
477            event_loop,
478            &window.0,
479            self.a11y_proxy.clone(),
480        );
481        let mut surface = SurfaceRunner {
482            handler,
483            window,
484            cursor_position: (0.0, 0.0),
485            scale_factor: 1.0,
486            modifiers: platform_core::ModifiersState::default(),
487            pace: None,
488            close_flag,
489            resumed: false,
490            a11y: Some(a11y),
491            a11y_nodes: Vec::new(),
492            title: config.title.clone(),
493        };
494        if resume_now {
495            if !resume_surface(&mut surface) {
496                tracing::error!("surface on_resume failed or panicked; skipping it");
497                return;
498            }
499            surface.window.request_redraw();
500            surface.resumed = true;
501        }
502        self.surfaces.insert(window_id, surface);
503    }
504}
505
506impl ApplicationHandler<UserEvent> for WinitMultiRunner {
507    fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UserEvent) {
508        match event {
509            // Routed by window: every surface carries its own adapter, so a request names the one it came from.
510            UserEvent::Accessibility(event) => {
511                use accesskit_winit::WindowEvent as AkEvent;
512                let Some(surface) = self.surfaces.get_mut(&event.window_id) else {
513                    return;
514                };
515                match event.window_event {
516                    AkEvent::InitialTreeRequested => surface.publish_accessibility(),
517                    AkEvent::ActionRequested(request) => {
518                        let Some((id, activate)) =
519                            crate::accessibility::requested_focus_id(&request, &surface.a11y_nodes)
520                        else {
521                            return;
522                        };
523                        surface.handler.on_accessibility_action(id, activate);
524                        surface.publish_accessibility();
525                    }
526                    AkEvent::AccessibilityDeactivated => surface.a11y_nodes.clear(),
527                }
528            }
529            #[cfg(target_os = "linux")]
530            UserEvent::ColorScheme(dark) => {
531                // Bracket each surface's write on its own (this callback is not inside a shared batch bracket).
532                for surface in self.surfaces.values_mut() {
533                    surface.handler.new_events();
534                    surface
535                        .handler
536                        .on_event(Event::ColorSchemeChanged { dark }, &surface.window);
537                    surface.pace = surface.handler.about_to_wait();
538                }
539            }
540            UserEvent::Wake => {
541                // Redraw every surface so each one's `on_frame` runs — the waking app's content may now live in
542                // any of them (a tabbed host can move it between windows), so we don't assume which.
543                for surface in self.surfaces.values() {
544                    surface.window.request_redraw();
545                }
546            }
547        }
548    }
549
550    fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
551        // Only gate keepalive redraws on a real timer expiry (not every event-queue drain).
552        self.timer_has_fired = matches!(cause, StartCause::ResumeTimeReached { .. });
553    }
554
555    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
556        // Bring up any surfaces opened via `open_surface` since the last iteration, and tear down any whose
557        // SurfaceControl flag was flipped — both are cheap and immediate on this one thread (no polling).
558        for req in drain_dynamic_requests() {
559            self.spawn_surface(event_loop, req.config, req.handler, Some(req.close), false);
560        }
561        let to_close: Vec<WindowId> = self
562            .surfaces
563            .iter()
564            .filter(|(_, s)| {
565                s.close_flag
566                    .as_ref()
567                    .is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed))
568            })
569            .map(|(&id, _)| id)
570            .collect();
571        for id in to_close {
572            if let Some(mut removed) = self.surfaces.remove(&id) {
573                removed.handler.on_suspend();
574                // Renderer + window teardown, serialized against sibling render threads (see the close path in
575                // window_event and renderer_core::gpu_sync).
576                let _gpu = renderer_core::gpu_sync::lifecycle_guard();
577                drop(removed);
578            }
579        }
580        if self.created && self.surfaces.is_empty() {
581            event_loop.exit();
582            return;
583        }
584
585        // Aggregate the soonest frame-pacing deadline across surfaces; wake each animating surface for its own
586        // frame on a timer tick (reactive changes call request_redraw themselves via flush_notify).
587        let mut next_wake: Option<std::time::Duration> = None;
588        for surface in self.surfaces.values() {
589            if let Some(d) = surface.pace {
590                if self.timer_has_fired {
591                    surface.window.request_redraw();
592                }
593                next_wake = Some(next_wake.map_or(d, |cur| cur.min(d)));
594            }
595        }
596        match next_wake {
597            Some(d) => {
598                event_loop.set_control_flow(ControlFlow::WaitUntil(std::time::Instant::now() + d))
599            }
600            None => event_loop.set_control_flow(ControlFlow::Wait),
601        }
602    }
603
604    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
605        // Create every static surface once. winit can emit `resumed` more than once on some platforms; the
606        // guard keeps us from spawning duplicate windows.
607        if self.created {
608            return;
609        }
610        self.created = true;
611        for (id, config) in std::mem::take(&mut self.pending) {
612            // The factory gives each handler its own `Surface` world, activated around each lifecycle call.
613            let handler = (self.factory)(id);
614            self.spawn_surface(event_loop, config, handler, None, true);
615        }
616        if self.surfaces.is_empty() {
617            event_loop.exit();
618        }
619    }
620
621    fn window_event(&mut self, _event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
622        let Some(surface) = self.surfaces.get_mut(&id) else {
623            return;
624        };
625        if !surface.resumed {
626            // Bring a dynamically-opened window up only on its first non-empty `Resized` — the compositor's
627            // configure, when the window has its real size (a tiling WM overrides the requested one). Ignore
628            // earlier events (e.g. a pre-configure `RedrawRequested`), which would resume at the requested
629            // size and overflow the smaller surface.
630            let configured =
631                matches!(&event, WindowEvent::Resized(s) if s.width > 0 && s.height > 0);
632            if !configured {
633                return;
634            }
635            if resume_surface(surface) {
636                surface.resumed = true;
637                // Fall through to dispatch this Resized: it carries the authoritative size, which relays the
638                // layout out to match the surface even if `window.inner_size()` (read in on_resume) lagged it.
639            } else {
640                // Exiting the whole loop is `about_to_wait`'s job (it runs after pending `open_surface`
641                // requests are spawned), so a just-removed last surface can't kill a window being born.
642                if let Some(removed) = self.surfaces.remove(&id) {
643                    let _gpu = renderer_core::gpu_sync::lifecycle_guard();
644                    drop(removed);
645                }
646                return;
647            }
648        }
649        // Clone (a cheap Arc bump) so the window borrow doesn't conflict with the mutable handler/input borrows.
650        let window = surface.window.clone();
651        let redrawn = matches!(event, WindowEvent::RedrawRequested);
652        // The adapter tracks the window itself — focus, size, scale — so it sees the event first.
653        if let Some(adapter) = &mut surface.a11y {
654            adapter.process_event(&window.0, &event);
655        }
656        surface.handler.new_events();
657        // Dispatch under a panic guard (T-4.2): a widget handler / render / effect panic unmounts just this
658        // surface. about_to_wait (end_batch) is guarded separately so it always runs, keeping the reactive
659        // batch balanced (T-1.3 leaves the shared runtime consistent after the unwind). Under panic=unwind
660        // only; a panic=abort release build aborts instead.
661        let dispatched = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
662            dispatch_window_event(
663                &mut surface.handler,
664                &window,
665                &mut surface.cursor_position,
666                &mut surface.scale_factor,
667                &mut surface.modifiers,
668                event,
669            )
670        }));
671        let paced = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
672            surface.handler.about_to_wait()
673        }));
674        surface.pace = paced.as_ref().copied().unwrap_or(None);
675        let panicked = dispatched.is_err() || paced.is_err();
676        // After the frame rather than before, as in the single-window runner: what is announced is the frame that was drawn.
677        if redrawn && !panicked {
678            surface.publish_accessibility();
679        }
680        let close = matches!(dispatched, Ok(WindowEventOutcome::CloseRequested));
681        // A panicked handler is in an unknown state; don't poll it, just unmount.
682        let exit_requested = !panicked && surface.handler.take_exit_request();
683        if panicked {
684            tracing::error!(?id, "surface panicked; unmounting it");
685        }
686        // OS close (WM X / Alt-F4), a custom title-bar close button, or a panic: tear down just this surface.
687        if panicked || close || exit_requested {
688            if let Some(mut removed) = self.surfaces.remove(&id) {
689                // on_suspend joins THIS surface's render thread (which needs the render guard to finish its
690                // last frame), so it must run before we take the lifecycle lock — otherwise we'd deadlock.
691                if !panicked {
692                    removed.handler.on_suspend();
693                }
694                // Destroying this window's swapchain/surface and its winit window (wl_surface) must not race a
695                // sibling window's render thread; the lock waits for every in-flight frame and blocks new ones
696                // for the duration of the drop (see renderer_core::gpu_sync). The GPU device/instance is shared
697                // process-wide, so this drops only a swapchain — never a device — which is what makes it safe.
698                let _gpu = renderer_core::gpu_sync::lifecycle_guard();
699                drop(removed);
700            }
701            tracing::debug!(
702                ?id,
703                close,
704                exit_requested,
705                panicked,
706                remaining = self.surfaces.len(),
707                "surface closed"
708            );
709            // The whole-loop exit is decided in `about_to_wait`, after the same iteration's queued
710            // `open_surface`/`open_window` requests are spawned — so detaching the last tab (which closes the
711            // host and opens a new window at once) doesn't exit before the new window exists.
712        }
713    }
714}
715
716impl MultiSurfacePlatform for WinitPlatform {
717    type Window = WinitWindow;
718
719    fn run_surfaces<H, F>(
720        self,
721        surfaces: Vec<(SurfaceId, WindowConfig)>,
722        factory: F,
723    ) -> Result<(), PlatformError>
724    where
725        H: EventHandler<WinitWindow> + 'static,
726        F: Fn(SurfaceId) -> H + 'static,
727    {
728        // Box the factory output so static and dynamic surfaces share one handler type in the runner's map.
729        let factory: BoxedFactory =
730            Box::new(move |id| Box::new(factory(id)) as Box<dyn EventHandler<WinitWindow>>);
731        let mut runner = WinitMultiRunner {
732            factory,
733            pending: surfaces,
734            surfaces: HashMap::new(),
735            created: false,
736            a11y_proxy: self.event_loop.create_proxy(),
737            timer_has_fired: false,
738        };
739        // The app-facing redraw waker wakes the loop through this proxy (which redraws every surface), not by
740        // holding a window — so an app can cache it and, if its content is later moved to another window, the
741        // original still closes and background wakeups still reach it wherever it now lives.
742        let wake_proxy = self.event_loop.create_proxy();
743        platform_core::set_loop_waker(std::sync::Arc::new(move || {
744            let _ = wake_proxy.send_event(UserEvent::Wake);
745        }));
746        // Live OS color-scheme changes, delivered to every surface (see the single-window `run`).
747        #[cfg(target_os = "linux")]
748        {
749            let proxy = self.event_loop.create_proxy();
750            crate::color_scheme::spawn_watch(move |dark| {
751                let _ = proxy.send_event(UserEvent::ColorScheme(dark));
752            });
753        }
754        self.event_loop
755            .run_app(&mut runner)
756            .map_err(|e| PlatformError(e.to_string()))
757    }
758}