Skip to main content

teksilo_platform/
window.rs

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