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    /// The accessibility state the adapter's off-thread handlers share with
56    /// the UI thread. See [`AccessibilityBridge`].
57    a11y_bridge: Arc<AccessibilityBridge>,
58}
59
60/// The state an AccessKit adapter's handlers share with the UI thread.
61///
62/// `accesskit_winit::Adapter::with_direct_handlers` requires every handler to
63/// be `Send` and calls it from whatever thread the platform's accessibility
64/// stack happens to use — the UIA provider thread on Windows, an AT-SPI task on
65/// Linux. A [`teksilo_core::WidgetTree`] is `!Send`, so no handler can reach
66/// one. Everything they need to say to the UI thread therefore goes through
67/// this, and everything they need to read from it is a snapshot the UI thread
68/// leaves here.
69/// The whole policy lives here rather than in the three handler types,
70/// because a handler owns an `Arc<Window>` and so cannot be built in a test
71/// without an event loop, while this can.
72#[derive(Debug, Default)]
73pub(crate) struct AccessibilityBridge {
74    /// The most recent `TreeUpdate` the UI thread published, kept so that
75    /// `request_initial_tree` can answer with the real tree instead of a
76    /// placeholder. `None` before the first frame.
77    snapshot: Mutex<Option<accesskit::TreeUpdate>>,
78    /// Whether an AccessKit client is attached right now. Set on activation,
79    /// cleared on deactivation.
80    active: std::sync::atomic::AtomicBool,
81}
82
83impl AccessibilityBridge {
84    /// Leave a tree where the activation handler can find it. Called from the
85    /// UI thread on every published update.
86    ///
87    /// A no-op while a client is attached, and that is the point: the snapshot
88    /// is read by `request_initial_tree` alone, which by definition runs while
89    /// nothing is attached — an attached client already has the live tree
90    /// through `update_if_active`. Skipping the clone there keeps the cost off
91    /// the frame path exactly when a screen reader is running and frames matter
92    /// most. The window between a detach and the next frame leaves the snapshot
93    /// one frame stale, which is a frame-old application rather than an empty
94    /// one; the deactivation handler asks for that frame.
95    pub(crate) fn publish(&self, update: &accesskit::TreeUpdate) {
96        if self.is_active() {
97            return;
98        }
99        if let Ok(mut slot) = self.snapshot.lock() {
100            *slot = Some(update.clone());
101        }
102    }
103
104    /// A client attached: record it and answer with the best tree available.
105    ///
106    /// The last published one if there is one — an assistive technology
107    /// attaching to an idle window must not be shown an empty application —
108    /// and the bare window node only before this window has ever drawn.
109    pub(crate) fn on_activate(&self) -> accesskit::TreeUpdate {
110        self.active
111            .store(true, std::sync::atomic::Ordering::Relaxed);
112        self.snapshot
113            .lock()
114            .ok()
115            .and_then(|slot| slot.clone())
116            .unwrap_or_else(empty_initial_tree)
117    }
118
119    /// The last client detached.
120    pub(crate) fn on_deactivate(&self) {
121        self.active
122            .store(false, std::sync::atomic::Ordering::Relaxed);
123    }
124
125    /// Whether a client is attached right now.
126    pub(crate) fn is_active(&self) -> bool {
127        self.active.load(std::sync::atomic::Ordering::Relaxed)
128    }
129}
130
131/// The wgpu objects every window in the process shares.
132///
133/// All three are `Arc` handles internally, so cloning one is a refcount bump,
134/// not a second GPU object.
135#[derive(Clone)]
136struct SharedGpu {
137    adapter: wgpu::Adapter,
138    device: wgpu::Device,
139    queue: wgpu::Queue,
140}
141
142/// The platform display connection the wgpu instance is built against.
143///
144/// Installed by the app layer via [`install_display_handle`] before the first
145/// window exists, and read once by [`shared_instance`].
146static DISPLAY_HANDLE: OnceLock<winit::event_loop::OwnedDisplayHandle> = OnceLock::new();
147
148/// Hand wgpu the platform display connection, before any window is created.
149///
150/// Load-bearing for the OpenGL backend, which is the only backend a machine
151/// with no Vulkan driver has left — an older GPU, or a VM whose guest driver
152/// stops at GL. Without a display handle, wgpu-hal's GLES backend has no
153/// windowing system to bind EGL to and falls back to
154/// `EGL_MESA_platform_surfaceless`: a display that can render offscreen but can
155/// never be compatible with a *window* surface. `request_adapter` then rejects
156/// the only adapter on the machine with `incompatible_surface_backends: GL`,
157/// and the process dies before its first window. Vulkan, Metal and D3D12 ignore
158/// the handle entirely, so this costs those paths nothing.
159///
160/// Only the first call counts; later ones are ignored, because the instance is
161/// built once per process and wgpu forbids presenting a surface from a display
162/// other than the one the instance was created with.
163pub fn install_display_handle(handle: winit::event_loop::OwnedDisplayHandle) {
164    let _ = DISPLAY_HANDLE.set(handle);
165}
166
167/// The one wgpu instance for this process.
168///
169/// A surface has to come from the same instance that later enumerates adapters
170/// for it, so this is the root every window hangs off. `Instance::new` is
171/// synchronous, which is why this one can be a plain `OnceLock` while the
172/// adapter and device below cannot.
173///
174/// The descriptor is built `_from_env`, so wgpu's own variables —
175/// `WGPU_BACKEND`, `WGPU_GLES_MINOR_VERSION` and the rest — work here as they
176/// do in every other wgpu application. That is the escape hatch for the machine
177/// whose preferred backend has a broken driver, and it is worth having
178/// precisely where the default choice is the thing under suspicion.
179fn shared_instance() -> &'static wgpu::Instance {
180    static INSTANCE: OnceLock<wgpu::Instance> = OnceLock::new();
181    INSTANCE.get_or_init(|| {
182        let descriptor = match DISPLAY_HANDLE.get() {
183            Some(display) => wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(
184                display.clone(),
185            )),
186            // No app layer installed one — an embedder driving `PlatformWindow`
187            // itself, or a test. Offscreen work is unaffected; only a GL-backed
188            // window needs the handle.
189            None => wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
190        };
191        wgpu::Instance::new(descriptor)
192    })
193}
194
195/// The adapter, device and queue every window shares.
196///
197/// One device per process, not one per window. A device is a heavyweight,
198/// process-level object and a second one buys nothing: each window still needs
199/// its own surface and its own [`Renderer`] (that is where the glyph and path
200/// atlases live), but the driver objects underneath are the same for every
201/// window on the same adapter. Opening one per window duplicated the entire
202/// pipeline set and both atlas textures for every window a user opened.
203///
204/// It also closes a latent crash. Two D3D12 **WARP** devices rasterizing at the
205/// same time fault inside `d3d10warp.dll` — Microsoft's software rasterizer,
206/// and what a GPU-less Windows host actually draws with. Teksilo renders its
207/// windows sequentially on the winit main thread, so that was not reachable
208/// here; it would have become reachable the moment any window work moved off
209/// that thread. `teksilo_render::test_support` shares its offscreen device for
210/// the same reason, where it *was* reachable and did crash.
211///
212/// `surface` is used only to pick an adapter that can actually present to it.
213/// If a later window's surface turns out to be incompatible with the adapter we
214/// cached — a genuinely multi-GPU machine, where the second window opens on the
215/// other GPU — that window quietly gets its own device rather than failing.
216/// The limits a live window asks its device for.
217///
218/// Deliberately **not** [`wgpu::Limits::default`]. That set demands eight
219/// colour attachments, 64 KiB uniform bindings and 8192-pixel textures. This
220/// renderer draws every pass into a *single* colour attachment, binds at most
221/// 8 KiB of uniforms (128 animation slots of 64 bytes) and caps its path atlas
222/// at 4096 pixels. The headroom was inherited from the default, never needed.
223///
224/// On GLES-3.1 class hardware that headroom is not merely unused, it is
225/// refused: a Raspberry Pi 4's V3D driver allows four colour attachments, so
226/// `default()` failed device creation outright and the app could not open a
227/// window at all.
228///
229/// `downlevel_defaults` is wgpu's GLES-3.1 floor, which is exactly that class
230/// of hardware, and it is already what [`teksilo_render::test_support`] opens
231/// its offscreen device with, so a frame that renders in a test now renders in
232/// a window too. `using_resolution` lifts the three texture-dimension limits
233/// back to whatever this adapter really supports, because the path atlas grows
234/// past the 2048-pixel downlevel cap.
235fn window_device_limits(adapter_limits: wgpu::Limits) -> wgpu::Limits {
236    wgpu::Limits::downlevel_defaults().using_resolution(adapter_limits)
237}
238
239/// Open a device on `adapter`, preferring [`window_device_limits`] and falling
240/// back to whatever the adapter itself reports.
241///
242/// The fallback is not redundant. `downlevel_defaults` is a floor for a *class*
243/// of hardware, not a promise about any given adapter. Anything below GLES 3.1
244/// (an old GL driver, a constrained software rasterizer) can sit under it on a
245/// field `using_resolution` does not lift, and then the principled ask fails
246/// for the same reason `default()` did on the Pi. `adapter.limits()` is by
247/// construction the most that adapter can give, so it cannot be refused on
248/// limit grounds; a request that still fails has a real problem rather than a
249/// mis-sized ask, and that is the error worth propagating.
250async fn open_device(
251    adapter: &wgpu::Adapter,
252) -> Result<(wgpu::Device, wgpu::Queue), wgpu::RequestDeviceError> {
253    let descriptor = |limits| wgpu::DeviceDescriptor {
254        label: Some("teksilo_device"),
255        required_features: wgpu::Features::empty(),
256        required_limits: limits,
257        ..Default::default()
258    };
259
260    match adapter
261        .request_device(&descriptor(window_device_limits(adapter.limits())))
262        .await
263    {
264        Ok(pair) => Ok(pair),
265        Err(err) => {
266            // Say why we dropped to the adapter's own limits: a silent
267            // fallback turns "this GPU is below the GLES-3.1 floor" into an
268            // unexplained difference in behaviour between two machines.
269            eprintln!(
270                "teksilo-platform: downlevel device limits refused ({err}); \
271                 retrying with the adapter's own limits"
272            );
273            adapter.request_device(&descriptor(adapter.limits())).await
274        }
275    }
276}
277
278/// Find an adapter that can present to `surface` *and* yields a device.
279///
280/// Adapter selection is a search, not a single request — the same lesson
281/// [`teksilo_render::test_support`] already encodes for its offscreen device,
282/// which the window path did not have. A host can enumerate an adapter it
283/// cannot actually open (a VM's GL driver is the usual one) while a perfectly
284/// good software adapter sits behind `force_fallback_adapter`. Treating the
285/// first failure as fatal reports "no GPU" on a machine that has one.
286///
287/// Both passes keep `compatible_surface`, so an adapter that cannot present to
288/// this window is never chosen — that is the check that failed on a machine
289/// with no Vulkan driver, and it is load-bearing, not a formality.
290///
291/// Panics only when *every* adapter on the machine declines, with a message
292/// naming what was tried and what the user can do about it.
293async fn open_gpu_for(
294    surface: &wgpu::Surface<'static>,
295) -> (wgpu::Adapter, wgpu::Device, wgpu::Queue) {
296    // `WGPU_POWER_PREF` is wgpu's own knob; honour it for the same reason the
297    // instance is built `_from_env`.
298    let power_preference = wgpu::PowerPreference::from_env().unwrap_or_default();
299    let mut adapter_error = None;
300    let mut device_error = None;
301
302    for force_fallback_adapter in [false, true] {
303        let adapter = match shared_instance()
304            .request_adapter(&wgpu::RequestAdapterOptions {
305                power_preference,
306                compatible_surface: Some(surface),
307                force_fallback_adapter,
308                ..Default::default()
309            })
310            .await
311        {
312            Ok(adapter) => adapter,
313            Err(err) => {
314                adapter_error.get_or_insert(err);
315                continue;
316            }
317        };
318
319        match open_device(&adapter).await {
320            Ok((device, queue)) => return (adapter, device, queue),
321            Err(err) => {
322                // Worth saying out loud: the next pass silently landing on a
323                // software adapter is a large performance difference, and an
324                // unexplained one is the sort of thing that gets reported as
325                // "Teksilo is slow on my machine".
326                eprintln!(
327                    "teksilo-platform: adapter {:?} could not open a device ({err}); \
328                     trying the next one",
329                    adapter.get_info().name
330                );
331                device_error.get_or_insert(err);
332            }
333        }
334    }
335
336    panic!(
337        "no usable GPU adapter for this window.\n\
338         Tried every backend wgpu was built with, then an explicit software \
339         fallback; none could both present to the window and open a device.\n\
340         adapter search: {adapter_error:?}\n\
341         device open:    {device_error:?}\n\
342         Teksilo needs Vulkan, Metal, D3D12 or OpenGL (3.3 desktop / ES 3.0). \
343         On Linux, installing a Vulkan driver is usually the fix: \
344         `mesa-vulkan-drivers` carries both the hardware drivers and the \
345         software `lavapipe`. `WGPU_BACKEND=gl|vulkan|dx12|metal` forces a \
346         specific backend."
347    );
348}
349
350async fn shared_gpu_for(surface: &wgpu::Surface<'static>) -> SharedGpu {
351    static SHARED: Mutex<Option<SharedGpu>> = Mutex::new(None);
352
353    // Clone out and release the lock: it is never held across the awaits below.
354    let cached = SHARED.lock().unwrap_or_else(|e| e.into_inner()).clone();
355    if let Some(gpu) = cached {
356        // A non-empty format list is wgpu's own answer to "can this adapter
357        // present to this surface".
358        if !surface.get_capabilities(&gpu.adapter).formats.is_empty() {
359            return gpu;
360        }
361    }
362
363    let (adapter, device, queue) = open_gpu_for(surface).await;
364
365    let gpu = SharedGpu {
366        adapter,
367        device,
368        queue,
369    };
370    // First one in becomes the shared device. Losing here is the multi-GPU case
371    // above (or a race that cannot happen while windows are created on one
372    // thread): the loser keeps the device it just opened, which is the old
373    // per-window behaviour and still correct.
374    let mut slot = SHARED.lock().unwrap_or_else(|e| e.into_inner());
375    if slot.is_none() {
376        *slot = Some(gpu.clone());
377    }
378    gpu
379}
380
381impl PlatformWindow {
382    /// Everything both constructors do: surface, shared device, swapchain
383    /// configuration, renderer. Kept in one place because the two entry points
384    /// differ only in whether they attach an AccessKit adapter, and sixty
385    /// duplicated lines of GPU setup is exactly the sort of thing that drifts.
386    async fn surface_and_renderer(
387        window: &Arc<Window>,
388    ) -> (wgpu::Surface<'static>, wgpu::SurfaceConfiguration, Renderer) {
389        let size = window.inner_size();
390        let surface = shared_instance()
391            .create_surface(window.clone())
392            .expect("wgpu surface creation failed for the platform window");
393
394        let gpu = shared_gpu_for(&surface).await;
395
396        let surface_caps = surface.get_capabilities(&gpu.adapter);
397        // Guard the index accesses: a degenerate adapter/surface (software
398        // fallback, headless) can report empty `formats` / `alpha_modes`, and
399        // `[0]` would panic with an opaque out-of-bounds instead of degrading.
400        let surface_format = surface_caps
401            .formats
402            .iter()
403            .find(|f| f.is_srgb())
404            .copied()
405            .or_else(|| surface_caps.formats.first().copied())
406            .unwrap_or(wgpu::TextureFormat::Rgba8UnormSrgb);
407
408        let surface_config = wgpu::SurfaceConfiguration {
409            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
410            format: surface_format,
411            width: size.width.max(1),
412            height: size.height.max(1),
413            present_mode: wgpu::PresentMode::Fifo,
414            alpha_mode: surface_caps
415                .alpha_modes
416                .first()
417                .copied()
418                .unwrap_or(wgpu::CompositeAlphaMode::Auto),
419            view_formats: vec![],
420            desired_maximum_frame_latency: 2,
421            // `Auto` reproduces wgpu's pre-30 behaviour: sRGB for the
422            // non-`Rgba16Float` formats we select above.
423            color_space: wgpu::SurfaceColorSpace::Auto,
424        };
425        surface.configure(&gpu.device, &surface_config);
426
427        // The renderer stays per-window: it owns the glyph atlas, the path
428        // atlas and the blur pool, and it is `!Sync` besides.
429        let renderer = Renderer::new(gpu.device, gpu.queue, surface_format);
430        (surface, surface_config, renderer)
431    }
432
433    /// Create a new platform window from a winit window.
434    /// The `event_loop` parameter is needed for the AccessKit adapter.
435    pub async fn new_with_a11y(
436        window: Window,
437        event_loop: &winit::event_loop::ActiveEventLoop,
438    ) -> Self {
439        let window = Arc::new(window);
440        let scale_factor = window.scale_factor();
441        let (surface, surface_config, renderer) = Self::surface_and_renderer(&window).await;
442
443        // Create AccessKit adapter with action channel
444        let (action_tx, action_rx) = mpsc::channel();
445
446        let a11y_needs_full_tree = Arc::new(AtomicBool::new(true));
447        let a11y_bridge = Arc::new(AccessibilityBridge::default());
448
449        // Every handler below runs off the UI thread and ends by asking winit
450        // to redraw this window. That request is the *only* thing that wakes
451        // the event loop: `handle_accessibility_actions` — the sole drain of
452        // the action channel — runs from `window_event`, so without a wakeup an
453        // action issued by Narrator or Orca would sit in the channel until some
454        // unrelated window event happened to arrive. `Window::request_redraw`
455        // is thread-safe, which is why an `Arc<Window>` clone is all a handler
456        // needs.
457        let a11y_adapter = accesskit_winit::Adapter::with_direct_handlers(
458            event_loop,
459            &window,
460            TeksiloActivationHandler {
461                needs_full_tree: a11y_needs_full_tree.clone(),
462                bridge: Arc::clone(&a11y_bridge),
463                window: Arc::clone(&window),
464            },
465            TeksiloActionHandler {
466                tx: action_tx,
467                window: Arc::clone(&window),
468            },
469            TeksiloDeactivationHandler {
470                bridge: Arc::clone(&a11y_bridge),
471                window: Arc::clone(&window),
472            },
473        );
474
475        // Show the window now that the adapter is created
476        window.set_visible(true);
477
478        Self {
479            window,
480            surface,
481            surface_config,
482            renderer,
483            scale_factor,
484            a11y_adapter: Some(a11y_adapter),
485            a11y_action_rx: action_rx,
486            a11y_needs_full_tree,
487            a11y_bridge,
488        }
489    }
490
491    /// Create a platform window without AccessKit (for contexts without ActiveEventLoop).
492    pub async fn new(window: Window) -> Self {
493        let window = Arc::new(window);
494        let scale_factor = window.scale_factor();
495        let (surface, surface_config, renderer) = Self::surface_and_renderer(&window).await;
496        let (_action_tx, action_rx) = mpsc::channel();
497
498        Self {
499            window,
500            surface,
501            surface_config,
502            renderer,
503            scale_factor,
504            a11y_adapter: None,
505            a11y_action_rx: action_rx,
506            a11y_needs_full_tree: Arc::new(AtomicBool::new(false)),
507            a11y_bridge: Arc::new(AccessibilityBridge::default()),
508        }
509    }
510
511    pub fn window(&self) -> &Window {
512        &self.window
513    }
514
515    /// Get a clonable `Arc` reference to the underlying winit window.
516    /// Used by `teksilo_platform::create_title_bar_host` and other components
517    /// that need shared ownership of the window.
518    pub fn window_arc(&self) -> Arc<Window> {
519        self.window.clone()
520    }
521
522    pub fn renderer(&self) -> &Renderer {
523        &self.renderer
524    }
525
526    pub fn renderer_mut(&mut self) -> &mut Renderer {
527        &mut self.renderer
528    }
529
530    pub fn scale_factor(&self) -> f64 {
531        self.scale_factor
532    }
533
534    pub fn set_scale_factor(&mut self, factor: f64) {
535        self.scale_factor = factor;
536    }
537
538    /// Resize the surface.
539    pub fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
540        if new_size.width > 0 && new_size.height > 0 {
541            self.surface_config.width = new_size.width;
542            self.surface_config.height = new_size.height;
543            self.surface
544                .configure(self.renderer.device(), &self.surface_config);
545        }
546    }
547
548    /// Get current surface dimensions.
549    pub fn surface_size(&self) -> (u32, u32) {
550        (self.surface_config.width, self.surface_config.height)
551    }
552
553    /// Reconfigure the surface with the current config.
554    /// Use after a Lost or Outdated surface error.
555    pub fn reconfigure_surface(&mut self) {
556        self.surface
557            .configure(self.renderer.device(), &self.surface_config);
558    }
559
560    /// Render a frame to the surface.
561    pub fn render_frame(
562        &mut self,
563        frame: &teksilo_canvas::RenderFrame,
564        clear_color: [f32; 4],
565    ) -> FrameOutcome {
566        let current = self.surface.get_current_texture();
567        let output = match current {
568            wgpu::CurrentSurfaceTexture::Success(tex)
569            | wgpu::CurrentSurfaceTexture::Suboptimal(tex) => tex,
570            wgpu::CurrentSurfaceTexture::Occluded | wgpu::CurrentSurfaceTexture::Timeout => {
571                return FrameOutcome::Skipped;
572            }
573            wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
574                return FrameOutcome::NeedsReconfigure;
575            }
576            other => return FrameOutcome::Error(SurfaceRenderError(format!("{other:?}"))),
577        };
578
579        let view = output
580            .texture
581            .create_view(&wgpu::TextureViewDescriptor::default());
582
583        let (w, h) = self.surface_size();
584        self.renderer
585            .render(frame, &view, self.scale_factor as f32, w, h, clear_color);
586
587        self.renderer.queue().present(output);
588        FrameOutcome::Rendered
589    }
590
591    /// Render `frame` into an offscreen texture and read it back as
592    /// tightly-packed RGBA8 bytes, returning `(rgba, width, height)`.
593    ///
594    /// Used by the debug-only automation bridge to capture a *live* window
595    /// without going through the swapchain — the surface texture is
596    /// configured `RENDER_ATTACHMENT` only (no `COPY_SRC`), so it can't be
597    /// read back directly. The offscreen texture uses the window's own
598    /// surface format so it matches the renderer's pipelines; a BGRA
599    /// readback is swizzled to RGBA here so the output is always RGBA. With
600    /// `crop = Some(rect)` (physical pixels, clamped to the surface) only
601    /// that sub-rectangle is returned. Returns an empty `(vec, 0, 0)` if
602    /// the crop is fully outside the surface.
603    ///
604    /// Note: a native `WebView` subview composites *on top of* the wgpu
605    /// surface and is invisible to this readback (a transparent hole).
606    pub fn capture_offscreen(
607        &mut self,
608        frame: &teksilo_canvas::RenderFrame,
609        clear_color: [f32; 4],
610        crop: Option<teksilo_canvas::Rect>,
611    ) -> (Vec<u8>, u32, u32) {
612        fn crop_rgba(
613            src: &[u8],
614            w: u32,
615            h: u32,
616            rect: teksilo_canvas::Rect,
617        ) -> (Vec<u8>, u32, u32) {
618            let x0 = (rect.x.floor().max(0.0) as u32).min(w);
619            let y0 = (rect.y.floor().max(0.0) as u32).min(h);
620            let x1 = ((rect.x + rect.width).ceil().max(0.0) as u32).min(w);
621            let y1 = ((rect.y + rect.height).ceil().max(0.0) as u32).min(h);
622            if x1 <= x0 || y1 <= y0 {
623                return (Vec::new(), 0, 0);
624            }
625            let cw = x1 - x0;
626            let ch = y1 - y0;
627            let mut out = Vec::with_capacity((cw * ch * 4) as usize);
628            for y in y0..y1 {
629                let row_start = ((y * w + x0) * 4) as usize;
630                let row_end = row_start + (cw * 4) as usize;
631                out.extend_from_slice(&src[row_start..row_end]);
632            }
633            (out, cw, ch)
634        }
635
636        let (w, h) = self.surface_size();
637        let format = self.surface_config.format;
638        // The readback assumes a 4-byte, 8-bit RGBA/BGRA layout (the BGRA
639        // swizzle below + `read_texture_rgba`'s fixed 4-bytes-per-pixel copy).
640        // Desktop wgpu surfaces are always one of these four; a packed
641        // (Rgb10a2) or wide (Rgba16Float) surface format would read back
642        // garbage, so flag it loudly in debug builds.
643        debug_assert!(
644            matches!(
645                format,
646                wgpu::TextureFormat::Rgba8Unorm
647                    | wgpu::TextureFormat::Rgba8UnormSrgb
648                    | wgpu::TextureFormat::Bgra8Unorm
649                    | wgpu::TextureFormat::Bgra8UnormSrgb
650            ),
651            "capture_offscreen: unsupported surface format {format:?} (expected 8-bit RGBA/BGRA)"
652        );
653        let texture = self
654            .renderer
655            .device()
656            .create_texture(&wgpu::TextureDescriptor {
657                label: Some("teksilo-automation capture"),
658                size: wgpu::Extent3d {
659                    width: w,
660                    height: h,
661                    depth_or_array_layers: 1,
662                },
663                mip_level_count: 1,
664                sample_count: 1,
665                dimension: wgpu::TextureDimension::D2,
666                format,
667                usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
668                view_formats: &[],
669            });
670        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
671        self.renderer
672            .render(frame, &view, self.scale_factor as f32, w, h, clear_color);
673        let mut bytes = teksilo_render::test_support::read_texture_rgba(
674            self.renderer.device(),
675            self.renderer.queue(),
676            &texture,
677            w,
678            h,
679        );
680        // `read_texture_rgba` copies raw channel bytes; a BGRA surface
681        // needs its B/R swapped to become RGBA for PNG encoding.
682        if matches!(
683            format,
684            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
685        ) {
686            for px in bytes.as_chunks_mut::<4>().0 {
687                px.swap(0, 2);
688            }
689        }
690        match crop {
691            Some(rect) => crop_rgba(&bytes, w, h, rect),
692            None => (bytes, w, h),
693        }
694    }
695
696    pub fn request_redraw(&self) {
697        self.window.request_redraw();
698    }
699
700    /// Push an AccessKit TreeUpdate to the adapter (called after layout).
701    /// Publish a freshly built `TreeUpdate` to the adapter, and leave a copy
702    /// where the activation handler can find it.
703    ///
704    /// The copy is what lets an assistive technology that attaches to an *idle*
705    /// window see the application instead of an empty window node: the handler
706    /// runs off the UI thread and cannot build a tree, so the last one the UI
707    /// thread built is the best answer available synchronously.
708    pub fn update_accessibility(&mut self, update: accesskit::TreeUpdate) {
709        self.a11y_bridge.publish(&update);
710        if let Some(adapter) = &mut self.a11y_adapter {
711            adapter.update_if_active(|| update);
712        }
713    }
714
715    /// Push an update the adapter builds only when it is actually going to
716    /// be delivered, and only when `build` says there is one worth sending.
717    ///
718    /// The caller decides *inside* the closure, because that is where the
719    /// decision belongs: `update_if_active` runs its closure only when an
720    /// assistive technology is attached, and on Linux it runs it under the
721    /// adapter's own state lock. Deciding outside would build a tree for
722    /// nobody on every frame, and would make the throttle count frames
723    /// nothing was listening to.
724    ///
725    /// `build` returning `None` means "nothing to deliver"; the previously
726    /// delivered tree is re-sent, which the consumer treats as a no-op.
727    pub fn update_accessibility_with(
728        &mut self,
729        build: impl FnOnce() -> Option<accesskit::TreeUpdate>,
730        previous: impl FnOnce() -> accesskit::TreeUpdate,
731    ) {
732        if let Some(adapter) = &mut self.a11y_adapter {
733            adapter.update_if_active(|| build().unwrap_or_else(previous));
734        }
735    }
736
737    /// Whether an assistive technology has asked this window for its tree
738    /// and has not yet been given a full one.
739    ///
740    /// Set by the activation handler, which runs on whichever thread the
741    /// platform's accessibility layer calls it from, and cleared by the
742    /// first delivery after it — so a reader that attaches mid-session gets
743    /// a complete tree rather than a geometry patch onto a tree it has
744    /// never seen.
745    pub fn accessibility_needs_full_tree(&self) -> bool {
746        self.a11y_needs_full_tree.load(Ordering::Relaxed)
747    }
748
749    /// Clear the flag above, reporting what it was.
750    pub fn take_accessibility_needs_full_tree(&self) -> bool {
751        self.a11y_needs_full_tree.swap(false, Ordering::Relaxed)
752    }
753
754    /// Whether an AccessKit client is attached to this window's adapter.
755    ///
756    /// True from the moment the platform accessibility stack asks for an
757    /// initial tree until it says it has gone away. Read once per frame by
758    /// `teksilo-app` and pushed into the window's tree; see
759    /// [`WidgetTree::set_at_client_attached`](teksilo_core::WidgetTree::set_at_client_attached)
760    /// for why attaching and detaching are read asymmetrically.
761    ///
762    /// Always `false` for a window built without an adapter
763    /// ([`PlatformWindow::new`]).
764    pub fn accessibility_active(&self) -> bool {
765        self.a11y_bridge.is_active()
766    }
767
768    /// Forward a winit WindowEvent to the AccessKit adapter.
769    pub fn process_accessibility_event(&mut self, event: &WindowEvent) {
770        if let Some(adapter) = &mut self.a11y_adapter {
771            adapter.process_event(&self.window, event);
772        }
773    }
774
775    /// Drain any pending AccessKit action requests from the adapter.
776    pub fn drain_accessibility_actions(&self) -> Vec<ActionRequest> {
777        let mut actions = Vec::new();
778        while let Ok(req) = self.a11y_action_rx.try_recv() {
779            actions.push(req);
780        }
781        actions
782    }
783}
784
785// --- AccessKit handler implementations ---
786
787/// Activation handler — answers with the last tree the UI thread built.
788///
789/// An assistive technology attaching to a window that is sitting idle used to
790/// be shown a bare `Role::Window` node with no children, and stayed shown it
791/// until something unrelated caused a frame. Answering from the published
792/// snapshot fixes the common case; the redraw request covers the rest, since
793/// the adapter is active from here on and the next
794/// [`PlatformWindow::update_accessibility`] reaches it.
795/// `needs_full_tree` is what makes the delivery that follows a *full*
796/// tree rather than a geometry patch: updates are otherwise throttled to
797/// the moves-only rate, and a reader that attaches mid-session has never
798/// seen the tree such a patch would be applied to.
799struct TeksiloActivationHandler {
800    needs_full_tree: Arc<AtomicBool>,
801    bridge: Arc<AccessibilityBridge>,
802    window: Arc<Window>,
803}
804
805/// The tree handed to a client that attached before this window ever drew.
806///
807/// A window node with no children — the same placeholder as before — because
808/// there is genuinely nothing else to say yet. The accompanying redraw request
809/// is what makes it short-lived.
810fn empty_initial_tree() -> accesskit::TreeUpdate {
811    let root = accesskit::Node::new(accesskit::Role::Window);
812    let root_id = teksilo_core::accessibility::root_node_id();
813    accesskit::TreeUpdate {
814        nodes: vec![(root_id, root)],
815        tree: Some(accesskit::TreeInfo::new(root_id)),
816        tree_id: accesskit::TreeId::ROOT,
817        focus: root_id,
818    }
819}
820
821impl accesskit::ActivationHandler for TeksiloActivationHandler {
822    fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
823        self.needs_full_tree.store(true, Ordering::Relaxed);
824        let update = self.bridge.on_activate();
825        // Whether or not we could answer with a real tree, ask for a frame: it
826        // is what carries the *next* update to the now-active adapter, and it
827        // is also how the UI thread learns that a client attached.
828        self.window.request_redraw();
829        Some(update)
830    }
831}
832
833/// Action handler — forwards action requests to the main thread via a channel,
834/// then wakes the loop so the channel is actually drained.
835struct TeksiloActionHandler {
836    tx: mpsc::Sender<ActionRequest>,
837    window: Arc<Window>,
838}
839
840impl accesskit::ActionHandler for TeksiloActionHandler {
841    fn do_action(&mut self, request: ActionRequest) {
842        let _ = self.tx.send(request);
843        self.window.request_redraw();
844    }
845}
846
847/// Deactivation handler — records that the last client detached.
848///
849/// Unlike activation, this *is* evidence about screen readers: when no client
850/// is attached, none of them is reading the tree either.
851struct TeksiloDeactivationHandler {
852    bridge: Arc<AccessibilityBridge>,
853    window: Arc<Window>,
854}
855
856impl accesskit::DeactivationHandler for TeksiloDeactivationHandler {
857    fn deactivate_accessibility(&mut self) {
858        self.bridge.on_deactivate();
859        // The UI thread reads the flag once per frame, so it needs a frame.
860        self.window.request_redraw();
861    }
862}
863
864#[cfg(test)]
865mod accessibility_bridge_tests {
866    use super::{AccessibilityBridge, empty_initial_tree};
867
868    /// A recognisable tree that is not the placeholder.
869    fn published_tree() -> accesskit::TreeUpdate {
870        let root_id = teksilo_core::accessibility::root_node_id();
871        let child_id = accesskit::NodeId(4242);
872        let mut root = accesskit::Node::new(accesskit::Role::Window);
873        root.push_child(child_id);
874        let mut child = accesskit::Node::new(accesskit::Role::Button);
875        child.set_label("Save");
876        accesskit::TreeUpdate {
877            nodes: vec![(root_id, root), (child_id, child)],
878            tree: Some(accesskit::TreeInfo::new(root_id)),
879            tree_id: accesskit::TreeId::ROOT,
880            focus: root_id,
881        }
882    }
883
884    #[test]
885    fn a_fresh_bridge_reports_no_client() {
886        assert!(!AccessibilityBridge::default().is_active());
887    }
888
889    #[test]
890    fn activation_before_the_first_frame_answers_with_the_placeholder() {
891        let bridge = AccessibilityBridge::default();
892        let update = bridge.on_activate();
893        assert_eq!(update.nodes.len(), empty_initial_tree().nodes.len());
894        assert_eq!(update.nodes[0].1.children().len(), 0);
895        assert!(bridge.is_active());
896    }
897
898    #[test]
899    fn activation_after_a_frame_answers_with_the_real_tree() {
900        // The defect this pins: an assistive technology attaching to an idle
901        // window was shown a childless window node and nothing scheduled a
902        // frame to replace it.
903        let bridge = AccessibilityBridge::default();
904        bridge.publish(&published_tree());
905        let update = bridge.on_activate();
906        assert_eq!(
907            update.nodes.len(),
908            2,
909            "the published tree, not a placeholder"
910        );
911        assert_eq!(update.nodes[0].1.children().len(), 1);
912    }
913
914    #[test]
915    fn the_snapshot_is_the_latest_published_tree() {
916        let bridge = AccessibilityBridge::default();
917        bridge.publish(&empty_initial_tree());
918        bridge.publish(&published_tree());
919        assert_eq!(bridge.on_activate().nodes.len(), 2);
920    }
921
922    #[test]
923    fn publishing_while_a_client_is_attached_is_skipped() {
924        // Not a behaviour change anyone can observe through `on_activate` —
925        // an attached client cannot ask for an initial tree — but it is what
926        // keeps a per-frame `TreeUpdate` clone off the frame path while a
927        // screen reader is running.
928        let bridge = AccessibilityBridge::default();
929        bridge.publish(&published_tree());
930        let _ = bridge.on_activate();
931        bridge.publish(&empty_initial_tree());
932        bridge.on_deactivate();
933        assert_eq!(
934            bridge.on_activate().nodes.len(),
935            2,
936            "the tree published while attached must not have replaced the snapshot"
937        );
938    }
939
940    #[test]
941    fn deactivation_clears_the_attached_flag() {
942        let bridge = AccessibilityBridge::default();
943        let _ = bridge.on_activate();
944        assert!(bridge.is_active());
945        bridge.on_deactivate();
946        assert!(!bridge.is_active());
947        // And the tree it published is still there for a client that comes back.
948        bridge.publish(&published_tree());
949        assert_eq!(bridge.on_activate().nodes.len(), 2);
950        assert!(bridge.is_active());
951    }
952}
953
954#[cfg(test)]
955mod device_limits_tests {
956    use super::*;
957
958    /// A Raspberry Pi 4's V3D driver in the fields that matter here: four
959    /// colour attachments and 4096-pixel textures. This is the adapter the
960    /// crash report came from.
961    fn pi4_class_limits() -> wgpu::Limits {
962        wgpu::Limits {
963            max_texture_dimension_1d: 4096,
964            max_texture_dimension_2d: 4096,
965            max_texture_dimension_3d: 256,
966            max_color_attachments: 4,
967            ..wgpu::Limits::downlevel_defaults()
968        }
969    }
970
971    #[test]
972    fn the_default_limits_are_refused_by_gles_class_hardware() {
973        // The bug, stated as a test: this is what the window used to ask for,
974        // and `check_limits` is the same comparison wgpu makes inside
975        // `request_device`. If this ever starts passing, wgpu changed its
976        // defaults and the fallback below is what keeps us honest.
977        assert!(
978            !wgpu::Limits::default().check_limits(&pi4_class_limits()),
979            "the wgpu default limits are supposed to over-ask for a Pi-4 class \
980             adapter; that refusal is the crash this module exists to prevent"
981        );
982    }
983
984    #[test]
985    fn the_window_ask_is_satisfiable_on_gles_class_hardware() {
986        let adapter = pi4_class_limits();
987        assert!(
988            window_device_limits(adapter.clone()).check_limits(&adapter),
989            "a Pi-4 class adapter must be able to grant what a window asks for"
990        );
991    }
992
993    #[test]
994    fn the_window_never_asks_past_the_downlevel_floor() {
995        // The regression pin: whatever the adapter offers, every limit that is
996        // not a texture dimension stays at the GLES-3.1 floor. Re-introducing
997        // `Limits::default()` fails here on a developer's desktop rather than
998        // only on a reviewer's Raspberry Pi.
999        let generous = wgpu::Limits::default();
1000        let asked = window_device_limits(generous.clone());
1001        let floor = wgpu::Limits::downlevel_defaults();
1002
1003        assert_eq!(asked.max_color_attachments, floor.max_color_attachments);
1004        assert_eq!(
1005            asked.max_uniform_buffer_binding_size,
1006            floor.max_uniform_buffer_binding_size
1007        );
1008        assert_eq!(
1009            asked.max_inter_stage_shader_variables,
1010            floor.max_inter_stage_shader_variables
1011        );
1012        assert_eq!(
1013            asked.max_storage_buffers_per_shader_stage,
1014            floor.max_storage_buffers_per_shader_stage
1015        );
1016        assert_ne!(
1017            asked, generous,
1018            "asking for the full default set is exactly the regression"
1019        );
1020    }
1021
1022    #[test]
1023    fn texture_dimensions_follow_the_adapter() {
1024        // `downlevel_defaults` caps 2D textures at 2048 and the path atlas
1025        // grows to 4096, so the resolution limits, and only those, are lifted
1026        // to whatever the adapter really offers.
1027        const PATH_ATLAS_MAX: u32 = 4096;
1028
1029        for adapter in [pi4_class_limits(), wgpu::Limits::default()] {
1030            let asked = window_device_limits(adapter.clone());
1031            assert_eq!(
1032                asked.max_texture_dimension_1d,
1033                adapter.max_texture_dimension_1d
1034            );
1035            assert_eq!(
1036                asked.max_texture_dimension_2d,
1037                adapter.max_texture_dimension_2d
1038            );
1039            assert_eq!(
1040                asked.max_texture_dimension_3d,
1041                adapter.max_texture_dimension_3d
1042            );
1043            assert!(
1044                asked.max_texture_dimension_2d >= PATH_ATLAS_MAX,
1045                "the path atlas grows to {PATH_ATLAS_MAX}; a device that cannot \
1046                 hold it would fail on a path-heavy frame instead of at startup"
1047            );
1048        }
1049    }
1050
1051    #[test]
1052    fn the_floor_still_covers_what_the_renderer_binds() {
1053        // What the renderer actually needs, so that lowering the ask further
1054        // fails here rather than in a frame. 128 animation slots of 64 bytes
1055        // is the largest uniform binding; every render pass has exactly one
1056        // colour attachment.
1057        const ANIM_UNIFORM_BYTES: u64 = 128 * 64;
1058        let asked = window_device_limits(pi4_class_limits());
1059
1060        assert!(asked.max_color_attachments >= 1);
1061        assert!(asked.max_uniform_buffer_binding_size >= ANIM_UNIFORM_BYTES);
1062    }
1063}