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