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;
5use std::sync::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    /// Receiver for accessibility action requests from the adapter.
50    a11y_action_rx: mpsc::Receiver<ActionRequest>,
51}
52
53impl PlatformWindow {
54    /// Create a new platform window from a winit window.
55    /// The `event_loop` parameter is needed for the AccessKit adapter.
56    pub async fn new_with_a11y(
57        window: Window,
58        event_loop: &winit::event_loop::ActiveEventLoop,
59    ) -> Self {
60        let window = Arc::new(window);
61        let size = window.inner_size();
62        let scale_factor = window.scale_factor();
63
64        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
65
66        let surface = instance
67            .create_surface(window.clone())
68            .expect("wgpu surface creation failed for the platform window");
69
70        let adapter = instance
71            .request_adapter(&wgpu::RequestAdapterOptions {
72                power_preference: wgpu::PowerPreference::default(),
73                compatible_surface: Some(&surface),
74                force_fallback_adapter: false,
75                ..Default::default()
76            })
77            .await
78            .expect("no compatible wgpu adapter available");
79
80        let (device, queue) = adapter
81            .request_device(&wgpu::DeviceDescriptor {
82                label: Some("teksilo_device"),
83                required_features: wgpu::Features::empty(),
84                required_limits: wgpu::Limits::default(),
85                ..Default::default()
86            })
87            .await
88            .expect("wgpu device request failed");
89
90        let surface_caps = surface.get_capabilities(&adapter);
91        // Guard the index accesses: a degenerate adapter/surface (software
92        // fallback, headless) can report empty `formats` / `alpha_modes`, and
93        // `[0]` would panic with an opaque out-of-bounds instead of degrading.
94        let surface_format = surface_caps
95            .formats
96            .iter()
97            .find(|f| f.is_srgb())
98            .copied()
99            .or_else(|| surface_caps.formats.first().copied())
100            .unwrap_or(wgpu::TextureFormat::Rgba8UnormSrgb);
101
102        let surface_config = wgpu::SurfaceConfiguration {
103            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
104            format: surface_format,
105            width: size.width.max(1),
106            height: size.height.max(1),
107            present_mode: wgpu::PresentMode::Fifo,
108            alpha_mode: surface_caps
109                .alpha_modes
110                .first()
111                .copied()
112                .unwrap_or(wgpu::CompositeAlphaMode::Auto),
113            view_formats: vec![],
114            desired_maximum_frame_latency: 2,
115            // `Auto` reproduces wgpu's pre-30 behaviour: sRGB for the
116            // non-`Rgba16Float` formats we select above.
117            color_space: wgpu::SurfaceColorSpace::Auto,
118        };
119        surface.configure(&device, &surface_config);
120
121        let renderer = Renderer::new(device, queue, surface_format);
122
123        // Create AccessKit adapter with action channel
124        let (action_tx, action_rx) = mpsc::channel();
125
126        let a11y_adapter = accesskit_winit::Adapter::with_direct_handlers(
127            event_loop,
128            &window,
129            TeksiloActivationHandler,
130            TeksiloActionHandler { tx: action_tx },
131            TeksiloDeactivationHandler,
132        );
133
134        // Show the window now that the adapter is created
135        window.set_visible(true);
136
137        Self {
138            window,
139            surface,
140            surface_config,
141            renderer,
142            scale_factor,
143            a11y_adapter: Some(a11y_adapter),
144            a11y_action_rx: action_rx,
145        }
146    }
147
148    /// Create a platform window without AccessKit (for contexts without ActiveEventLoop).
149    pub async fn new(window: Window) -> Self {
150        let window = Arc::new(window);
151        let size = window.inner_size();
152        let scale_factor = window.scale_factor();
153
154        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
155        let surface = instance
156            .create_surface(window.clone())
157            .expect("wgpu surface creation failed for the platform window");
158
159        let gpu_adapter = instance
160            .request_adapter(&wgpu::RequestAdapterOptions {
161                power_preference: wgpu::PowerPreference::default(),
162                compatible_surface: Some(&surface),
163                force_fallback_adapter: false,
164                ..Default::default()
165            })
166            .await
167            .expect("no compatible wgpu adapter available");
168
169        let (device, queue) = gpu_adapter
170            .request_device(&wgpu::DeviceDescriptor {
171                label: Some("teksilo_device"),
172                required_features: wgpu::Features::empty(),
173                required_limits: wgpu::Limits::default(),
174                ..Default::default()
175            })
176            .await
177            .expect("wgpu device request failed");
178
179        let surface_caps = surface.get_capabilities(&gpu_adapter);
180        // See the sibling path above: guard against an empty `formats` /
181        // `alpha_modes` rather than panicking on `[0]`.
182        let surface_format = surface_caps
183            .formats
184            .iter()
185            .find(|f| f.is_srgb())
186            .copied()
187            .or_else(|| surface_caps.formats.first().copied())
188            .unwrap_or(wgpu::TextureFormat::Rgba8UnormSrgb);
189
190        let surface_config = wgpu::SurfaceConfiguration {
191            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
192            format: surface_format,
193            width: size.width.max(1),
194            height: size.height.max(1),
195            present_mode: wgpu::PresentMode::Fifo,
196            alpha_mode: surface_caps
197                .alpha_modes
198                .first()
199                .copied()
200                .unwrap_or(wgpu::CompositeAlphaMode::Auto),
201            view_formats: vec![],
202            desired_maximum_frame_latency: 2,
203            // `Auto` reproduces wgpu's pre-30 behaviour: sRGB for the
204            // non-`Rgba16Float` formats we select above.
205            color_space: wgpu::SurfaceColorSpace::Auto,
206        };
207        surface.configure(&device, &surface_config);
208
209        let renderer = Renderer::new(device, queue, surface_format);
210        let (_action_tx, action_rx) = mpsc::channel();
211
212        Self {
213            window,
214            surface,
215            surface_config,
216            renderer,
217            scale_factor,
218            a11y_adapter: None,
219            a11y_action_rx: action_rx,
220        }
221    }
222
223    pub fn window(&self) -> &Window {
224        &self.window
225    }
226
227    /// Get a clonable `Arc` reference to the underlying winit window.
228    /// Used by `teksilo_platform::create_title_bar_host` and other components
229    /// that need shared ownership of the window.
230    pub fn window_arc(&self) -> Arc<Window> {
231        self.window.clone()
232    }
233
234    pub fn renderer(&self) -> &Renderer {
235        &self.renderer
236    }
237
238    pub fn renderer_mut(&mut self) -> &mut Renderer {
239        &mut self.renderer
240    }
241
242    pub fn scale_factor(&self) -> f64 {
243        self.scale_factor
244    }
245
246    pub fn set_scale_factor(&mut self, factor: f64) {
247        self.scale_factor = factor;
248    }
249
250    /// Resize the surface.
251    pub fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
252        if new_size.width > 0 && new_size.height > 0 {
253            self.surface_config.width = new_size.width;
254            self.surface_config.height = new_size.height;
255            self.surface
256                .configure(self.renderer.device(), &self.surface_config);
257        }
258    }
259
260    /// Get current surface dimensions.
261    pub fn surface_size(&self) -> (u32, u32) {
262        (self.surface_config.width, self.surface_config.height)
263    }
264
265    /// Reconfigure the surface with the current config.
266    /// Use after a Lost or Outdated surface error.
267    pub fn reconfigure_surface(&mut self) {
268        self.surface
269            .configure(self.renderer.device(), &self.surface_config);
270    }
271
272    /// Render a frame to the surface.
273    pub fn render_frame(
274        &mut self,
275        frame: &teksilo_canvas::RenderFrame,
276        clear_color: [f32; 4],
277    ) -> FrameOutcome {
278        let current = self.surface.get_current_texture();
279        let output = match current {
280            wgpu::CurrentSurfaceTexture::Success(tex)
281            | wgpu::CurrentSurfaceTexture::Suboptimal(tex) => tex,
282            wgpu::CurrentSurfaceTexture::Occluded | wgpu::CurrentSurfaceTexture::Timeout => {
283                return FrameOutcome::Skipped;
284            }
285            wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
286                return FrameOutcome::NeedsReconfigure;
287            }
288            other => return FrameOutcome::Error(SurfaceRenderError(format!("{other:?}"))),
289        };
290
291        let view = output
292            .texture
293            .create_view(&wgpu::TextureViewDescriptor::default());
294
295        let (w, h) = self.surface_size();
296        self.renderer
297            .render(frame, &view, self.scale_factor as f32, w, h, clear_color);
298
299        self.renderer.queue().present(output);
300        FrameOutcome::Rendered
301    }
302
303    /// Render `frame` into an offscreen texture and read it back as
304    /// tightly-packed RGBA8 bytes, returning `(rgba, width, height)`.
305    ///
306    /// Used by the debug-only automation bridge to capture a *live* window
307    /// without going through the swapchain — the surface texture is
308    /// configured `RENDER_ATTACHMENT` only (no `COPY_SRC`), so it can't be
309    /// read back directly. The offscreen texture uses the window's own
310    /// surface format so it matches the renderer's pipelines; a BGRA
311    /// readback is swizzled to RGBA here so the output is always RGBA. With
312    /// `crop = Some(rect)` (physical pixels, clamped to the surface) only
313    /// that sub-rectangle is returned. Returns an empty `(vec, 0, 0)` if
314    /// the crop is fully outside the surface.
315    ///
316    /// Note: a native `WebView` subview composites *on top of* the wgpu
317    /// surface and is invisible to this readback (a transparent hole).
318    pub fn capture_offscreen(
319        &mut self,
320        frame: &teksilo_canvas::RenderFrame,
321        clear_color: [f32; 4],
322        crop: Option<teksilo_canvas::Rect>,
323    ) -> (Vec<u8>, u32, u32) {
324        fn crop_rgba(
325            src: &[u8],
326            w: u32,
327            h: u32,
328            rect: teksilo_canvas::Rect,
329        ) -> (Vec<u8>, u32, u32) {
330            let x0 = (rect.x.floor().max(0.0) as u32).min(w);
331            let y0 = (rect.y.floor().max(0.0) as u32).min(h);
332            let x1 = ((rect.x + rect.width).ceil().max(0.0) as u32).min(w);
333            let y1 = ((rect.y + rect.height).ceil().max(0.0) as u32).min(h);
334            if x1 <= x0 || y1 <= y0 {
335                return (Vec::new(), 0, 0);
336            }
337            let cw = x1 - x0;
338            let ch = y1 - y0;
339            let mut out = Vec::with_capacity((cw * ch * 4) as usize);
340            for y in y0..y1 {
341                let row_start = ((y * w + x0) * 4) as usize;
342                let row_end = row_start + (cw * 4) as usize;
343                out.extend_from_slice(&src[row_start..row_end]);
344            }
345            (out, cw, ch)
346        }
347
348        let (w, h) = self.surface_size();
349        let format = self.surface_config.format;
350        // The readback assumes a 4-byte, 8-bit RGBA/BGRA layout (the BGRA
351        // swizzle below + `read_texture_rgba`'s fixed 4-bytes-per-pixel copy).
352        // Desktop wgpu surfaces are always one of these four; a packed
353        // (Rgb10a2) or wide (Rgba16Float) surface format would read back
354        // garbage, so flag it loudly in debug builds.
355        debug_assert!(
356            matches!(
357                format,
358                wgpu::TextureFormat::Rgba8Unorm
359                    | wgpu::TextureFormat::Rgba8UnormSrgb
360                    | wgpu::TextureFormat::Bgra8Unorm
361                    | wgpu::TextureFormat::Bgra8UnormSrgb
362            ),
363            "capture_offscreen: unsupported surface format {format:?} (expected 8-bit RGBA/BGRA)"
364        );
365        let texture = self
366            .renderer
367            .device()
368            .create_texture(&wgpu::TextureDescriptor {
369                label: Some("teksilo-automation capture"),
370                size: wgpu::Extent3d {
371                    width: w,
372                    height: h,
373                    depth_or_array_layers: 1,
374                },
375                mip_level_count: 1,
376                sample_count: 1,
377                dimension: wgpu::TextureDimension::D2,
378                format,
379                usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
380                view_formats: &[],
381            });
382        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
383        self.renderer
384            .render(frame, &view, self.scale_factor as f32, w, h, clear_color);
385        let mut bytes = teksilo_render::test_support::read_texture_rgba(
386            self.renderer.device(),
387            self.renderer.queue(),
388            &texture,
389            w,
390            h,
391        );
392        // `read_texture_rgba` copies raw channel bytes; a BGRA surface
393        // needs its B/R swapped to become RGBA for PNG encoding.
394        if matches!(
395            format,
396            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
397        ) {
398            for px in bytes.as_chunks_mut::<4>().0 {
399                px.swap(0, 2);
400            }
401        }
402        match crop {
403            Some(rect) => crop_rgba(&bytes, w, h, rect),
404            None => (bytes, w, h),
405        }
406    }
407
408    pub fn request_redraw(&self) {
409        self.window.request_redraw();
410    }
411
412    /// Push an AccessKit TreeUpdate to the adapter (called after layout).
413    pub fn update_accessibility(&mut self, update: accesskit::TreeUpdate) {
414        if let Some(adapter) = &mut self.a11y_adapter {
415            adapter.update_if_active(|| update);
416        }
417    }
418
419    /// Forward a winit WindowEvent to the AccessKit adapter.
420    pub fn process_accessibility_event(&mut self, event: &WindowEvent) {
421        if let Some(adapter) = &mut self.a11y_adapter {
422            adapter.process_event(&self.window, event);
423        }
424    }
425
426    /// Drain any pending AccessKit action requests from the adapter.
427    pub fn drain_accessibility_actions(&self) -> Vec<ActionRequest> {
428        let mut actions = Vec::new();
429        while let Ok(req) = self.a11y_action_rx.try_recv() {
430            actions.push(req);
431        }
432        actions
433    }
434}
435
436// --- AccessKit handler implementations ---
437
438/// Activation handler — returns an empty initial tree.
439/// The real tree is sent via `update_if_active` on the next frame.
440struct TeksiloActivationHandler;
441
442impl accesskit::ActivationHandler for TeksiloActivationHandler {
443    fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
444        // Return a minimal tree; the real one arrives on the next frame
445        let root = accesskit::Node::new(accesskit::Role::Window);
446        Some(accesskit::TreeUpdate {
447            nodes: vec![(accesskit::NodeId(0), root)],
448            tree: Some(accesskit::TreeInfo::new(accesskit::NodeId(0))),
449            tree_id: accesskit::TreeId::ROOT,
450            focus: accesskit::NodeId(0),
451        })
452    }
453}
454
455/// Action handler — forwards action requests to the main thread via a channel.
456struct TeksiloActionHandler {
457    tx: mpsc::Sender<ActionRequest>,
458}
459
460impl accesskit::ActionHandler for TeksiloActionHandler {
461    fn do_action(&mut self, request: ActionRequest) {
462        let _ = self.tx.send(request);
463    }
464}
465
466/// Deactivation handler — no-op.
467struct TeksiloDeactivationHandler;
468
469impl accesskit::DeactivationHandler for TeksiloDeactivationHandler {
470    fn deactivate_accessibility(&mut self) {
471        // Nothing to clean up
472    }
473}