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