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.
227fn shared_instance() -> &'static wgpu::Instance {
228 static INSTANCE: OnceLock<wgpu::Instance> = OnceLock::new();
229 INSTANCE.get_or_init(|| {
230 let descriptor = match DISPLAY_HANDLE.get() {
231 Some(display) => wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(
232 display.clone(),
233 )),
234 // No app layer installed one — an embedder driving `PlatformWindow`
235 // itself, or a test. Offscreen work is unaffected; only a GL-backed
236 // window needs the handle.
237 None => wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
238 };
239 wgpu::Instance::new(descriptor)
240 })
241}
242
243/// The adapter, device and queue every window shares.
244///
245/// One device per process, not one per window. A device is a heavyweight,
246/// process-level object and a second one buys nothing: each window still needs
247/// its own surface and its own [`Renderer`] (that is where the glyph and path
248/// atlases live), but the driver objects underneath are the same for every
249/// window on the same adapter. Opening one per window duplicated the entire
250/// pipeline set and both atlas textures for every window a user opened.
251///
252/// It also closes a latent crash. Two D3D12 **WARP** devices rasterizing at the
253/// same time fault inside `d3d10warp.dll` — Microsoft's software rasterizer,
254/// and what a GPU-less Windows host actually draws with. Teksilo renders its
255/// windows sequentially on the winit main thread, so that was not reachable
256/// here; it would have become reachable the moment any window work moved off
257/// that thread. `teksilo_render::test_support` shares its offscreen device for
258/// the same reason, where it *was* reachable and did crash.
259///
260/// `surface` is used only to pick an adapter that can actually present to it.
261/// If a later window's surface turns out to be incompatible with the adapter we
262/// cached — a genuinely multi-GPU machine, where the second window opens on the
263/// other GPU — that window quietly gets its own device rather than failing.
264/// The limits a live window asks its device for.
265///
266/// Deliberately **not** [`wgpu::Limits::default`]. That set demands eight
267/// colour attachments, 64 KiB uniform bindings and 8192-pixel textures. This
268/// renderer draws every pass into a *single* colour attachment, binds at most
269/// 8 KiB of uniforms (128 animation slots of 64 bytes) and caps its path atlas
270/// at 4096 pixels. The headroom was inherited from the default, never needed.
271///
272/// On GLES-3.1 class hardware that headroom is not merely unused, it is
273/// refused: a Raspberry Pi 4's V3D driver allows four colour attachments, so
274/// `default()` failed device creation outright and the app could not open a
275/// window at all.
276///
277/// `downlevel_defaults` is wgpu's GLES-3.1 floor, which is exactly that class
278/// of hardware, and it is already what [`teksilo_render::test_support`] opens
279/// its offscreen device with, so a frame that renders in a test now renders in
280/// a window too. `using_resolution` lifts the three texture-dimension limits
281/// back to whatever this adapter really supports, because the path atlas grows
282/// past the 2048-pixel downlevel cap.
283fn window_device_limits(adapter_limits: wgpu::Limits) -> wgpu::Limits {
284 wgpu::Limits::downlevel_defaults().using_resolution(adapter_limits)
285}
286
287/// Open a device on `adapter`, preferring [`window_device_limits`] and falling
288/// back to whatever the adapter itself reports.
289///
290/// The fallback is not redundant. `downlevel_defaults` is a floor for a *class*
291/// of hardware, not a promise about any given adapter. Anything below GLES 3.1
292/// (an old GL driver, a constrained software rasterizer) can sit under it on a
293/// field `using_resolution` does not lift, and then the principled ask fails
294/// for the same reason `default()` did on the Pi. `adapter.limits()` is by
295/// construction the most that adapter can give, so it cannot be refused on
296/// limit grounds; a request that still fails has a real problem rather than a
297/// mis-sized ask, and that is the error worth propagating.
298async fn open_device(
299 adapter: &wgpu::Adapter,
300) -> Result<(wgpu::Device, wgpu::Queue), wgpu::RequestDeviceError> {
301 let descriptor = |limits| wgpu::DeviceDescriptor {
302 label: Some("teksilo_device"),
303 required_features: wgpu::Features::empty(),
304 required_limits: limits,
305 ..Default::default()
306 };
307
308 match adapter
309 .request_device(&descriptor(window_device_limits(adapter.limits())))
310 .await
311 {
312 Ok(pair) => Ok(pair),
313 Err(err) => {
314 // Say why we dropped to the adapter's own limits: a silent
315 // fallback turns "this GPU is below the GLES-3.1 floor" into an
316 // unexplained difference in behaviour between two machines.
317 eprintln!(
318 "teksilo-platform: downlevel device limits refused ({err}); \
319 retrying with the adapter's own limits"
320 );
321 adapter.request_device(&descriptor(adapter.limits())).await
322 }
323 }
324}
325
326/// Find an adapter that can present to `surface` *and* yields a device.
327///
328/// Adapter selection is a search, not a single request — the same lesson
329/// [`teksilo_render::test_support`] already encodes for its offscreen device,
330/// which the window path did not have. A host can enumerate an adapter it
331/// cannot actually open (a VM's GL driver is the usual one) while a perfectly
332/// good software adapter sits behind `force_fallback_adapter`. Treating the
333/// first failure as fatal reports "no GPU" on a machine that has one.
334///
335/// Both passes keep `compatible_surface`, so an adapter that cannot present to
336/// this window is never chosen — that is the check that failed on a machine
337/// with no Vulkan driver, and it is load-bearing, not a formality.
338///
339/// Panics only when *every* adapter on the machine declines, with a message
340/// naming what was tried and what the user can do about it.
341async fn open_gpu_for(
342 surface: &wgpu::Surface<'static>,
343) -> (wgpu::Adapter, wgpu::Device, wgpu::Queue) {
344 // `WGPU_POWER_PREF` is wgpu's own knob; honour it for the same reason the
345 // instance is built `_from_env`.
346 let power_preference = wgpu::PowerPreference::from_env().unwrap_or_default();
347 let mut adapter_error = None;
348 let mut device_error = None;
349
350 for force_fallback_adapter in [false, true] {
351 let adapter = match shared_instance()
352 .request_adapter(&wgpu::RequestAdapterOptions {
353 power_preference,
354 compatible_surface: Some(surface),
355 force_fallback_adapter,
356 ..Default::default()
357 })
358 .await
359 {
360 Ok(adapter) => adapter,
361 Err(err) => {
362 adapter_error.get_or_insert(err);
363 continue;
364 }
365 };
366
367 match open_device(&adapter).await {
368 Ok((device, queue)) => return (adapter, device, queue),
369 Err(err) => {
370 // Worth saying out loud: the next pass silently landing on a
371 // software adapter is a large performance difference, and an
372 // unexplained one is the sort of thing that gets reported as
373 // "Teksilo is slow on my machine".
374 eprintln!(
375 "teksilo-platform: adapter {:?} could not open a device ({err}); \
376 trying the next one",
377 adapter.get_info().name
378 );
379 device_error.get_or_insert(err);
380 }
381 }
382 }
383
384 panic!(
385 "no usable GPU adapter for this window.\n\
386 Tried every backend wgpu was built with, then an explicit software \
387 fallback; none could both present to the window and open a device.\n\
388 adapter search: {adapter_error:?}\n\
389 device open: {device_error:?}\n\
390 Teksilo needs Vulkan, Metal, D3D12 or OpenGL (3.3 desktop / ES 3.0). \
391 On Linux, installing a Vulkan driver is usually the fix: \
392 `mesa-vulkan-drivers` carries both the hardware drivers and the \
393 software `lavapipe`. `WGPU_BACKEND=gl|vulkan|dx12|metal` forces a \
394 specific backend."
395 );
396}
397
398async fn shared_gpu_for(surface: &wgpu::Surface<'static>) -> SharedGpu {
399 static SHARED: Mutex<Option<SharedGpu>> = Mutex::new(None);
400
401 // Clone out and release the lock: it is never held across the awaits below.
402 let cached = SHARED.lock().unwrap_or_else(|e| e.into_inner()).clone();
403 if let Some(gpu) = cached {
404 // A non-empty format list is wgpu's own answer to "can this adapter
405 // present to this surface".
406 if !surface.get_capabilities(&gpu.adapter).formats.is_empty() {
407 return gpu;
408 }
409 }
410
411 let (adapter, device, queue) = open_gpu_for(surface).await;
412
413 let gpu = SharedGpu {
414 adapter,
415 device,
416 queue,
417 };
418 // First one in becomes the shared device. Losing here is the multi-GPU case
419 // above (or a race that cannot happen while windows are created on one
420 // thread): the loser keeps the device it just opened, which is the old
421 // per-window behaviour and still correct.
422 let mut slot = SHARED.lock().unwrap_or_else(|e| e.into_inner());
423 if slot.is_none() {
424 *slot = Some(gpu.clone());
425 }
426 gpu
427}
428
429/// Configure `surface`, handing back the failure instead of letting wgpu's
430/// default uncaptured-error handler panic.
431///
432/// Every `Surface::configure` in this file goes through here, and the error
433/// scope is what makes that sufficient. Asking the surface whether it is still
434/// alive and *then* configuring it leaves a gap between the two in which the
435/// compositor can exit, and that gap is the bug: `request_adapter` validated
436/// this adapter against this surface with the same query `configure` runs, so
437/// only a change in between can make the second one fail. A scope catches the
438/// error from this exact call, however late the display server goes away.
439///
440/// `pop` resolves immediately on native (wgpu answers with a ready future) and
441/// wgpu's error scopes are thread-local. Both hold because every window in this
442/// process is created and drawn on the winit main thread.
443fn configure_surface(
444 surface: &wgpu::Surface<'static>,
445 adapter: &wgpu::Adapter,
446 device: &wgpu::Device,
447 config: &wgpu::SurfaceConfiguration,
448) -> Result<(), SurfaceConfigureError> {
449 let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
450 surface.configure(device, config);
451 match pollster::block_on(scope.pop()) {
452 None => Ok(()),
453 Some(err) => Err(classify_configure_failure(
454 err.to_string(),
455 !surface.get_capabilities(adapter).formats.is_empty(),
456 )),
457 }
458}
459
460/// Sort a configure failure into "the display server left" and everything
461/// else, on the one signal that separates them.
462///
463/// A live surface offers its adapter a non-empty format list; a surface whose
464/// display server has gone offers none, and that transition is observable:
465/// seven formats before the compositor exits, zero after. Deliberately not a
466/// match on wgpu's message text, which is both misleading here and free to
467/// change between releases.
468fn classify_configure_failure(
469 description: String,
470 surface_has_formats: bool,
471) -> SurfaceConfigureError {
472 if surface_has_formats {
473 SurfaceConfigureError::Rejected(description)
474 } else {
475 SurfaceConfigureError::DisplayLost
476 }
477}
478
479/// What [`PlatformWindow::surface_and_renderer`] hands to both constructors.
480struct WindowGpu {
481 surface: wgpu::Surface<'static>,
482 surface_config: wgpu::SurfaceConfiguration,
483 renderer: Renderer,
484 adapter: wgpu::Adapter,
485 display_lost: bool,
486}
487
488impl PlatformWindow {
489 /// Everything both constructors do: surface, shared device, swapchain
490 /// configuration, renderer. Kept in one place because the two entry points
491 /// differ only in whether they attach an AccessKit adapter, and sixty
492 /// duplicated lines of GPU setup is exactly the sort of thing that drifts.
493 async fn surface_and_renderer(window: &Arc<Window>) -> WindowGpu {
494 let size = window.inner_size();
495 let surface = shared_instance()
496 .create_surface(window.clone())
497 .expect("wgpu surface creation failed for the platform window");
498
499 let gpu = shared_gpu_for(&surface).await;
500
501 let surface_caps = surface.get_capabilities(&gpu.adapter);
502 // Guard the index accesses: a degenerate adapter/surface (software
503 // fallback, headless) can report empty `formats` / `alpha_modes`, and
504 // `[0]` would panic with an opaque out-of-bounds instead of degrading.
505 let surface_format = surface_caps
506 .formats
507 .iter()
508 .find(|f| f.is_srgb())
509 .copied()
510 .or_else(|| surface_caps.formats.first().copied())
511 .unwrap_or(wgpu::TextureFormat::Rgba8UnormSrgb);
512
513 let surface_config = wgpu::SurfaceConfiguration {
514 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
515 format: surface_format,
516 width: size.width.max(1),
517 height: size.height.max(1),
518 present_mode: wgpu::PresentMode::Fifo,
519 alpha_mode: surface_caps
520 .alpha_modes
521 .first()
522 .copied()
523 .unwrap_or(wgpu::CompositeAlphaMode::Auto),
524 view_formats: vec![],
525 desired_maximum_frame_latency: 2,
526 // `Auto` reproduces wgpu's pre-30 behaviour: sRGB for the
527 // non-`Rgba16Float` formats we select above.
528 color_space: wgpu::SurfaceColorSpace::Auto,
529 };
530 // A compositor that goes away while the device above is being opened
531 // lands here, because opening one is the slowest step between the
532 // adapter's validation and this call. It used to panic out of wgpu's
533 // default error handler before the window ever existed.
534 let display_lost =
535 match configure_surface(&surface, &gpu.adapter, &gpu.device, &surface_config) {
536 Ok(()) => false,
537 Err(err) => {
538 eprintln!("teksilo-platform: {err}");
539 matches!(err, SurfaceConfigureError::DisplayLost)
540 }
541 };
542
543 // The renderer stays per-window: it owns the glyph atlas, the path
544 // atlas and the blur pool, and it is `!Sync` besides.
545 let renderer = Renderer::new(gpu.device, gpu.queue, surface_format);
546 WindowGpu {
547 surface,
548 surface_config,
549 renderer,
550 adapter: gpu.adapter,
551 display_lost,
552 }
553 }
554
555 /// Create a new platform window from a winit window.
556 /// The `event_loop` parameter is needed for the AccessKit adapter.
557 pub async fn new_with_a11y(
558 window: Window,
559 event_loop: &winit::event_loop::ActiveEventLoop,
560 ) -> Self {
561 let window = Arc::new(window);
562 let scale_factor = window.scale_factor();
563 let WindowGpu {
564 surface,
565 surface_config,
566 renderer,
567 adapter,
568 display_lost,
569 } = Self::surface_and_renderer(&window).await;
570
571 // Create AccessKit adapter with action channel
572 let (action_tx, action_rx) = mpsc::channel();
573
574 let a11y_needs_full_tree = Arc::new(AtomicBool::new(true));
575 let a11y_bridge = Arc::new(AccessibilityBridge::default());
576
577 // Every handler below runs off the UI thread and ends by asking winit
578 // to redraw this window. That request is the *only* thing that wakes
579 // the event loop: `handle_accessibility_actions` — the sole drain of
580 // the action channel — runs from `window_event`, so without a wakeup an
581 // action issued by Narrator or Orca would sit in the channel until some
582 // unrelated window event happened to arrive. `Window::request_redraw`
583 // is thread-safe, which is why an `Arc<Window>` clone is all a handler
584 // needs.
585 let a11y_adapter = accesskit_winit::Adapter::with_direct_handlers(
586 event_loop,
587 &window,
588 TeksiloActivationHandler {
589 needs_full_tree: a11y_needs_full_tree.clone(),
590 bridge: Arc::clone(&a11y_bridge),
591 window: Arc::clone(&window),
592 },
593 TeksiloActionHandler {
594 tx: action_tx,
595 window: Arc::clone(&window),
596 },
597 TeksiloDeactivationHandler {
598 bridge: Arc::clone(&a11y_bridge),
599 window: Arc::clone(&window),
600 },
601 );
602
603 // Show the window now that the adapter is created
604 window.set_visible(true);
605
606 Self {
607 window,
608 surface,
609 surface_config,
610 adapter,
611 display_lost,
612 renderer,
613 scale_factor,
614 a11y_adapter: Some(a11y_adapter),
615 a11y_action_rx: action_rx,
616 a11y_needs_full_tree,
617 a11y_bridge,
618 }
619 }
620
621 /// Create a platform window without AccessKit (for contexts without ActiveEventLoop).
622 pub async fn new(window: Window) -> Self {
623 let window = Arc::new(window);
624 let scale_factor = window.scale_factor();
625 let WindowGpu {
626 surface,
627 surface_config,
628 renderer,
629 adapter,
630 display_lost,
631 } = Self::surface_and_renderer(&window).await;
632 let (_action_tx, action_rx) = mpsc::channel();
633
634 Self {
635 window,
636 surface,
637 surface_config,
638 adapter,
639 display_lost,
640 renderer,
641 scale_factor,
642 a11y_adapter: None,
643 a11y_action_rx: action_rx,
644 a11y_needs_full_tree: Arc::new(AtomicBool::new(false)),
645 a11y_bridge: Arc::new(AccessibilityBridge::default()),
646 }
647 }
648
649 pub fn window(&self) -> &Window {
650 &self.window
651 }
652
653 /// Get a clonable `Arc` reference to the underlying winit window.
654 /// Used by `teksilo_platform::create_title_bar_host` and other components
655 /// that need shared ownership of the window.
656 pub fn window_arc(&self) -> Arc<Window> {
657 self.window.clone()
658 }
659
660 pub fn renderer(&self) -> &Renderer {
661 &self.renderer
662 }
663
664 pub fn renderer_mut(&mut self) -> &mut Renderer {
665 &mut self.renderer
666 }
667
668 pub fn scale_factor(&self) -> f64 {
669 self.scale_factor
670 }
671
672 pub fn set_scale_factor(&mut self, factor: f64) {
673 self.scale_factor = factor;
674 }
675
676 /// Resize the surface.
677 ///
678 /// A resize that arrives once the display server has gone is dropped:
679 /// there is nothing left to present to.
680 pub fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
681 if self.display_lost || new_size.width == 0 || new_size.height == 0 {
682 return;
683 }
684 self.surface_config.width = new_size.width;
685 self.surface_config.height = new_size.height;
686 self.apply_surface_config();
687 }
688
689 /// Get current surface dimensions.
690 pub fn surface_size(&self) -> (u32, u32) {
691 (self.surface_config.width, self.surface_config.height)
692 }
693
694 /// Reconfigure the surface with the current config.
695 /// Use after a Lost or Outdated surface error.
696 ///
697 /// Answers whether this window can still present. `false` means the
698 /// display server is gone, and the caller should wind down rather than ask
699 /// for another frame: the next one would come back here unchanged.
700 pub fn reconfigure_surface(&mut self) -> bool {
701 if self.display_lost {
702 return false;
703 }
704 self.apply_surface_config();
705 !self.display_lost
706 }
707
708 /// Whether the display server has gone away under this window.
709 pub fn display_lost(&self) -> bool {
710 self.display_lost
711 }
712
713 /// Push `surface_config` to the surface, latching a departed display
714 /// server and reporting anything else wgpu refused.
715 ///
716 /// The latch is what keeps the report to one line: every later configure
717 /// returns before reaching here.
718 fn apply_surface_config(&mut self) {
719 if let Err(err) = configure_surface(
720 &self.surface,
721 &self.adapter,
722 self.renderer.device(),
723 &self.surface_config,
724 ) {
725 eprintln!("teksilo-platform: {err}");
726 if matches!(err, SurfaceConfigureError::DisplayLost) {
727 self.display_lost = true;
728 }
729 }
730 }
731
732 /// Render a frame to the surface.
733 pub fn render_frame(
734 &mut self,
735 frame: &teksilo_canvas::RenderFrame,
736 clear_color: [f32; 4],
737 ) -> FrameOutcome {
738 if self.display_lost {
739 return FrameOutcome::DisplayLost;
740 }
741 let current = self.surface.get_current_texture();
742 let output = match current {
743 wgpu::CurrentSurfaceTexture::Success(tex)
744 | wgpu::CurrentSurfaceTexture::Suboptimal(tex) => tex,
745 wgpu::CurrentSurfaceTexture::Occluded | wgpu::CurrentSurfaceTexture::Timeout => {
746 return FrameOutcome::Skipped;
747 }
748 wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
749 return FrameOutcome::NeedsReconfigure;
750 }
751 other => return FrameOutcome::Error(SurfaceRenderError(format!("{other:?}"))),
752 };
753
754 let view = output
755 .texture
756 .create_view(&wgpu::TextureViewDescriptor::default());
757
758 let (w, h) = self.surface_size();
759 self.renderer
760 .render(frame, &view, self.scale_factor as f32, w, h, clear_color);
761
762 self.renderer.queue().present(output);
763 FrameOutcome::Rendered
764 }
765
766 /// Render `frame` into an offscreen texture and read it back as
767 /// tightly-packed RGBA8 bytes, returning `(rgba, width, height)`.
768 ///
769 /// Used by the debug-only automation bridge to capture a *live* window
770 /// without going through the swapchain — the surface texture is
771 /// configured `RENDER_ATTACHMENT` only (no `COPY_SRC`), so it can't be
772 /// read back directly. The offscreen texture uses the window's own
773 /// surface format so it matches the renderer's pipelines; a BGRA
774 /// readback is swizzled to RGBA here so the output is always RGBA. With
775 /// `crop = Some(rect)` (physical pixels, clamped to the surface) only
776 /// that sub-rectangle is returned. Returns an empty `(vec, 0, 0)` if
777 /// the crop is fully outside the surface.
778 ///
779 /// Note: a native `WebView` subview composites *on top of* the wgpu
780 /// surface and is invisible to this readback (a transparent hole).
781 pub fn capture_offscreen(
782 &mut self,
783 frame: &teksilo_canvas::RenderFrame,
784 clear_color: [f32; 4],
785 crop: Option<teksilo_canvas::Rect>,
786 ) -> (Vec<u8>, u32, u32) {
787 fn crop_rgba(
788 src: &[u8],
789 w: u32,
790 h: u32,
791 rect: teksilo_canvas::Rect,
792 ) -> (Vec<u8>, u32, u32) {
793 let x0 = (rect.x.floor().max(0.0) as u32).min(w);
794 let y0 = (rect.y.floor().max(0.0) as u32).min(h);
795 let x1 = ((rect.x + rect.width).ceil().max(0.0) as u32).min(w);
796 let y1 = ((rect.y + rect.height).ceil().max(0.0) as u32).min(h);
797 if x1 <= x0 || y1 <= y0 {
798 return (Vec::new(), 0, 0);
799 }
800 let cw = x1 - x0;
801 let ch = y1 - y0;
802 let mut out = Vec::with_capacity((cw * ch * 4) as usize);
803 for y in y0..y1 {
804 let row_start = ((y * w + x0) * 4) as usize;
805 let row_end = row_start + (cw * 4) as usize;
806 out.extend_from_slice(&src[row_start..row_end]);
807 }
808 (out, cw, ch)
809 }
810
811 let (w, h) = self.surface_size();
812 let format = self.surface_config.format;
813 // The readback assumes a 4-byte, 8-bit RGBA/BGRA layout (the BGRA
814 // swizzle below + `read_texture_rgba`'s fixed 4-bytes-per-pixel copy).
815 // Desktop wgpu surfaces are always one of these four; a packed
816 // (Rgb10a2) or wide (Rgba16Float) surface format would read back
817 // garbage, so flag it loudly in debug builds.
818 debug_assert!(
819 matches!(
820 format,
821 wgpu::TextureFormat::Rgba8Unorm
822 | wgpu::TextureFormat::Rgba8UnormSrgb
823 | wgpu::TextureFormat::Bgra8Unorm
824 | wgpu::TextureFormat::Bgra8UnormSrgb
825 ),
826 "capture_offscreen: unsupported surface format {format:?} (expected 8-bit RGBA/BGRA)"
827 );
828 let texture = self
829 .renderer
830 .device()
831 .create_texture(&wgpu::TextureDescriptor {
832 label: Some("teksilo-automation capture"),
833 size: wgpu::Extent3d {
834 width: w,
835 height: h,
836 depth_or_array_layers: 1,
837 },
838 mip_level_count: 1,
839 sample_count: 1,
840 dimension: wgpu::TextureDimension::D2,
841 format,
842 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
843 view_formats: &[],
844 });
845 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
846 self.renderer
847 .render(frame, &view, self.scale_factor as f32, w, h, clear_color);
848 let mut bytes = teksilo_render::test_support::read_texture_rgba(
849 self.renderer.device(),
850 self.renderer.queue(),
851 &texture,
852 w,
853 h,
854 );
855 // `read_texture_rgba` copies raw channel bytes; a BGRA surface
856 // needs its B/R swapped to become RGBA for PNG encoding.
857 if matches!(
858 format,
859 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
860 ) {
861 for px in bytes.as_chunks_mut::<4>().0 {
862 px.swap(0, 2);
863 }
864 }
865 match crop {
866 Some(rect) => crop_rgba(&bytes, w, h, rect),
867 None => (bytes, w, h),
868 }
869 }
870
871 pub fn request_redraw(&self) {
872 self.window.request_redraw();
873 }
874
875 /// Push an AccessKit TreeUpdate to the adapter (called after layout).
876 /// Publish a freshly built `TreeUpdate` to the adapter, and leave a copy
877 /// where the activation handler can find it.
878 ///
879 /// The copy is what lets an assistive technology that attaches to an *idle*
880 /// window see the application instead of an empty window node: the handler
881 /// runs off the UI thread and cannot build a tree, so the last one the UI
882 /// thread built is the best answer available synchronously.
883 pub fn update_accessibility(&mut self, update: accesskit::TreeUpdate) {
884 self.a11y_bridge.publish(&update);
885 if let Some(adapter) = &mut self.a11y_adapter {
886 adapter.update_if_active(|| update);
887 }
888 }
889
890 /// Push an update the adapter builds only when it is actually going to
891 /// be delivered, and only when `build` says there is one worth sending.
892 ///
893 /// The caller decides *inside* the closure, because that is where the
894 /// decision belongs: `update_if_active` runs its closure only when an
895 /// assistive technology is attached, and on Linux it runs it under the
896 /// adapter's own state lock. Deciding outside would build a tree for
897 /// nobody on every frame, and would make the throttle count frames
898 /// nothing was listening to.
899 ///
900 /// `build` returning `None` means "nothing to deliver"; the previously
901 /// delivered tree is re-sent, which the consumer treats as a no-op.
902 pub fn update_accessibility_with(
903 &mut self,
904 build: impl FnOnce() -> Option<accesskit::TreeUpdate>,
905 previous: impl FnOnce() -> accesskit::TreeUpdate,
906 ) {
907 if let Some(adapter) = &mut self.a11y_adapter {
908 adapter.update_if_active(|| build().unwrap_or_else(previous));
909 }
910 }
911
912 /// Whether an assistive technology has asked this window for its tree
913 /// and has not yet been given a full one.
914 ///
915 /// Set by the activation handler, which runs on whichever thread the
916 /// platform's accessibility layer calls it from, and cleared by the
917 /// first delivery after it — so a reader that attaches mid-session gets
918 /// a complete tree rather than a geometry patch onto a tree it has
919 /// never seen.
920 pub fn accessibility_needs_full_tree(&self) -> bool {
921 self.a11y_needs_full_tree.load(Ordering::Relaxed)
922 }
923
924 /// Clear the flag above, reporting what it was.
925 pub fn take_accessibility_needs_full_tree(&self) -> bool {
926 self.a11y_needs_full_tree.swap(false, Ordering::Relaxed)
927 }
928
929 /// Whether an AccessKit client is attached to this window's adapter.
930 ///
931 /// True from the moment the platform accessibility stack asks for an
932 /// initial tree until it says it has gone away. Read once per frame by
933 /// `teksilo-app` and pushed into the window's tree; see
934 /// [`WidgetTree::set_at_client_attached`](teksilo_core::WidgetTree::set_at_client_attached)
935 /// for why attaching and detaching are read asymmetrically.
936 ///
937 /// Always `false` for a window built without an adapter
938 /// ([`PlatformWindow::new`]).
939 pub fn accessibility_active(&self) -> bool {
940 self.a11y_bridge.is_active()
941 }
942
943 /// Forward a winit WindowEvent to the AccessKit adapter.
944 pub fn process_accessibility_event(&mut self, event: &WindowEvent) {
945 if let Some(adapter) = &mut self.a11y_adapter {
946 adapter.process_event(&self.window, event);
947 }
948 }
949
950 /// Drain any pending AccessKit action requests from the adapter.
951 pub fn drain_accessibility_actions(&self) -> Vec<ActionRequest> {
952 let mut actions = Vec::new();
953 while let Ok(req) = self.a11y_action_rx.try_recv() {
954 actions.push(req);
955 }
956 actions
957 }
958}
959
960// --- AccessKit handler implementations ---
961
962/// Activation handler — answers with the last tree the UI thread built.
963///
964/// An assistive technology attaching to a window that is sitting idle used to
965/// be shown a bare `Role::Window` node with no children, and stayed shown it
966/// until something unrelated caused a frame. Answering from the published
967/// snapshot fixes the common case; the redraw request covers the rest, since
968/// the adapter is active from here on and the next
969/// [`PlatformWindow::update_accessibility`] reaches it.
970/// `needs_full_tree` is what makes the delivery that follows a *full*
971/// tree rather than a geometry patch: updates are otherwise throttled to
972/// the moves-only rate, and a reader that attaches mid-session has never
973/// seen the tree such a patch would be applied to.
974struct TeksiloActivationHandler {
975 needs_full_tree: Arc<AtomicBool>,
976 bridge: Arc<AccessibilityBridge>,
977 window: Arc<Window>,
978}
979
980/// The tree handed to a client that attached before this window ever drew.
981///
982/// A window node with no children — the same placeholder as before — because
983/// there is genuinely nothing else to say yet. The accompanying redraw request
984/// is what makes it short-lived.
985fn empty_initial_tree() -> accesskit::TreeUpdate {
986 let root = accesskit::Node::new(accesskit::Role::Window);
987 let root_id = teksilo_core::accessibility::root_node_id();
988 accesskit::TreeUpdate {
989 nodes: vec![(root_id, root)],
990 tree: Some(accesskit::TreeInfo::new(root_id)),
991 tree_id: accesskit::TreeId::ROOT,
992 focus: root_id,
993 }
994}
995
996impl accesskit::ActivationHandler for TeksiloActivationHandler {
997 fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
998 self.needs_full_tree.store(true, Ordering::Relaxed);
999 let update = self.bridge.on_activate();
1000 // Whether or not we could answer with a real tree, ask for a frame: it
1001 // is what carries the *next* update to the now-active adapter, and it
1002 // is also how the UI thread learns that a client attached.
1003 self.window.request_redraw();
1004 Some(update)
1005 }
1006}
1007
1008/// Action handler — forwards action requests to the main thread via a channel,
1009/// then wakes the loop so the channel is actually drained.
1010struct TeksiloActionHandler {
1011 tx: mpsc::Sender<ActionRequest>,
1012 window: Arc<Window>,
1013}
1014
1015impl accesskit::ActionHandler for TeksiloActionHandler {
1016 fn do_action(&mut self, request: ActionRequest) {
1017 let _ = self.tx.send(request);
1018 self.window.request_redraw();
1019 }
1020}
1021
1022/// Deactivation handler — records that the last client detached.
1023///
1024/// Unlike activation, this *is* evidence about screen readers: when no client
1025/// is attached, none of them is reading the tree either.
1026struct TeksiloDeactivationHandler {
1027 bridge: Arc<AccessibilityBridge>,
1028 window: Arc<Window>,
1029}
1030
1031impl accesskit::DeactivationHandler for TeksiloDeactivationHandler {
1032 fn deactivate_accessibility(&mut self) {
1033 self.bridge.on_deactivate();
1034 // The UI thread reads the flag once per frame, so it needs a frame.
1035 self.window.request_redraw();
1036 }
1037}
1038
1039#[cfg(test)]
1040mod surface_configure_tests {
1041 use super::{SurfaceConfigureError, classify_configure_failure};
1042
1043 /// The defect this pins: a compositor crash reached the user as
1044 /// `Surface does not support the adapter's queue family`, and was read as
1045 /// a GPU mismatch by everyone who saw it, including the maintainer. A
1046 /// surface with no formats left for the adapter it was matched against has
1047 /// lost its display server, whatever wgpu chooses to call it.
1048 #[test]
1049 fn a_surface_with_no_formats_left_means_the_display_server_is_gone() {
1050 let err = classify_configure_failure(
1051 "Surface does not support the adapter's queue family".to_string(),
1052 false,
1053 );
1054 assert!(matches!(err, SurfaceConfigureError::DisplayLost));
1055 assert!(err.to_string().contains("display server"));
1056 }
1057
1058 /// The other half, and the reason this is not simply "any configure
1059 /// failure means the compositor left": a surface that still answers with
1060 /// formats has a real configuration problem, and its own message has to
1061 /// survive rather than be relabelled as a dead compositor.
1062 #[test]
1063 fn a_surface_that_still_has_formats_keeps_wgpus_own_message() {
1064 let err = classify_configure_failure(
1065 "Requested format Rgba8Unorm is not in the list of supported formats".to_string(),
1066 true,
1067 );
1068 assert!(matches!(err, SurfaceConfigureError::Rejected(_)));
1069 assert!(err.to_string().contains("Rgba8Unorm"));
1070 assert!(!err.to_string().contains("display server"));
1071 }
1072}
1073
1074#[cfg(test)]
1075mod accessibility_bridge_tests {
1076 use super::{AccessibilityBridge, empty_initial_tree};
1077
1078 /// A recognisable tree that is not the placeholder.
1079 fn published_tree() -> accesskit::TreeUpdate {
1080 let root_id = teksilo_core::accessibility::root_node_id();
1081 let child_id = accesskit::NodeId(4242);
1082 let mut root = accesskit::Node::new(accesskit::Role::Window);
1083 root.push_child(child_id);
1084 let mut child = accesskit::Node::new(accesskit::Role::Button);
1085 child.set_label("Save");
1086 accesskit::TreeUpdate {
1087 nodes: vec![(root_id, root), (child_id, child)],
1088 tree: Some(accesskit::TreeInfo::new(root_id)),
1089 tree_id: accesskit::TreeId::ROOT,
1090 focus: root_id,
1091 }
1092 }
1093
1094 #[test]
1095 fn a_fresh_bridge_reports_no_client() {
1096 assert!(!AccessibilityBridge::default().is_active());
1097 }
1098
1099 #[test]
1100 fn activation_before_the_first_frame_answers_with_the_placeholder() {
1101 let bridge = AccessibilityBridge::default();
1102 let update = bridge.on_activate();
1103 assert_eq!(update.nodes.len(), empty_initial_tree().nodes.len());
1104 assert_eq!(update.nodes[0].1.children().len(), 0);
1105 assert!(bridge.is_active());
1106 }
1107
1108 #[test]
1109 fn activation_after_a_frame_answers_with_the_real_tree() {
1110 // The defect this pins: an assistive technology attaching to an idle
1111 // window was shown a childless window node and nothing scheduled a
1112 // frame to replace it.
1113 let bridge = AccessibilityBridge::default();
1114 bridge.publish(&published_tree());
1115 let update = bridge.on_activate();
1116 assert_eq!(
1117 update.nodes.len(),
1118 2,
1119 "the published tree, not a placeholder"
1120 );
1121 assert_eq!(update.nodes[0].1.children().len(), 1);
1122 }
1123
1124 #[test]
1125 fn the_snapshot_is_the_latest_published_tree() {
1126 let bridge = AccessibilityBridge::default();
1127 bridge.publish(&empty_initial_tree());
1128 bridge.publish(&published_tree());
1129 assert_eq!(bridge.on_activate().nodes.len(), 2);
1130 }
1131
1132 #[test]
1133 fn publishing_while_a_client_is_attached_is_skipped() {
1134 // Not a behaviour change anyone can observe through `on_activate` —
1135 // an attached client cannot ask for an initial tree — but it is what
1136 // keeps a per-frame `TreeUpdate` clone off the frame path while a
1137 // screen reader is running.
1138 let bridge = AccessibilityBridge::default();
1139 bridge.publish(&published_tree());
1140 let _ = bridge.on_activate();
1141 bridge.publish(&empty_initial_tree());
1142 bridge.on_deactivate();
1143 assert_eq!(
1144 bridge.on_activate().nodes.len(),
1145 2,
1146 "the tree published while attached must not have replaced the snapshot"
1147 );
1148 }
1149
1150 #[test]
1151 fn deactivation_clears_the_attached_flag() {
1152 let bridge = AccessibilityBridge::default();
1153 let _ = bridge.on_activate();
1154 assert!(bridge.is_active());
1155 bridge.on_deactivate();
1156 assert!(!bridge.is_active());
1157 // And the tree it published is still there for a client that comes back.
1158 bridge.publish(&published_tree());
1159 assert_eq!(bridge.on_activate().nodes.len(), 2);
1160 assert!(bridge.is_active());
1161 }
1162}
1163
1164#[cfg(test)]
1165mod device_limits_tests {
1166 use super::*;
1167
1168 /// A Raspberry Pi 4's V3D driver in the fields that matter here: four
1169 /// colour attachments and 4096-pixel textures. This is the adapter the
1170 /// crash report came from.
1171 fn pi4_class_limits() -> wgpu::Limits {
1172 wgpu::Limits {
1173 max_texture_dimension_1d: 4096,
1174 max_texture_dimension_2d: 4096,
1175 max_texture_dimension_3d: 256,
1176 max_color_attachments: 4,
1177 ..wgpu::Limits::downlevel_defaults()
1178 }
1179 }
1180
1181 #[test]
1182 fn the_default_limits_are_refused_by_gles_class_hardware() {
1183 // The bug, stated as a test: this is what the window used to ask for,
1184 // and `check_limits` is the same comparison wgpu makes inside
1185 // `request_device`. If this ever starts passing, wgpu changed its
1186 // defaults and the fallback below is what keeps us honest.
1187 assert!(
1188 !wgpu::Limits::default().check_limits(&pi4_class_limits()),
1189 "the wgpu default limits are supposed to over-ask for a Pi-4 class \
1190 adapter; that refusal is the crash this module exists to prevent"
1191 );
1192 }
1193
1194 #[test]
1195 fn the_window_ask_is_satisfiable_on_gles_class_hardware() {
1196 let adapter = pi4_class_limits();
1197 assert!(
1198 window_device_limits(adapter.clone()).check_limits(&adapter),
1199 "a Pi-4 class adapter must be able to grant what a window asks for"
1200 );
1201 }
1202
1203 #[test]
1204 fn the_window_never_asks_past_the_downlevel_floor() {
1205 // The regression pin: whatever the adapter offers, every limit that is
1206 // not a texture dimension stays at the GLES-3.1 floor. Re-introducing
1207 // `Limits::default()` fails here on a developer's desktop rather than
1208 // only on a reviewer's Raspberry Pi.
1209 let generous = wgpu::Limits::default();
1210 let asked = window_device_limits(generous.clone());
1211 let floor = wgpu::Limits::downlevel_defaults();
1212
1213 assert_eq!(asked.max_color_attachments, floor.max_color_attachments);
1214 assert_eq!(
1215 asked.max_uniform_buffer_binding_size,
1216 floor.max_uniform_buffer_binding_size
1217 );
1218 assert_eq!(
1219 asked.max_inter_stage_shader_variables,
1220 floor.max_inter_stage_shader_variables
1221 );
1222 assert_eq!(
1223 asked.max_storage_buffers_per_shader_stage,
1224 floor.max_storage_buffers_per_shader_stage
1225 );
1226 assert_ne!(
1227 asked, generous,
1228 "asking for the full default set is exactly the regression"
1229 );
1230 }
1231
1232 #[test]
1233 fn texture_dimensions_follow_the_adapter() {
1234 // `downlevel_defaults` caps 2D textures at 2048 and the path atlas
1235 // grows to 4096, so the resolution limits, and only those, are lifted
1236 // to whatever the adapter really offers.
1237 const PATH_ATLAS_MAX: u32 = 4096;
1238
1239 for adapter in [pi4_class_limits(), wgpu::Limits::default()] {
1240 let asked = window_device_limits(adapter.clone());
1241 assert_eq!(
1242 asked.max_texture_dimension_1d,
1243 adapter.max_texture_dimension_1d
1244 );
1245 assert_eq!(
1246 asked.max_texture_dimension_2d,
1247 adapter.max_texture_dimension_2d
1248 );
1249 assert_eq!(
1250 asked.max_texture_dimension_3d,
1251 adapter.max_texture_dimension_3d
1252 );
1253 assert!(
1254 asked.max_texture_dimension_2d >= PATH_ATLAS_MAX,
1255 "the path atlas grows to {PATH_ATLAS_MAX}; a device that cannot \
1256 hold it would fail on a path-heavy frame instead of at startup"
1257 );
1258 }
1259 }
1260
1261 #[test]
1262 fn the_floor_still_covers_what_the_renderer_binds() {
1263 // What the renderer actually needs, so that lowering the ask further
1264 // fails here rather than in a frame. 128 animation slots of 64 bytes
1265 // is the largest uniform binding; every render pass has exactly one
1266 // colour attachment.
1267 const ANIM_UNIFORM_BYTES: u64 = 128 * 64;
1268 let asked = window_device_limits(pi4_class_limits());
1269
1270 assert!(asked.max_color_attachments >= 1);
1271 assert!(asked.max_uniform_buffer_binding_size >= ANIM_UNIFORM_BYTES);
1272 }
1273}