Skip to main content

teksilo_platform/
window.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex, OnceLock, mpsc};
6
7use winit::event::WindowEvent;
8use winit::window::Window;
9
10use accesskit::ActionRequest;
11use teksilo_render::Renderer;
12
13/// Error returned when surface texture acquisition fails during rendering.
14#[derive(Debug, thiserror::Error)]
15#[error("Surface error: {0}")]
16pub struct SurfaceRenderError(pub String);
17
18/// Outcome of [`PlatformWindow::render_frame`]. Mirrors the wgpu
19/// surface-status cases that matter to the caller so the app loop can
20/// decide how to respond (ignore, reconfigure, log) without every frame
21/// getting logged as an error.
22#[derive(Debug)]
23pub enum FrameOutcome {
24    /// Frame was rendered and presented.
25    Rendered,
26    /// wgpu reported the window as occluded or the acquire timed out.
27    /// Per wgpu guidance, skip this frame. On macOS, the initial paint
28    /// after window creation often hits `Occluded` one or more times
29    /// before Metal finishes compositing, so the caller should still
30    /// request another redraw once — unless it already knows the
31    /// window is occluded via `WindowEvent::Occluded(true)`.
32    Skipped,
33    /// Surface became outdated (resize, scale change, device switch).
34    /// Caller should reconfigure the surface and try again.
35    NeedsReconfigure,
36    /// Acquisition failed with a non-transient error.
37    Error(SurfaceRenderError),
38}
39
40/// A platform window wrapping a winit window, wgpu surface, renderer,
41/// and AccessKit adapter for screen reader support.
42pub struct PlatformWindow {
43    window: Arc<Window>,
44    surface: wgpu::Surface<'static>,
45    surface_config: wgpu::SurfaceConfiguration,
46    renderer: Renderer,
47    scale_factor: f64,
48    a11y_adapter: Option<accesskit_winit::Adapter>,
49    /// Set by the activation handler when an assistive technology asks for
50    /// the tree; cleared by the first delivery after it. Shared because the
51    /// handler may run off the main thread.
52    a11y_needs_full_tree: Arc<AtomicBool>,
53    /// Receiver for accessibility action requests from the adapter.
54    a11y_action_rx: mpsc::Receiver<ActionRequest>,
55}
56
57/// The wgpu objects every window in the process shares.
58///
59/// All three are `Arc` handles internally, so cloning one is a refcount bump,
60/// not a second GPU object.
61#[derive(Clone)]
62struct SharedGpu {
63    adapter: wgpu::Adapter,
64    device: wgpu::Device,
65    queue: wgpu::Queue,
66}
67
68/// The one wgpu instance for this process.
69///
70/// A surface has to come from the same instance that later enumerates adapters
71/// for it, so this is the root every window hangs off. `Instance::new` is
72/// synchronous, which is why this one can be a plain `OnceLock` while the
73/// adapter and device below cannot.
74fn shared_instance() -> &'static wgpu::Instance {
75    static INSTANCE: OnceLock<wgpu::Instance> = OnceLock::new();
76    INSTANCE
77        .get_or_init(|| wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()))
78}
79
80/// The adapter, device and queue every window shares.
81///
82/// One device per process, not one per window. A device is a heavyweight,
83/// process-level object and a second one buys nothing: each window still needs
84/// its own surface and its own [`Renderer`] (that is where the glyph and path
85/// atlases live), but the driver objects underneath are the same for every
86/// window on the same adapter. Opening one per window duplicated the entire
87/// pipeline set and both atlas textures for every window a user opened.
88///
89/// It also closes a latent crash. Two D3D12 **WARP** devices rasterizing at the
90/// same time fault inside `d3d10warp.dll` — Microsoft's software rasterizer,
91/// and what a GPU-less Windows host actually draws with. Teksilo renders its
92/// windows sequentially on the winit main thread, so that was not reachable
93/// here; it would have become reachable the moment any window work moved off
94/// that thread. `teksilo_render::test_support` shares its offscreen device for
95/// the same reason, where it *was* reachable and did crash.
96///
97/// `surface` is used only to pick an adapter that can actually present to it.
98/// If a later window's surface turns out to be incompatible with the adapter we
99/// cached — a genuinely multi-GPU machine, where the second window opens on the
100/// other GPU — that window quietly gets its own device rather than failing.
101async fn shared_gpu_for(surface: &wgpu::Surface<'static>) -> SharedGpu {
102    static SHARED: Mutex<Option<SharedGpu>> = Mutex::new(None);
103
104    // Clone out and release the lock: it is never held across the awaits below.
105    let cached = SHARED.lock().unwrap_or_else(|e| e.into_inner()).clone();
106    if let Some(gpu) = cached {
107        // A non-empty format list is wgpu's own answer to "can this adapter
108        // present to this surface".
109        if !surface.get_capabilities(&gpu.adapter).formats.is_empty() {
110            return gpu;
111        }
112    }
113
114    let adapter = shared_instance()
115        .request_adapter(&wgpu::RequestAdapterOptions {
116            power_preference: wgpu::PowerPreference::default(),
117            compatible_surface: Some(surface),
118            force_fallback_adapter: false,
119            ..Default::default()
120        })
121        .await
122        .expect("no compatible wgpu adapter available");
123
124    let (device, queue) = adapter
125        .request_device(&wgpu::DeviceDescriptor {
126            label: Some("teksilo_device"),
127            required_features: wgpu::Features::empty(),
128            required_limits: wgpu::Limits::default(),
129            ..Default::default()
130        })
131        .await
132        .expect("wgpu device request failed");
133
134    let gpu = SharedGpu {
135        adapter,
136        device,
137        queue,
138    };
139    // First one in becomes the shared device. Losing here is the multi-GPU case
140    // above (or a race that cannot happen while windows are created on one
141    // thread): the loser keeps the device it just opened, which is the old
142    // per-window behaviour and still correct.
143    let mut slot = SHARED.lock().unwrap_or_else(|e| e.into_inner());
144    if slot.is_none() {
145        *slot = Some(gpu.clone());
146    }
147    gpu
148}
149
150impl PlatformWindow {
151    /// Everything both constructors do: surface, shared device, swapchain
152    /// configuration, renderer. Kept in one place because the two entry points
153    /// differ only in whether they attach an AccessKit adapter, and sixty
154    /// duplicated lines of GPU setup is exactly the sort of thing that drifts.
155    async fn surface_and_renderer(
156        window: &Arc<Window>,
157    ) -> (wgpu::Surface<'static>, wgpu::SurfaceConfiguration, Renderer) {
158        let size = window.inner_size();
159        let surface = shared_instance()
160            .create_surface(window.clone())
161            .expect("wgpu surface creation failed for the platform window");
162
163        let gpu = shared_gpu_for(&surface).await;
164
165        let surface_caps = surface.get_capabilities(&gpu.adapter);
166        // Guard the index accesses: a degenerate adapter/surface (software
167        // fallback, headless) can report empty `formats` / `alpha_modes`, and
168        // `[0]` would panic with an opaque out-of-bounds instead of degrading.
169        let surface_format = surface_caps
170            .formats
171            .iter()
172            .find(|f| f.is_srgb())
173            .copied()
174            .or_else(|| surface_caps.formats.first().copied())
175            .unwrap_or(wgpu::TextureFormat::Rgba8UnormSrgb);
176
177        let surface_config = wgpu::SurfaceConfiguration {
178            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
179            format: surface_format,
180            width: size.width.max(1),
181            height: size.height.max(1),
182            present_mode: wgpu::PresentMode::Fifo,
183            alpha_mode: surface_caps
184                .alpha_modes
185                .first()
186                .copied()
187                .unwrap_or(wgpu::CompositeAlphaMode::Auto),
188            view_formats: vec![],
189            desired_maximum_frame_latency: 2,
190            // `Auto` reproduces wgpu's pre-30 behaviour: sRGB for the
191            // non-`Rgba16Float` formats we select above.
192            color_space: wgpu::SurfaceColorSpace::Auto,
193        };
194        surface.configure(&gpu.device, &surface_config);
195
196        // The renderer stays per-window: it owns the glyph atlas, the path
197        // atlas and the blur pool, and it is `!Sync` besides.
198        let renderer = Renderer::new(gpu.device, gpu.queue, surface_format);
199        (surface, surface_config, renderer)
200    }
201
202    /// Create a new platform window from a winit window.
203    /// The `event_loop` parameter is needed for the AccessKit adapter.
204    pub async fn new_with_a11y(
205        window: Window,
206        event_loop: &winit::event_loop::ActiveEventLoop,
207    ) -> Self {
208        let window = Arc::new(window);
209        let scale_factor = window.scale_factor();
210        let (surface, surface_config, renderer) = Self::surface_and_renderer(&window).await;
211
212        // Create AccessKit adapter with action channel
213        let (action_tx, action_rx) = mpsc::channel();
214
215        let a11y_needs_full_tree = Arc::new(AtomicBool::new(true));
216        let a11y_adapter = accesskit_winit::Adapter::with_direct_handlers(
217            event_loop,
218            &window,
219            TeksiloActivationHandler {
220                needs_full_tree: a11y_needs_full_tree.clone(),
221            },
222            TeksiloActionHandler { tx: action_tx },
223            TeksiloDeactivationHandler,
224        );
225
226        // Show the window now that the adapter is created
227        window.set_visible(true);
228
229        Self {
230            window,
231            surface,
232            surface_config,
233            renderer,
234            scale_factor,
235            a11y_adapter: Some(a11y_adapter),
236            a11y_action_rx: action_rx,
237            a11y_needs_full_tree,
238        }
239    }
240
241    /// Create a platform window without AccessKit (for contexts without ActiveEventLoop).
242    pub async fn new(window: Window) -> Self {
243        let window = Arc::new(window);
244        let scale_factor = window.scale_factor();
245        let (surface, surface_config, renderer) = Self::surface_and_renderer(&window).await;
246        let (_action_tx, action_rx) = mpsc::channel();
247
248        Self {
249            window,
250            surface,
251            surface_config,
252            renderer,
253            scale_factor,
254            a11y_adapter: None,
255            a11y_action_rx: action_rx,
256            a11y_needs_full_tree: Arc::new(AtomicBool::new(false)),
257        }
258    }
259
260    pub fn window(&self) -> &Window {
261        &self.window
262    }
263
264    /// Get a clonable `Arc` reference to the underlying winit window.
265    /// Used by `teksilo_platform::create_title_bar_host` and other components
266    /// that need shared ownership of the window.
267    pub fn window_arc(&self) -> Arc<Window> {
268        self.window.clone()
269    }
270
271    pub fn renderer(&self) -> &Renderer {
272        &self.renderer
273    }
274
275    pub fn renderer_mut(&mut self) -> &mut Renderer {
276        &mut self.renderer
277    }
278
279    pub fn scale_factor(&self) -> f64 {
280        self.scale_factor
281    }
282
283    pub fn set_scale_factor(&mut self, factor: f64) {
284        self.scale_factor = factor;
285    }
286
287    /// Resize the surface.
288    pub fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
289        if new_size.width > 0 && new_size.height > 0 {
290            self.surface_config.width = new_size.width;
291            self.surface_config.height = new_size.height;
292            self.surface
293                .configure(self.renderer.device(), &self.surface_config);
294        }
295    }
296
297    /// Get current surface dimensions.
298    pub fn surface_size(&self) -> (u32, u32) {
299        (self.surface_config.width, self.surface_config.height)
300    }
301
302    /// Reconfigure the surface with the current config.
303    /// Use after a Lost or Outdated surface error.
304    pub fn reconfigure_surface(&mut self) {
305        self.surface
306            .configure(self.renderer.device(), &self.surface_config);
307    }
308
309    /// Render a frame to the surface.
310    pub fn render_frame(
311        &mut self,
312        frame: &teksilo_canvas::RenderFrame,
313        clear_color: [f32; 4],
314    ) -> FrameOutcome {
315        let current = self.surface.get_current_texture();
316        let output = match current {
317            wgpu::CurrentSurfaceTexture::Success(tex)
318            | wgpu::CurrentSurfaceTexture::Suboptimal(tex) => tex,
319            wgpu::CurrentSurfaceTexture::Occluded | wgpu::CurrentSurfaceTexture::Timeout => {
320                return FrameOutcome::Skipped;
321            }
322            wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
323                return FrameOutcome::NeedsReconfigure;
324            }
325            other => return FrameOutcome::Error(SurfaceRenderError(format!("{other:?}"))),
326        };
327
328        let view = output
329            .texture
330            .create_view(&wgpu::TextureViewDescriptor::default());
331
332        let (w, h) = self.surface_size();
333        self.renderer
334            .render(frame, &view, self.scale_factor as f32, w, h, clear_color);
335
336        self.renderer.queue().present(output);
337        FrameOutcome::Rendered
338    }
339
340    /// Render `frame` into an offscreen texture and read it back as
341    /// tightly-packed RGBA8 bytes, returning `(rgba, width, height)`.
342    ///
343    /// Used by the debug-only automation bridge to capture a *live* window
344    /// without going through the swapchain — the surface texture is
345    /// configured `RENDER_ATTACHMENT` only (no `COPY_SRC`), so it can't be
346    /// read back directly. The offscreen texture uses the window's own
347    /// surface format so it matches the renderer's pipelines; a BGRA
348    /// readback is swizzled to RGBA here so the output is always RGBA. With
349    /// `crop = Some(rect)` (physical pixels, clamped to the surface) only
350    /// that sub-rectangle is returned. Returns an empty `(vec, 0, 0)` if
351    /// the crop is fully outside the surface.
352    ///
353    /// Note: a native `WebView` subview composites *on top of* the wgpu
354    /// surface and is invisible to this readback (a transparent hole).
355    pub fn capture_offscreen(
356        &mut self,
357        frame: &teksilo_canvas::RenderFrame,
358        clear_color: [f32; 4],
359        crop: Option<teksilo_canvas::Rect>,
360    ) -> (Vec<u8>, u32, u32) {
361        fn crop_rgba(
362            src: &[u8],
363            w: u32,
364            h: u32,
365            rect: teksilo_canvas::Rect,
366        ) -> (Vec<u8>, u32, u32) {
367            let x0 = (rect.x.floor().max(0.0) as u32).min(w);
368            let y0 = (rect.y.floor().max(0.0) as u32).min(h);
369            let x1 = ((rect.x + rect.width).ceil().max(0.0) as u32).min(w);
370            let y1 = ((rect.y + rect.height).ceil().max(0.0) as u32).min(h);
371            if x1 <= x0 || y1 <= y0 {
372                return (Vec::new(), 0, 0);
373            }
374            let cw = x1 - x0;
375            let ch = y1 - y0;
376            let mut out = Vec::with_capacity((cw * ch * 4) as usize);
377            for y in y0..y1 {
378                let row_start = ((y * w + x0) * 4) as usize;
379                let row_end = row_start + (cw * 4) as usize;
380                out.extend_from_slice(&src[row_start..row_end]);
381            }
382            (out, cw, ch)
383        }
384
385        let (w, h) = self.surface_size();
386        let format = self.surface_config.format;
387        // The readback assumes a 4-byte, 8-bit RGBA/BGRA layout (the BGRA
388        // swizzle below + `read_texture_rgba`'s fixed 4-bytes-per-pixel copy).
389        // Desktop wgpu surfaces are always one of these four; a packed
390        // (Rgb10a2) or wide (Rgba16Float) surface format would read back
391        // garbage, so flag it loudly in debug builds.
392        debug_assert!(
393            matches!(
394                format,
395                wgpu::TextureFormat::Rgba8Unorm
396                    | wgpu::TextureFormat::Rgba8UnormSrgb
397                    | wgpu::TextureFormat::Bgra8Unorm
398                    | wgpu::TextureFormat::Bgra8UnormSrgb
399            ),
400            "capture_offscreen: unsupported surface format {format:?} (expected 8-bit RGBA/BGRA)"
401        );
402        let texture = self
403            .renderer
404            .device()
405            .create_texture(&wgpu::TextureDescriptor {
406                label: Some("teksilo-automation capture"),
407                size: wgpu::Extent3d {
408                    width: w,
409                    height: h,
410                    depth_or_array_layers: 1,
411                },
412                mip_level_count: 1,
413                sample_count: 1,
414                dimension: wgpu::TextureDimension::D2,
415                format,
416                usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
417                view_formats: &[],
418            });
419        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
420        self.renderer
421            .render(frame, &view, self.scale_factor as f32, w, h, clear_color);
422        let mut bytes = teksilo_render::test_support::read_texture_rgba(
423            self.renderer.device(),
424            self.renderer.queue(),
425            &texture,
426            w,
427            h,
428        );
429        // `read_texture_rgba` copies raw channel bytes; a BGRA surface
430        // needs its B/R swapped to become RGBA for PNG encoding.
431        if matches!(
432            format,
433            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
434        ) {
435            for px in bytes.as_chunks_mut::<4>().0 {
436                px.swap(0, 2);
437            }
438        }
439        match crop {
440            Some(rect) => crop_rgba(&bytes, w, h, rect),
441            None => (bytes, w, h),
442        }
443    }
444
445    pub fn request_redraw(&self) {
446        self.window.request_redraw();
447    }
448
449    /// Push an AccessKit TreeUpdate to the adapter (called after layout).
450    pub fn update_accessibility(&mut self, update: accesskit::TreeUpdate) {
451        if let Some(adapter) = &mut self.a11y_adapter {
452            adapter.update_if_active(|| update);
453        }
454    }
455
456    /// Push an update the adapter builds only when it is actually going to
457    /// be delivered, and only when `build` says there is one worth sending.
458    ///
459    /// The caller decides *inside* the closure, because that is where the
460    /// decision belongs: `update_if_active` runs its closure only when an
461    /// assistive technology is attached, and on Linux it runs it under the
462    /// adapter's own state lock. Deciding outside would build a tree for
463    /// nobody on every frame, and would make the throttle count frames
464    /// nothing was listening to.
465    ///
466    /// `build` returning `None` means "nothing to deliver"; the previously
467    /// delivered tree is re-sent, which the consumer treats as a no-op.
468    pub fn update_accessibility_with(
469        &mut self,
470        build: impl FnOnce() -> Option<accesskit::TreeUpdate>,
471        previous: impl FnOnce() -> accesskit::TreeUpdate,
472    ) {
473        if let Some(adapter) = &mut self.a11y_adapter {
474            adapter.update_if_active(|| build().unwrap_or_else(previous));
475        }
476    }
477
478    /// Whether an assistive technology has asked this window for its tree
479    /// and has not yet been given a full one.
480    ///
481    /// Set by the activation handler, which runs on whichever thread the
482    /// platform's accessibility layer calls it from, and cleared by the
483    /// first delivery after it — so a reader that attaches mid-session gets
484    /// a complete tree rather than a geometry patch onto a tree it has
485    /// never seen.
486    pub fn accessibility_needs_full_tree(&self) -> bool {
487        self.a11y_needs_full_tree.load(Ordering::Relaxed)
488    }
489
490    /// Clear the flag above, reporting what it was.
491    pub fn take_accessibility_needs_full_tree(&self) -> bool {
492        self.a11y_needs_full_tree.swap(false, Ordering::Relaxed)
493    }
494
495    /// Forward a winit WindowEvent to the AccessKit adapter.
496    pub fn process_accessibility_event(&mut self, event: &WindowEvent) {
497        if let Some(adapter) = &mut self.a11y_adapter {
498            adapter.process_event(&self.window, event);
499        }
500    }
501
502    /// Drain any pending AccessKit action requests from the adapter.
503    pub fn drain_accessibility_actions(&self) -> Vec<ActionRequest> {
504        let mut actions = Vec::new();
505        while let Ok(req) = self.a11y_action_rx.try_recv() {
506            actions.push(req);
507        }
508        actions
509    }
510}
511
512// --- AccessKit handler implementations ---
513
514/// Activation handler — returns an empty initial tree.
515/// The real tree is sent via `update_if_active` on the next frame.
516///
517/// The flag is what makes that next frame send a *full* tree: deliveries
518/// are otherwise throttled to the moves-only rate, and a reader that
519/// attaches mid-session would be handed a geometry patch onto a tree
520/// consisting of one empty window.
521struct TeksiloActivationHandler {
522    needs_full_tree: Arc<AtomicBool>,
523}
524
525impl accesskit::ActivationHandler for TeksiloActivationHandler {
526    fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
527        self.needs_full_tree.store(true, Ordering::Relaxed);
528        // Return a minimal tree; the real one arrives on the next frame
529        let root = accesskit::Node::new(accesskit::Role::Window);
530        Some(accesskit::TreeUpdate {
531            nodes: vec![(accesskit::NodeId(0), root)],
532            tree: Some(accesskit::TreeInfo::new(accesskit::NodeId(0))),
533            tree_id: accesskit::TreeId::ROOT,
534            focus: accesskit::NodeId(0),
535        })
536    }
537}
538
539/// Action handler — forwards action requests to the main thread via a channel.
540struct TeksiloActionHandler {
541    tx: mpsc::Sender<ActionRequest>,
542}
543
544impl accesskit::ActionHandler for TeksiloActionHandler {
545    fn do_action(&mut self, request: ActionRequest) {
546        let _ = self.tx.send(request);
547    }
548}
549
550/// Deactivation handler — no-op.
551struct TeksiloDeactivationHandler;
552
553impl accesskit::DeactivationHandler for TeksiloDeactivationHandler {
554    fn deactivate_accessibility(&mut self) {
555        // Nothing to clean up
556    }
557}