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