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/// Why a window could not configure its swapchain.
19///
20/// `Surface::configure` reports nothing: it hands its error to wgpu's
21/// uncaptured-error handler, whose default is to panic. Every configure in
22/// this file goes through [`configure_surface`] instead, which catches the
23/// error and sorts it into one of these two.
24///
25/// Crate-internal: what a caller outside acts on is
26/// [`FrameOutcome::DisplayLost`] and [`PlatformWindow::display_lost`], not the
27/// classification behind them.
28#[derive(Debug, thiserror::Error)]
29pub(crate) enum SurfaceConfigureError {
30    /// The surface can no longer answer for its adapter. On every backend
31    /// that means the connection to the display server is gone: the
32    /// compositor exited or crashed, or the GPU was reset under it.
33    ///
34    /// wgpu's own wording for this is `Surface does not support the
35    /// adapter's queue family`, which reads like a hardware mismatch and has
36    /// been reported as one. It is not. The adapter was chosen with
37    /// `compatible_surface`, and `request_adapter` filters on the very query
38    /// that fails here (`wgpu_core::instance`), so it answered yes to the
39    /// same question moments earlier. What changed is the surface, not the
40    /// adapter.
41    #[error(
42        "the display server connection is gone: the window surface now reports no \
43         supported formats for an adapter that was selected for it"
44    )]
45    DisplayLost,
46    /// wgpu refused the configuration for some other reason, carrying its own
47    /// message so a genuine mistake is not relabelled as a dead compositor.
48    #[error("wgpu refused the surface configuration: {0}")]
49    Rejected(String),
50}
51
52/// Outcome of [`PlatformWindow::render_frame`]. Mirrors the wgpu
53/// surface-status cases that matter to the caller so the app loop can
54/// decide how to respond (ignore, reconfigure, log) without every frame
55/// getting logged as an error.
56#[derive(Debug)]
57pub enum FrameOutcome {
58    /// Frame was rendered and presented.
59    Rendered,
60    /// wgpu reported the window as occluded or the acquire timed out.
61    /// Per wgpu guidance, skip this frame. On macOS, the initial paint
62    /// after window creation often hits `Occluded` one or more times
63    /// before Metal finishes compositing, so the caller should still
64    /// request another redraw once — unless it already knows the
65    /// window is occluded via `WindowEvent::Occluded(true)`.
66    Skipped,
67    /// Surface became outdated (resize, scale change, device switch).
68    /// Caller should reconfigure the surface and try again.
69    NeedsReconfigure,
70    /// The display server is gone, so this window can never present again.
71    /// Caller should wind the application down. It must not reconfigure or
72    /// ask for another redraw: both come straight back here, and that spin
73    /// is the whole reason this is its own outcome rather than an `Error`.
74    DisplayLost,
75    /// Acquisition failed with a non-transient error.
76    Error(SurfaceRenderError),
77}
78
79/// A platform window wrapping a winit window, wgpu surface, renderer,
80/// and AccessKit adapter for screen reader support.
81pub struct PlatformWindow {
82    window: Arc<Window>,
83    surface: wgpu::Surface<'static>,
84    surface_config: wgpu::SurfaceConfiguration,
85    /// The adapter this surface was matched against. Kept so that a configure
86    /// failure can ask the surface whether it still has formats for it, which
87    /// is how [`classify_configure_failure`] tells a departed display server
88    /// from a configuration wgpu genuinely refused.
89    adapter: wgpu::Adapter,
90    /// Latched the first time the display server is found to be gone. Every
91    /// later configure and every frame is then skipped: with no compositor
92    /// there is nothing to present to, and retrying only spins the loop.
93    display_lost: bool,
94    renderer: Renderer,
95    scale_factor: f64,
96    a11y_adapter: Option<accesskit_winit::Adapter>,
97    /// Set by the activation handler when an assistive technology asks for
98    /// the tree; cleared by the first delivery after it. Shared because the
99    /// handler may run off the main thread.
100    a11y_needs_full_tree: Arc<AtomicBool>,
101    /// Receiver for accessibility action requests from the adapter.
102    a11y_action_rx: mpsc::Receiver<ActionRequest>,
103    /// The accessibility state the adapter's off-thread handlers share with
104    /// the UI thread. See [`AccessibilityBridge`].
105    a11y_bridge: Arc<AccessibilityBridge>,
106}
107
108/// The state an AccessKit adapter's handlers share with the UI thread.
109///
110/// `accesskit_winit::Adapter::with_direct_handlers` requires every handler to
111/// be `Send` and calls it from whatever thread the platform's accessibility
112/// stack happens to use — the UIA provider thread on Windows, an AT-SPI task on
113/// Linux. A [`teksilo_core::WidgetTree`] is `!Send`, so no handler can reach
114/// one. Everything they need to say to the UI thread therefore goes through
115/// this, and everything they need to read from it is a snapshot the UI thread
116/// leaves here.
117/// The whole policy lives here rather than in the three handler types,
118/// because a handler owns an `Arc<Window>` and so cannot be built in a test
119/// without an event loop, while this can.
120#[derive(Debug, Default)]
121pub(crate) struct AccessibilityBridge {
122    /// The most recent `TreeUpdate` the UI thread published, kept so that
123    /// `request_initial_tree` can answer with the real tree instead of a
124    /// placeholder. `None` before the first frame.
125    snapshot: Mutex<Option<accesskit::TreeUpdate>>,
126    /// Whether an AccessKit client is attached right now. Set on activation,
127    /// cleared on deactivation.
128    active: std::sync::atomic::AtomicBool,
129}
130
131impl AccessibilityBridge {
132    /// Leave a tree where the activation handler can find it. Called from the
133    /// UI thread on every published update.
134    ///
135    /// A no-op while a client is attached, and that is the point: the snapshot
136    /// is read by `request_initial_tree` alone, which by definition runs while
137    /// nothing is attached — an attached client already has the live tree
138    /// through `update_if_active`. Skipping the clone there keeps the cost off
139    /// the frame path exactly when a screen reader is running and frames matter
140    /// most. The window between a detach and the next frame leaves the snapshot
141    /// one frame stale, which is a frame-old application rather than an empty
142    /// one; the deactivation handler asks for that frame.
143    pub(crate) fn publish(&self, update: &accesskit::TreeUpdate) {
144        if self.is_active() {
145            return;
146        }
147        if let Ok(mut slot) = self.snapshot.lock() {
148            *slot = Some(update.clone());
149        }
150    }
151
152    /// A client attached: record it and answer with the best tree available.
153    ///
154    /// The last published one if there is one — an assistive technology
155    /// attaching to an idle window must not be shown an empty application —
156    /// and the bare window node only before this window has ever drawn.
157    pub(crate) fn on_activate(&self) -> accesskit::TreeUpdate {
158        self.active
159            .store(true, std::sync::atomic::Ordering::Relaxed);
160        self.snapshot
161            .lock()
162            .ok()
163            .and_then(|slot| slot.clone())
164            .unwrap_or_else(empty_initial_tree)
165    }
166
167    /// The last client detached.
168    pub(crate) fn on_deactivate(&self) {
169        self.active
170            .store(false, std::sync::atomic::Ordering::Relaxed);
171    }
172
173    /// Whether a client is attached right now.
174    pub(crate) fn is_active(&self) -> bool {
175        self.active.load(std::sync::atomic::Ordering::Relaxed)
176    }
177}
178
179/// The wgpu objects every window in the process shares.
180///
181/// All three are `Arc` handles internally, so cloning one is a refcount bump,
182/// not a second GPU object.
183#[derive(Clone)]
184struct SharedGpu {
185    adapter: wgpu::Adapter,
186    device: wgpu::Device,
187    queue: wgpu::Queue,
188}
189
190/// The platform display connection the wgpu instance is built against.
191///
192/// Installed by the app layer via [`install_display_handle`] before the first
193/// window exists, and read once by [`shared_instance`].
194static DISPLAY_HANDLE: OnceLock<winit::event_loop::OwnedDisplayHandle> = OnceLock::new();
195
196/// Hand wgpu the platform display connection, before any window is created.
197///
198/// Load-bearing for the OpenGL backend, which is the only backend a machine
199/// with no Vulkan driver has left — an older GPU, or a VM whose guest driver
200/// stops at GL. Without a display handle, wgpu-hal's GLES backend has no
201/// windowing system to bind EGL to and falls back to
202/// `EGL_MESA_platform_surfaceless`: a display that can render offscreen but can
203/// never be compatible with a *window* surface. `request_adapter` then rejects
204/// the only adapter on the machine with `incompatible_surface_backends: GL`,
205/// and the process dies before its first window. Vulkan, Metal and D3D12 ignore
206/// the handle entirely, so this costs those paths nothing.
207///
208/// Only the first call counts; later ones are ignored, because the instance is
209/// built once per process and wgpu forbids presenting a surface from a display
210/// other than the one the instance was created with.
211pub fn install_display_handle(handle: winit::event_loop::OwnedDisplayHandle) {
212    let _ = DISPLAY_HANDLE.set(handle);
213}
214
215/// The one wgpu instance for this process.
216///
217/// A surface has to come from the same instance that later enumerates adapters
218/// for it, so this is the root every window hangs off. `Instance::new` is
219/// synchronous, which is why this one can be a plain `OnceLock` while the
220/// adapter and device below cannot.
221///
222/// The descriptor is built `_from_env`, so wgpu's own variables —
223/// `WGPU_BACKEND`, `WGPU_GLES_MINOR_VERSION` and the rest — work here as they
224/// do in every other wgpu application. That is the escape hatch for the machine
225/// whose preferred backend has a broken driver, and it is worth having
226/// precisely where the default choice is the thing under suspicion.
227///
228/// Its flags come from [`teksilo_render::instance_flags`] rather than from
229/// `_from_env` alone, so this instance and the offscreen one
230/// [`teksilo_render::test_support`] opens agree about them.
231fn shared_instance() -> &'static wgpu::Instance {
232    static INSTANCE: OnceLock<wgpu::Instance> = OnceLock::new();
233    INSTANCE.get_or_init(|| {
234        let mut descriptor = match DISPLAY_HANDLE.get() {
235            Some(display) => wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(
236                display.clone(),
237            )),
238            // No app layer installed one — an embedder driving `PlatformWindow`
239            // itself, or a test. Offscreen work is unaffected; only a GL-backed
240            // window needs the handle.
241            None => wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
242        };
243        descriptor.flags = teksilo_render::instance_flags();
244        wgpu::Instance::new(descriptor)
245    })
246}
247
248/// The adapter, device and queue every window shares.
249///
250/// One device per process, not one per window. A device is a heavyweight,
251/// process-level object and a second one buys nothing: each window still needs
252/// its own surface and its own [`Renderer`] (that is where the glyph and path
253/// atlases live), but the driver objects underneath are the same for every
254/// window on the same adapter. Opening one per window duplicated the entire
255/// pipeline set and both atlas textures for every window a user opened.
256///
257/// It also closes a latent crash. Two D3D12 **WARP** devices rasterizing at the
258/// same time fault inside `d3d10warp.dll` — Microsoft's software rasterizer,
259/// and what a GPU-less Windows host actually draws with. Teksilo renders its
260/// windows sequentially on the winit main thread, so that was not reachable
261/// here; it would have become reachable the moment any window work moved off
262/// that thread. `teksilo_render::test_support` shares its offscreen device for
263/// the same reason, where it *was* reachable and did crash.
264///
265/// `surface` is used only to pick an adapter that can actually present to it.
266/// If a later window's surface turns out to be incompatible with the adapter we
267/// cached — a genuinely multi-GPU machine, where the second window opens on the
268/// other GPU — that window quietly gets its own device rather than failing.
269/// The limits a live window asks its device for.
270///
271/// Deliberately **not** [`wgpu::Limits::default`]. That set demands eight
272/// colour attachments, 64 KiB uniform bindings and 8192-pixel textures. This
273/// renderer draws every pass into a *single* colour attachment, binds at most
274/// 8 KiB of uniforms (128 animation slots of 64 bytes) and caps its path atlas
275/// at 4096 pixels. The headroom was inherited from the default, never needed.
276///
277/// On GLES-3.1 class hardware that headroom is not merely unused, it is
278/// refused: a Raspberry Pi 4's V3D driver allows four colour attachments, so
279/// `default()` failed device creation outright and the app could not open a
280/// window at all.
281///
282/// `downlevel_defaults` is wgpu's GLES-3.1 floor, which is exactly that class
283/// of hardware, and it is already what [`teksilo_render::test_support`] opens
284/// its offscreen device with, so a frame that renders in a test now renders in
285/// a window too. `using_resolution` lifts the three texture-dimension limits
286/// back to whatever this adapter really supports, because the path atlas grows
287/// past the 2048-pixel downlevel cap.
288fn window_device_limits(adapter_limits: wgpu::Limits) -> wgpu::Limits {
289    wgpu::Limits::downlevel_defaults().using_resolution(adapter_limits)
290}
291
292/// Open a device on `adapter`, preferring [`window_device_limits`] and falling
293/// back to whatever the adapter itself reports.
294///
295/// The fallback is not redundant. `downlevel_defaults` is a floor for a *class*
296/// of hardware, not a promise about any given adapter. Anything below GLES 3.1
297/// (an old GL driver, a constrained software rasterizer) can sit under it on a
298/// field `using_resolution` does not lift, and then the principled ask fails
299/// for the same reason `default()` did on the Pi. `adapter.limits()` is by
300/// construction the most that adapter can give, so it cannot be refused on
301/// limit grounds; a request that still fails has a real problem rather than a
302/// mis-sized ask, and that is the error worth propagating.
303async fn open_device(
304    adapter: &wgpu::Adapter,
305) -> Result<(wgpu::Device, wgpu::Queue), wgpu::RequestDeviceError> {
306    let descriptor = |limits| wgpu::DeviceDescriptor {
307        label: Some("teksilo_device"),
308        required_features: wgpu::Features::empty(),
309        required_limits: limits,
310        ..Default::default()
311    };
312
313    match adapter
314        .request_device(&descriptor(window_device_limits(adapter.limits())))
315        .await
316    {
317        Ok(pair) => Ok(pair),
318        Err(err) => {
319            // Say why we dropped to the adapter's own limits: a silent
320            // fallback turns "this GPU is below the GLES-3.1 floor" into an
321            // unexplained difference in behaviour between two machines.
322            eprintln!(
323                "teksilo-platform: downlevel device limits refused ({err}); \
324                 retrying with the adapter's own limits"
325            );
326            adapter.request_device(&descriptor(adapter.limits())).await
327        }
328    }
329}
330
331/// The backends this platform prefers, most preferred first.
332///
333/// wgpu does not rank backends. `Instance::new` initialises them in a fixed
334/// order — Vulkan, Metal, D3D12, GLES — and `request_adapter` then sorts the
335/// adapters it collected **only** by device type, and only when a power
336/// preference is set. Ours is `PowerPreference::None` unless `WGPU_POWER_PREF`
337/// says otherwise, which is wgpu's own default and sorts nothing at all. So the
338/// winner has been "the first adapter the first initialised backend
339/// enumerated", which on Windows means D3D12 was never reached as long as any
340/// Vulkan ICD was installed, however old.
341///
342/// That is the wrong default there. D3D12 is the backend Windows GPU drivers
343/// are tested against hardest — it is what the browsers use on Windows — while
344/// Vulkan support on older Windows hardware ranges from good to a stub that
345/// enumerates an adapter it cannot really drive. The field report that prompted
346/// this is one of those: a Windows 10 machine whose Vulkan ICD cannot build
347/// wgpu's own indirect-validation pipelines (see
348/// [`teksilo_render::instance_flags`]) and, past that, cannot present at all,
349/// while D3D12 on the same machine works.
350///
351/// Elsewhere the order simply writes down what wgpu already did, so this is a
352/// change of behaviour on Windows only. An explicit `WGPU_BACKEND` still wins:
353/// it is applied at `Instance::new`, so the backends it excludes enumerate
354/// nothing here and this order silently narrows to the one that was asked for.
355fn preferred_backends() -> &'static [wgpu::Backends] {
356    #[cfg(target_os = "windows")]
357    {
358        &[
359            wgpu::Backends::DX12,
360            wgpu::Backends::VULKAN,
361            wgpu::Backends::GL,
362        ]
363    }
364    #[cfg(any(target_os = "macos", target_os = "ios"))]
365    {
366        &[
367            wgpu::Backends::METAL,
368            wgpu::Backends::VULKAN,
369            wgpu::Backends::GL,
370        ]
371    }
372    #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "ios")))]
373    {
374        &[wgpu::Backends::VULKAN, wgpu::Backends::GL]
375    }
376}
377
378/// The adapters on `backends` that can present to `surface`, ranked as
379/// `request_adapter` would rank them.
380///
381/// `enumerate_adapters` asks each backend for its adapters with no surface, so
382/// the "can this one actually present to this window" filter that
383/// `request_adapter` applies internally has to be applied here instead. A
384/// non-empty format list is wgpu's own answer to that question, and it is the
385/// same one [`shared_gpu_for`] uses to revalidate the cached adapter.
386///
387/// The ordering within a backend deliberately mirrors
388/// `wgpu_core::instance::request_adapter`: rank by device type under a power
389/// preference, and leave enumeration order alone under `None`. Keeping the two
390/// identical means this pass changes *which backend* is tried first and nothing
391/// else about how an adapter is chosen.
392async fn presentable_adapters(
393    surface: &wgpu::Surface<'static>,
394    backends: wgpu::Backends,
395    power_preference: wgpu::PowerPreference,
396) -> Vec<wgpu::Adapter> {
397    let mut adapters: Vec<wgpu::Adapter> = shared_instance()
398        .enumerate_adapters(backends)
399        .await
400        .into_iter()
401        .filter(|adapter| !surface.get_capabilities(adapter).formats.is_empty())
402        .collect();
403
404    let prefer_integrated = match power_preference {
405        wgpu::PowerPreference::LowPower => true,
406        wgpu::PowerPreference::HighPerformance => false,
407        // wgpu does not sort at all here, so neither do we.
408        _ => return adapters,
409    };
410    adapters
411        .sort_by_key(|adapter| device_type_rank(adapter.get_info().device_type, prefer_integrated));
412    adapters
413}
414
415/// `wgpu_core::instance::request_adapter`'s `get_order`, kept in step with it.
416///
417/// "Other" outranks the virtual and CPU types because a backend that does not
418/// report device types at all (OpenGL) lands there, and it is likelier to be
419/// real hardware than a software rasterizer is.
420fn device_type_rank(device_type: wgpu::DeviceType, prefer_integrated: bool) -> u8 {
421    match device_type {
422        wgpu::DeviceType::DiscreteGpu if prefer_integrated => 2,
423        wgpu::DeviceType::IntegratedGpu if prefer_integrated => 1,
424        wgpu::DeviceType::DiscreteGpu => 1,
425        wgpu::DeviceType::IntegratedGpu => 2,
426        wgpu::DeviceType::Other => 3,
427        wgpu::DeviceType::VirtualGpu => 4,
428        wgpu::DeviceType::Cpu => 5,
429    }
430}
431
432/// Find an adapter that can present to `surface` *and* yields a device.
433///
434/// Adapter selection is a search, not a single request — the same lesson
435/// [`teksilo_render::test_support`] already encodes for its offscreen device,
436/// which the window path did not have. A host can enumerate an adapter it
437/// cannot actually open (a VM's GL driver is the usual one) while a perfectly
438/// good software adapter sits behind `force_fallback_adapter`. Treating the
439/// first failure as fatal reports "no GPU" on a machine that has one.
440///
441/// Every pass filters on surface compatibility, so an adapter that cannot
442/// present to this window is never chosen — that is the check that failed on a
443/// machine with no Vulkan driver, and it is load-bearing, not a formality. The
444/// ordered pass tests it with [`presentable_adapters`], the fallback pass with
445/// `request_adapter`'s own `compatible_surface`; both ask wgpu the same
446/// question.
447///
448/// Panics only when *every* adapter on the machine declines, with a message
449/// naming what was tried and what the user can do about it.
450async fn open_gpu_for(
451    surface: &wgpu::Surface<'static>,
452) -> (wgpu::Adapter, wgpu::Device, wgpu::Queue) {
453    // `WGPU_POWER_PREF` is wgpu's own knob; honour it for the same reason the
454    // instance is built `_from_env`.
455    let power_preference = wgpu::PowerPreference::from_env().unwrap_or_default();
456    let mut adapter_error = None;
457    let mut device_error = None;
458
459    // First pass: this platform's own backend order (see
460    // `preferred_backends`). `request_adapter` cannot express "try D3D12
461    // before Vulkan" — its options carry no backend field — so the ordering
462    // has to be done by enumerating one backend at a time.
463    for &backends in preferred_backends() {
464        for adapter in presentable_adapters(surface, backends, power_preference).await {
465            match open_device(&adapter).await {
466                Ok((device, queue)) => return (adapter, device, queue),
467                Err(err) => {
468                    eprintln!(
469                        "teksilo-platform: adapter {:?} could not open a device ({err}); \
470                         trying the next one",
471                        adapter.get_info().name
472                    );
473                    device_error.get_or_insert(err);
474                }
475            }
476        }
477    }
478
479    // Second pass: whatever wgpu itself would have picked, then an explicit
480    // software adapter. The first arm is not redundant with the loop above —
481    // it reaches any backend `preferred_backends` does not name — and the
482    // second is the only way to ask for a CPU adapter, which
483    // `enumerate_adapters` cannot express.
484    for force_fallback_adapter in [false, true] {
485        let adapter = match shared_instance()
486            .request_adapter(&wgpu::RequestAdapterOptions {
487                power_preference,
488                compatible_surface: Some(surface),
489                force_fallback_adapter,
490                ..Default::default()
491            })
492            .await
493        {
494            Ok(adapter) => adapter,
495            Err(err) => {
496                adapter_error.get_or_insert(err);
497                continue;
498            }
499        };
500
501        match open_device(&adapter).await {
502            Ok((device, queue)) => return (adapter, device, queue),
503            Err(err) => {
504                // Worth saying out loud: the next pass silently landing on a
505                // software adapter is a large performance difference, and an
506                // unexplained one is the sort of thing that gets reported as
507                // "Teksilo is slow on my machine".
508                eprintln!(
509                    "teksilo-platform: adapter {:?} could not open a device ({err}); \
510                     trying the next one",
511                    adapter.get_info().name
512                );
513                device_error.get_or_insert(err);
514            }
515        }
516    }
517
518    panic!(
519        "no usable GPU adapter for this window.\n\
520         Tried every backend wgpu was built with, then an explicit software \
521         fallback; none could both present to the window and open a device.\n\
522         adapter search: {adapter_error:?}\n\
523         device open:    {device_error:?}\n\
524         Teksilo needs Vulkan, Metal, D3D12 or OpenGL (3.3 desktop / ES 3.0). \
525         On Linux, installing a Vulkan driver is usually the fix: \
526         `mesa-vulkan-drivers` carries both the hardware drivers and the \
527         software `lavapipe`. `WGPU_BACKEND=gl|vulkan|dx12|metal` forces a \
528         specific backend."
529    );
530}
531
532async fn shared_gpu_for(surface: &wgpu::Surface<'static>) -> SharedGpu {
533    static SHARED: Mutex<Option<SharedGpu>> = Mutex::new(None);
534
535    // Clone out and release the lock: it is never held across the awaits below.
536    let cached = SHARED.lock().unwrap_or_else(|e| e.into_inner()).clone();
537    if let Some(gpu) = cached {
538        // A non-empty format list is wgpu's own answer to "can this adapter
539        // present to this surface".
540        if !surface.get_capabilities(&gpu.adapter).formats.is_empty() {
541            return gpu;
542        }
543    }
544
545    let (adapter, device, queue) = open_gpu_for(surface).await;
546
547    let gpu = SharedGpu {
548        adapter,
549        device,
550        queue,
551    };
552    // First one in becomes the shared device. Losing here is the multi-GPU case
553    // above (or a race that cannot happen while windows are created on one
554    // thread): the loser keeps the device it just opened, which is the old
555    // per-window behaviour and still correct.
556    let mut slot = SHARED.lock().unwrap_or_else(|e| e.into_inner());
557    if slot.is_none() {
558        *slot = Some(gpu.clone());
559    }
560    gpu
561}
562
563/// Configure `surface`, handing back the failure instead of letting wgpu's
564/// default uncaptured-error handler panic.
565///
566/// Every `Surface::configure` in this file goes through here, and the error
567/// scope is what makes that sufficient. Asking the surface whether it is still
568/// alive and *then* configuring it leaves a gap between the two in which the
569/// compositor can exit, and that gap is the bug: `request_adapter` validated
570/// this adapter against this surface with the same query `configure` runs, so
571/// only a change in between can make the second one fail. A scope catches the
572/// error from this exact call, however late the display server goes away.
573///
574/// `pop` resolves immediately on native (wgpu answers with a ready future) and
575/// wgpu's error scopes are thread-local. Both hold because every window in this
576/// process is created and drawn on the winit main thread.
577fn configure_surface(
578    surface: &wgpu::Surface<'static>,
579    adapter: &wgpu::Adapter,
580    device: &wgpu::Device,
581    config: &wgpu::SurfaceConfiguration,
582) -> Result<(), SurfaceConfigureError> {
583    let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
584    surface.configure(device, config);
585    match pollster::block_on(scope.pop()) {
586        None => Ok(()),
587        Some(err) => Err(classify_configure_failure(
588            err.to_string(),
589            !surface.get_capabilities(adapter).formats.is_empty(),
590        )),
591    }
592}
593
594/// Sort a configure failure into "the display server left" and everything
595/// else, on the one signal that separates them.
596///
597/// A live surface offers its adapter a non-empty format list; a surface whose
598/// display server has gone offers none, and that transition is observable:
599/// seven formats before the compositor exits, zero after. Deliberately not a
600/// match on wgpu's message text, which is both misleading here and free to
601/// change between releases.
602fn classify_configure_failure(
603    description: String,
604    surface_has_formats: bool,
605) -> SurfaceConfigureError {
606    if surface_has_formats {
607        SurfaceConfigureError::Rejected(description)
608    } else {
609        SurfaceConfigureError::DisplayLost
610    }
611}
612
613/// What [`PlatformWindow::surface_and_renderer`] hands to both constructors.
614struct WindowGpu {
615    surface: wgpu::Surface<'static>,
616    surface_config: wgpu::SurfaceConfiguration,
617    renderer: Renderer,
618    adapter: wgpu::Adapter,
619    display_lost: bool,
620}
621
622impl PlatformWindow {
623    /// Everything both constructors do: surface, shared device, swapchain
624    /// configuration, renderer. Kept in one place because the two entry points
625    /// differ only in whether they attach an AccessKit adapter, and sixty
626    /// duplicated lines of GPU setup is exactly the sort of thing that drifts.
627    async fn surface_and_renderer(window: &Arc<Window>) -> WindowGpu {
628        let size = window.inner_size();
629        let surface = shared_instance()
630            .create_surface(window.clone())
631            .expect("wgpu surface creation failed for the platform window");
632
633        let gpu = shared_gpu_for(&surface).await;
634
635        let surface_caps = surface.get_capabilities(&gpu.adapter);
636        // Guard the index accesses: a degenerate adapter/surface (software
637        // fallback, headless) can report empty `formats` / `alpha_modes`, and
638        // `[0]` would panic with an opaque out-of-bounds instead of degrading.
639        let surface_format = surface_caps
640            .formats
641            .iter()
642            .find(|f| f.is_srgb())
643            .copied()
644            .or_else(|| surface_caps.formats.first().copied())
645            .unwrap_or(wgpu::TextureFormat::Rgba8UnormSrgb);
646
647        let surface_config = wgpu::SurfaceConfiguration {
648            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
649            format: surface_format,
650            width: size.width.max(1),
651            height: size.height.max(1),
652            present_mode: wgpu::PresentMode::Fifo,
653            alpha_mode: surface_caps
654                .alpha_modes
655                .first()
656                .copied()
657                .unwrap_or(wgpu::CompositeAlphaMode::Auto),
658            view_formats: vec![],
659            desired_maximum_frame_latency: 2,
660            // `Auto` reproduces wgpu's pre-30 behaviour: sRGB for the
661            // non-`Rgba16Float` formats we select above.
662            color_space: wgpu::SurfaceColorSpace::Auto,
663        };
664        // A compositor that goes away while the device above is being opened
665        // lands here, because opening one is the slowest step between the
666        // adapter's validation and this call. It used to panic out of wgpu's
667        // default error handler before the window ever existed.
668        let display_lost =
669            match configure_surface(&surface, &gpu.adapter, &gpu.device, &surface_config) {
670                Ok(()) => false,
671                Err(err) => {
672                    eprintln!("teksilo-platform: {err}");
673                    matches!(err, SurfaceConfigureError::DisplayLost)
674                }
675            };
676
677        // The renderer stays per-window: it owns the glyph atlas, the path
678        // atlas and the blur pool, and it is `!Sync` besides.
679        let renderer = Renderer::new(gpu.device, gpu.queue, surface_format);
680        WindowGpu {
681            surface,
682            surface_config,
683            renderer,
684            adapter: gpu.adapter,
685            display_lost,
686        }
687    }
688
689    /// Create a new platform window from a winit window.
690    /// The `event_loop` parameter is needed for the AccessKit adapter.
691    pub async fn new_with_a11y(
692        window: Window,
693        event_loop: &winit::event_loop::ActiveEventLoop,
694    ) -> Self {
695        let window = Arc::new(window);
696        let scale_factor = window.scale_factor();
697        let WindowGpu {
698            surface,
699            surface_config,
700            renderer,
701            adapter,
702            display_lost,
703        } = Self::surface_and_renderer(&window).await;
704
705        // Create AccessKit adapter with action channel
706        let (action_tx, action_rx) = mpsc::channel();
707
708        let a11y_needs_full_tree = Arc::new(AtomicBool::new(true));
709        let a11y_bridge = Arc::new(AccessibilityBridge::default());
710
711        // Every handler below runs off the UI thread and ends by asking winit
712        // to redraw this window. That request is the *only* thing that wakes
713        // the event loop: `handle_accessibility_actions` — the sole drain of
714        // the action channel — runs from `window_event`, so without a wakeup an
715        // action issued by Narrator or Orca would sit in the channel until some
716        // unrelated window event happened to arrive. `Window::request_redraw`
717        // is thread-safe, which is why an `Arc<Window>` clone is all a handler
718        // needs.
719        let a11y_adapter = accesskit_winit::Adapter::with_direct_handlers(
720            event_loop,
721            &window,
722            TeksiloActivationHandler {
723                needs_full_tree: a11y_needs_full_tree.clone(),
724                bridge: Arc::clone(&a11y_bridge),
725                window: Arc::clone(&window),
726            },
727            TeksiloActionHandler {
728                tx: action_tx,
729                window: Arc::clone(&window),
730            },
731            TeksiloDeactivationHandler {
732                bridge: Arc::clone(&a11y_bridge),
733                window: Arc::clone(&window),
734            },
735        );
736
737        // Show the window now that the adapter is created
738        window.set_visible(true);
739
740        Self {
741            window,
742            surface,
743            surface_config,
744            adapter,
745            display_lost,
746            renderer,
747            scale_factor,
748            a11y_adapter: Some(a11y_adapter),
749            a11y_action_rx: action_rx,
750            a11y_needs_full_tree,
751            a11y_bridge,
752        }
753    }
754
755    /// Create a platform window without AccessKit (for contexts without ActiveEventLoop).
756    pub async fn new(window: Window) -> Self {
757        let window = Arc::new(window);
758        let scale_factor = window.scale_factor();
759        let WindowGpu {
760            surface,
761            surface_config,
762            renderer,
763            adapter,
764            display_lost,
765        } = Self::surface_and_renderer(&window).await;
766        let (_action_tx, action_rx) = mpsc::channel();
767
768        Self {
769            window,
770            surface,
771            surface_config,
772            adapter,
773            display_lost,
774            renderer,
775            scale_factor,
776            a11y_adapter: None,
777            a11y_action_rx: action_rx,
778            a11y_needs_full_tree: Arc::new(AtomicBool::new(false)),
779            a11y_bridge: Arc::new(AccessibilityBridge::default()),
780        }
781    }
782
783    pub fn window(&self) -> &Window {
784        &self.window
785    }
786
787    /// Get a clonable `Arc` reference to the underlying winit window.
788    /// Used by `teksilo_platform::create_title_bar_host` and other components
789    /// that need shared ownership of the window.
790    pub fn window_arc(&self) -> Arc<Window> {
791        self.window.clone()
792    }
793
794    pub fn renderer(&self) -> &Renderer {
795        &self.renderer
796    }
797
798    pub fn renderer_mut(&mut self) -> &mut Renderer {
799        &mut self.renderer
800    }
801
802    pub fn scale_factor(&self) -> f64 {
803        self.scale_factor
804    }
805
806    pub fn set_scale_factor(&mut self, factor: f64) {
807        self.scale_factor = factor;
808    }
809
810    /// Resize the surface.
811    ///
812    /// A resize that arrives once the display server has gone is dropped:
813    /// there is nothing left to present to.
814    pub fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
815        if self.display_lost || new_size.width == 0 || new_size.height == 0 {
816            return;
817        }
818        self.surface_config.width = new_size.width;
819        self.surface_config.height = new_size.height;
820        self.apply_surface_config();
821    }
822
823    /// Get current surface dimensions.
824    pub fn surface_size(&self) -> (u32, u32) {
825        (self.surface_config.width, self.surface_config.height)
826    }
827
828    /// Reconfigure the surface with the current config.
829    /// Use after a Lost or Outdated surface error.
830    ///
831    /// Answers whether this window can still present. `false` means the
832    /// display server is gone, and the caller should wind down rather than ask
833    /// for another frame: the next one would come back here unchanged.
834    pub fn reconfigure_surface(&mut self) -> bool {
835        if self.display_lost {
836            return false;
837        }
838        self.apply_surface_config();
839        !self.display_lost
840    }
841
842    /// Whether the display server has gone away under this window.
843    pub fn display_lost(&self) -> bool {
844        self.display_lost
845    }
846
847    /// Push `surface_config` to the surface, latching a departed display
848    /// server and reporting anything else wgpu refused.
849    ///
850    /// The latch is what keeps the report to one line: every later configure
851    /// returns before reaching here.
852    fn apply_surface_config(&mut self) {
853        if let Err(err) = configure_surface(
854            &self.surface,
855            &self.adapter,
856            self.renderer.device(),
857            &self.surface_config,
858        ) {
859            eprintln!("teksilo-platform: {err}");
860            if matches!(err, SurfaceConfigureError::DisplayLost) {
861                self.display_lost = true;
862            }
863        }
864    }
865
866    /// Render a frame to the surface.
867    pub fn render_frame(
868        &mut self,
869        frame: &teksilo_canvas::RenderFrame,
870        clear_color: [f32; 4],
871    ) -> FrameOutcome {
872        if self.display_lost {
873            return FrameOutcome::DisplayLost;
874        }
875        let current = self.surface.get_current_texture();
876        let output = match current {
877            wgpu::CurrentSurfaceTexture::Success(tex)
878            | wgpu::CurrentSurfaceTexture::Suboptimal(tex) => tex,
879            wgpu::CurrentSurfaceTexture::Occluded | wgpu::CurrentSurfaceTexture::Timeout => {
880                return FrameOutcome::Skipped;
881            }
882            wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
883                return FrameOutcome::NeedsReconfigure;
884            }
885            other => return FrameOutcome::Error(SurfaceRenderError(format!("{other:?}"))),
886        };
887
888        let view = output
889            .texture
890            .create_view(&wgpu::TextureViewDescriptor::default());
891
892        let (w, h) = self.surface_size();
893        self.renderer
894            .render(frame, &view, self.scale_factor as f32, w, h, clear_color);
895
896        self.renderer.queue().present(output);
897        FrameOutcome::Rendered
898    }
899
900    /// Render `frame` into an offscreen texture and read it back as
901    /// tightly-packed RGBA8 bytes, returning `(rgba, width, height)`.
902    ///
903    /// Used by the debug-only automation bridge to capture a *live* window
904    /// without going through the swapchain — the surface texture is
905    /// configured `RENDER_ATTACHMENT` only (no `COPY_SRC`), so it can't be
906    /// read back directly. The offscreen texture uses the window's own
907    /// surface format so it matches the renderer's pipelines; a BGRA
908    /// readback is swizzled to RGBA here so the output is always RGBA. With
909    /// `crop = Some(rect)` (physical pixels, clamped to the surface) only
910    /// that sub-rectangle is returned. Returns an empty `(vec, 0, 0)` if
911    /// the crop is fully outside the surface.
912    ///
913    /// Note: a native `WebView` subview composites *on top of* the wgpu
914    /// surface and is invisible to this readback (a transparent hole).
915    pub fn capture_offscreen(
916        &mut self,
917        frame: &teksilo_canvas::RenderFrame,
918        clear_color: [f32; 4],
919        crop: Option<teksilo_canvas::Rect>,
920    ) -> (Vec<u8>, u32, u32) {
921        fn crop_rgba(
922            src: &[u8],
923            w: u32,
924            h: u32,
925            rect: teksilo_canvas::Rect,
926        ) -> (Vec<u8>, u32, u32) {
927            let x0 = (rect.x.floor().max(0.0) as u32).min(w);
928            let y0 = (rect.y.floor().max(0.0) as u32).min(h);
929            let x1 = ((rect.x + rect.width).ceil().max(0.0) as u32).min(w);
930            let y1 = ((rect.y + rect.height).ceil().max(0.0) as u32).min(h);
931            if x1 <= x0 || y1 <= y0 {
932                return (Vec::new(), 0, 0);
933            }
934            let cw = x1 - x0;
935            let ch = y1 - y0;
936            let mut out = Vec::with_capacity((cw * ch * 4) as usize);
937            for y in y0..y1 {
938                let row_start = ((y * w + x0) * 4) as usize;
939                let row_end = row_start + (cw * 4) as usize;
940                out.extend_from_slice(&src[row_start..row_end]);
941            }
942            (out, cw, ch)
943        }
944
945        let (w, h) = self.surface_size();
946        let format = self.surface_config.format;
947        // The readback assumes a 4-byte, 8-bit RGBA/BGRA layout (the BGRA
948        // swizzle below + `read_texture_rgba`'s fixed 4-bytes-per-pixel copy).
949        // Desktop wgpu surfaces are always one of these four; a packed
950        // (Rgb10a2) or wide (Rgba16Float) surface format would read back
951        // garbage, so flag it loudly in debug builds.
952        debug_assert!(
953            matches!(
954                format,
955                wgpu::TextureFormat::Rgba8Unorm
956                    | wgpu::TextureFormat::Rgba8UnormSrgb
957                    | wgpu::TextureFormat::Bgra8Unorm
958                    | wgpu::TextureFormat::Bgra8UnormSrgb
959            ),
960            "capture_offscreen: unsupported surface format {format:?} (expected 8-bit RGBA/BGRA)"
961        );
962        let texture = self
963            .renderer
964            .device()
965            .create_texture(&wgpu::TextureDescriptor {
966                label: Some("teksilo-automation capture"),
967                size: wgpu::Extent3d {
968                    width: w,
969                    height: h,
970                    depth_or_array_layers: 1,
971                },
972                mip_level_count: 1,
973                sample_count: 1,
974                dimension: wgpu::TextureDimension::D2,
975                format,
976                usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
977                view_formats: &[],
978            });
979        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
980        self.renderer
981            .render(frame, &view, self.scale_factor as f32, w, h, clear_color);
982        let mut bytes = teksilo_render::test_support::read_texture_rgba(
983            self.renderer.device(),
984            self.renderer.queue(),
985            &texture,
986            w,
987            h,
988        );
989        // `read_texture_rgba` copies raw channel bytes; a BGRA surface
990        // needs its B/R swapped to become RGBA for PNG encoding.
991        if matches!(
992            format,
993            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
994        ) {
995            for px in bytes.as_chunks_mut::<4>().0 {
996                px.swap(0, 2);
997            }
998        }
999        match crop {
1000            Some(rect) => crop_rgba(&bytes, w, h, rect),
1001            None => (bytes, w, h),
1002        }
1003    }
1004
1005    pub fn request_redraw(&self) {
1006        self.window.request_redraw();
1007    }
1008
1009    /// Push an AccessKit TreeUpdate to the adapter (called after layout).
1010    /// Publish a freshly built `TreeUpdate` to the adapter, and leave a copy
1011    /// where the activation handler can find it.
1012    ///
1013    /// The copy is what lets an assistive technology that attaches to an *idle*
1014    /// window see the application instead of an empty window node: the handler
1015    /// runs off the UI thread and cannot build a tree, so the last one the UI
1016    /// thread built is the best answer available synchronously.
1017    pub fn update_accessibility(&mut self, update: accesskit::TreeUpdate) {
1018        self.a11y_bridge.publish(&update);
1019        if let Some(adapter) = &mut self.a11y_adapter {
1020            adapter.update_if_active(|| update);
1021        }
1022    }
1023
1024    /// Push an update the adapter builds only when it is actually going to
1025    /// be delivered, and only when `build` says there is one worth sending.
1026    ///
1027    /// The caller decides *inside* the closure, because that is where the
1028    /// decision belongs: `update_if_active` runs its closure only when an
1029    /// assistive technology is attached, and on Linux it runs it under the
1030    /// adapter's own state lock. Deciding outside would build a tree for
1031    /// nobody on every frame, and would make the throttle count frames
1032    /// nothing was listening to.
1033    ///
1034    /// `build` returning `None` means "nothing to deliver"; the previously
1035    /// delivered tree is re-sent, which the consumer treats as a no-op.
1036    pub fn update_accessibility_with(
1037        &mut self,
1038        build: impl FnOnce() -> Option<accesskit::TreeUpdate>,
1039        previous: impl FnOnce() -> accesskit::TreeUpdate,
1040    ) {
1041        if let Some(adapter) = &mut self.a11y_adapter {
1042            adapter.update_if_active(|| build().unwrap_or_else(previous));
1043        }
1044    }
1045
1046    /// Whether an assistive technology has asked this window for its tree
1047    /// and has not yet been given a full one.
1048    ///
1049    /// Set by the activation handler, which runs on whichever thread the
1050    /// platform's accessibility layer calls it from, and cleared by the
1051    /// first delivery after it — so a reader that attaches mid-session gets
1052    /// a complete tree rather than a geometry patch onto a tree it has
1053    /// never seen.
1054    pub fn accessibility_needs_full_tree(&self) -> bool {
1055        self.a11y_needs_full_tree.load(Ordering::Relaxed)
1056    }
1057
1058    /// Clear the flag above, reporting what it was.
1059    pub fn take_accessibility_needs_full_tree(&self) -> bool {
1060        self.a11y_needs_full_tree.swap(false, Ordering::Relaxed)
1061    }
1062
1063    /// Whether an AccessKit client is attached to this window's adapter.
1064    ///
1065    /// True from the moment the platform accessibility stack asks for an
1066    /// initial tree until it says it has gone away. Read once per frame by
1067    /// `teksilo-app` and pushed into the window's tree; see
1068    /// [`WidgetTree::set_at_client_attached`](teksilo_core::WidgetTree::set_at_client_attached)
1069    /// for why attaching and detaching are read asymmetrically.
1070    ///
1071    /// Always `false` for a window built without an adapter
1072    /// ([`PlatformWindow::new`]).
1073    pub fn accessibility_active(&self) -> bool {
1074        self.a11y_bridge.is_active()
1075    }
1076
1077    /// Forward a winit WindowEvent to the AccessKit adapter.
1078    pub fn process_accessibility_event(&mut self, event: &WindowEvent) {
1079        if let Some(adapter) = &mut self.a11y_adapter {
1080            adapter.process_event(&self.window, event);
1081        }
1082    }
1083
1084    /// Drain any pending AccessKit action requests from the adapter.
1085    pub fn drain_accessibility_actions(&self) -> Vec<ActionRequest> {
1086        let mut actions = Vec::new();
1087        while let Ok(req) = self.a11y_action_rx.try_recv() {
1088            actions.push(req);
1089        }
1090        actions
1091    }
1092}
1093
1094// --- AccessKit handler implementations ---
1095
1096/// Activation handler — answers with the last tree the UI thread built.
1097///
1098/// An assistive technology attaching to a window that is sitting idle used to
1099/// be shown a bare `Role::Window` node with no children, and stayed shown it
1100/// until something unrelated caused a frame. Answering from the published
1101/// snapshot fixes the common case; the redraw request covers the rest, since
1102/// the adapter is active from here on and the next
1103/// [`PlatformWindow::update_accessibility`] reaches it.
1104/// `needs_full_tree` is what makes the delivery that follows a *full*
1105/// tree rather than a geometry patch: updates are otherwise throttled to
1106/// the moves-only rate, and a reader that attaches mid-session has never
1107/// seen the tree such a patch would be applied to.
1108struct TeksiloActivationHandler {
1109    needs_full_tree: Arc<AtomicBool>,
1110    bridge: Arc<AccessibilityBridge>,
1111    window: Arc<Window>,
1112}
1113
1114/// The tree handed to a client that attached before this window ever drew.
1115///
1116/// A window node with no children — the same placeholder as before — because
1117/// there is genuinely nothing else to say yet. The accompanying redraw request
1118/// is what makes it short-lived.
1119fn empty_initial_tree() -> accesskit::TreeUpdate {
1120    let root = accesskit::Node::new(accesskit::Role::Window);
1121    let root_id = teksilo_core::accessibility::root_node_id();
1122    accesskit::TreeUpdate {
1123        nodes: vec![(root_id, root)],
1124        tree: Some(accesskit::TreeInfo::new(root_id)),
1125        tree_id: accesskit::TreeId::ROOT,
1126        focus: root_id,
1127    }
1128}
1129
1130impl accesskit::ActivationHandler for TeksiloActivationHandler {
1131    fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
1132        self.needs_full_tree.store(true, Ordering::Relaxed);
1133        let update = self.bridge.on_activate();
1134        // Whether or not we could answer with a real tree, ask for a frame: it
1135        // is what carries the *next* update to the now-active adapter, and it
1136        // is also how the UI thread learns that a client attached.
1137        self.window.request_redraw();
1138        Some(update)
1139    }
1140}
1141
1142/// Action handler — forwards action requests to the main thread via a channel,
1143/// then wakes the loop so the channel is actually drained.
1144struct TeksiloActionHandler {
1145    tx: mpsc::Sender<ActionRequest>,
1146    window: Arc<Window>,
1147}
1148
1149impl accesskit::ActionHandler for TeksiloActionHandler {
1150    fn do_action(&mut self, request: ActionRequest) {
1151        let _ = self.tx.send(request);
1152        self.window.request_redraw();
1153    }
1154}
1155
1156/// Deactivation handler — records that the last client detached.
1157///
1158/// Unlike activation, this *is* evidence about screen readers: when no client
1159/// is attached, none of them is reading the tree either.
1160struct TeksiloDeactivationHandler {
1161    bridge: Arc<AccessibilityBridge>,
1162    window: Arc<Window>,
1163}
1164
1165impl accesskit::DeactivationHandler for TeksiloDeactivationHandler {
1166    fn deactivate_accessibility(&mut self) {
1167        self.bridge.on_deactivate();
1168        // The UI thread reads the flag once per frame, so it needs a frame.
1169        self.window.request_redraw();
1170    }
1171}
1172
1173#[cfg(test)]
1174mod surface_configure_tests {
1175    use super::{SurfaceConfigureError, classify_configure_failure};
1176
1177    /// The defect this pins: a compositor crash reached the user as
1178    /// `Surface does not support the adapter's queue family`, and was read as
1179    /// a GPU mismatch by everyone who saw it, including the maintainer. A
1180    /// surface with no formats left for the adapter it was matched against has
1181    /// lost its display server, whatever wgpu chooses to call it.
1182    #[test]
1183    fn a_surface_with_no_formats_left_means_the_display_server_is_gone() {
1184        let err = classify_configure_failure(
1185            "Surface does not support the adapter's queue family".to_string(),
1186            false,
1187        );
1188        assert!(matches!(err, SurfaceConfigureError::DisplayLost));
1189        assert!(err.to_string().contains("display server"));
1190    }
1191
1192    /// The other half, and the reason this is not simply "any configure
1193    /// failure means the compositor left": a surface that still answers with
1194    /// formats has a real configuration problem, and its own message has to
1195    /// survive rather than be relabelled as a dead compositor.
1196    #[test]
1197    fn a_surface_that_still_has_formats_keeps_wgpus_own_message() {
1198        let err = classify_configure_failure(
1199            "Requested format Rgba8Unorm is not in the list of supported formats".to_string(),
1200            true,
1201        );
1202        assert!(matches!(err, SurfaceConfigureError::Rejected(_)));
1203        assert!(err.to_string().contains("Rgba8Unorm"));
1204        assert!(!err.to_string().contains("display server"));
1205    }
1206}
1207
1208#[cfg(test)]
1209mod accessibility_bridge_tests {
1210    use super::{AccessibilityBridge, empty_initial_tree};
1211
1212    /// A recognisable tree that is not the placeholder.
1213    fn published_tree() -> accesskit::TreeUpdate {
1214        let root_id = teksilo_core::accessibility::root_node_id();
1215        let child_id = accesskit::NodeId(4242);
1216        let mut root = accesskit::Node::new(accesskit::Role::Window);
1217        root.push_child(child_id);
1218        let mut child = accesskit::Node::new(accesskit::Role::Button);
1219        child.set_label("Save");
1220        accesskit::TreeUpdate {
1221            nodes: vec![(root_id, root), (child_id, child)],
1222            tree: Some(accesskit::TreeInfo::new(root_id)),
1223            tree_id: accesskit::TreeId::ROOT,
1224            focus: root_id,
1225        }
1226    }
1227
1228    #[test]
1229    fn a_fresh_bridge_reports_no_client() {
1230        assert!(!AccessibilityBridge::default().is_active());
1231    }
1232
1233    #[test]
1234    fn activation_before_the_first_frame_answers_with_the_placeholder() {
1235        let bridge = AccessibilityBridge::default();
1236        let update = bridge.on_activate();
1237        assert_eq!(update.nodes.len(), empty_initial_tree().nodes.len());
1238        assert_eq!(update.nodes[0].1.children().len(), 0);
1239        assert!(bridge.is_active());
1240    }
1241
1242    #[test]
1243    fn activation_after_a_frame_answers_with_the_real_tree() {
1244        // The defect this pins: an assistive technology attaching to an idle
1245        // window was shown a childless window node and nothing scheduled a
1246        // frame to replace it.
1247        let bridge = AccessibilityBridge::default();
1248        bridge.publish(&published_tree());
1249        let update = bridge.on_activate();
1250        assert_eq!(
1251            update.nodes.len(),
1252            2,
1253            "the published tree, not a placeholder"
1254        );
1255        assert_eq!(update.nodes[0].1.children().len(), 1);
1256    }
1257
1258    #[test]
1259    fn the_snapshot_is_the_latest_published_tree() {
1260        let bridge = AccessibilityBridge::default();
1261        bridge.publish(&empty_initial_tree());
1262        bridge.publish(&published_tree());
1263        assert_eq!(bridge.on_activate().nodes.len(), 2);
1264    }
1265
1266    #[test]
1267    fn publishing_while_a_client_is_attached_is_skipped() {
1268        // Not a behaviour change anyone can observe through `on_activate` —
1269        // an attached client cannot ask for an initial tree — but it is what
1270        // keeps a per-frame `TreeUpdate` clone off the frame path while a
1271        // screen reader is running.
1272        let bridge = AccessibilityBridge::default();
1273        bridge.publish(&published_tree());
1274        let _ = bridge.on_activate();
1275        bridge.publish(&empty_initial_tree());
1276        bridge.on_deactivate();
1277        assert_eq!(
1278            bridge.on_activate().nodes.len(),
1279            2,
1280            "the tree published while attached must not have replaced the snapshot"
1281        );
1282    }
1283
1284    #[test]
1285    fn deactivation_clears_the_attached_flag() {
1286        let bridge = AccessibilityBridge::default();
1287        let _ = bridge.on_activate();
1288        assert!(bridge.is_active());
1289        bridge.on_deactivate();
1290        assert!(!bridge.is_active());
1291        // And the tree it published is still there for a client that comes back.
1292        bridge.publish(&published_tree());
1293        assert_eq!(bridge.on_activate().nodes.len(), 2);
1294        assert!(bridge.is_active());
1295    }
1296}
1297
1298#[cfg(test)]
1299mod device_limits_tests {
1300    use super::*;
1301
1302    /// A Raspberry Pi 4's V3D driver in the fields that matter here: four
1303    /// colour attachments and 4096-pixel textures. This is the adapter the
1304    /// crash report came from.
1305    fn pi4_class_limits() -> wgpu::Limits {
1306        wgpu::Limits {
1307            max_texture_dimension_1d: 4096,
1308            max_texture_dimension_2d: 4096,
1309            max_texture_dimension_3d: 256,
1310            max_color_attachments: 4,
1311            ..wgpu::Limits::downlevel_defaults()
1312        }
1313    }
1314
1315    #[test]
1316    fn the_default_limits_are_refused_by_gles_class_hardware() {
1317        // The bug, stated as a test: this is what the window used to ask for,
1318        // and `check_limits` is the same comparison wgpu makes inside
1319        // `request_device`. If this ever starts passing, wgpu changed its
1320        // defaults and the fallback below is what keeps us honest.
1321        assert!(
1322            !wgpu::Limits::default().check_limits(&pi4_class_limits()),
1323            "the wgpu default limits are supposed to over-ask for a Pi-4 class \
1324             adapter; that refusal is the crash this module exists to prevent"
1325        );
1326    }
1327
1328    #[test]
1329    fn the_window_ask_is_satisfiable_on_gles_class_hardware() {
1330        let adapter = pi4_class_limits();
1331        assert!(
1332            window_device_limits(adapter.clone()).check_limits(&adapter),
1333            "a Pi-4 class adapter must be able to grant what a window asks for"
1334        );
1335    }
1336
1337    #[test]
1338    fn the_window_never_asks_past_the_downlevel_floor() {
1339        // The regression pin: whatever the adapter offers, every limit that is
1340        // not a texture dimension stays at the GLES-3.1 floor. Re-introducing
1341        // `Limits::default()` fails here on a developer's desktop rather than
1342        // only on a reviewer's Raspberry Pi.
1343        let generous = wgpu::Limits::default();
1344        let asked = window_device_limits(generous.clone());
1345        let floor = wgpu::Limits::downlevel_defaults();
1346
1347        assert_eq!(asked.max_color_attachments, floor.max_color_attachments);
1348        assert_eq!(
1349            asked.max_uniform_buffer_binding_size,
1350            floor.max_uniform_buffer_binding_size
1351        );
1352        assert_eq!(
1353            asked.max_inter_stage_shader_variables,
1354            floor.max_inter_stage_shader_variables
1355        );
1356        assert_eq!(
1357            asked.max_storage_buffers_per_shader_stage,
1358            floor.max_storage_buffers_per_shader_stage
1359        );
1360        assert_ne!(
1361            asked, generous,
1362            "asking for the full default set is exactly the regression"
1363        );
1364    }
1365
1366    #[test]
1367    fn texture_dimensions_follow_the_adapter() {
1368        // `downlevel_defaults` caps 2D textures at 2048 and the path atlas
1369        // grows to 4096, so the resolution limits, and only those, are lifted
1370        // to whatever the adapter really offers.
1371        const PATH_ATLAS_MAX: u32 = 4096;
1372
1373        for adapter in [pi4_class_limits(), wgpu::Limits::default()] {
1374            let asked = window_device_limits(adapter.clone());
1375            assert_eq!(
1376                asked.max_texture_dimension_1d,
1377                adapter.max_texture_dimension_1d
1378            );
1379            assert_eq!(
1380                asked.max_texture_dimension_2d,
1381                adapter.max_texture_dimension_2d
1382            );
1383            assert_eq!(
1384                asked.max_texture_dimension_3d,
1385                adapter.max_texture_dimension_3d
1386            );
1387            assert!(
1388                asked.max_texture_dimension_2d >= PATH_ATLAS_MAX,
1389                "the path atlas grows to {PATH_ATLAS_MAX}; a device that cannot \
1390                 hold it would fail on a path-heavy frame instead of at startup"
1391            );
1392        }
1393    }
1394
1395    #[test]
1396    fn the_floor_still_covers_what_the_renderer_binds() {
1397        // What the renderer actually needs, so that lowering the ask further
1398        // fails here rather than in a frame. 128 animation slots of 64 bytes
1399        // is the largest uniform binding; every render pass has exactly one
1400        // colour attachment.
1401        const ANIM_UNIFORM_BYTES: u64 = 128 * 64;
1402        let asked = window_device_limits(pi4_class_limits());
1403
1404        assert!(asked.max_color_attachments >= 1);
1405        assert!(asked.max_uniform_buffer_binding_size >= ANIM_UNIFORM_BYTES);
1406    }
1407}