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}
56
57/// The wgpu objects every window in the process shares.
58///
59/// All three are `Arc` handles internally, so cloning one is a refcount bump,
60/// not a second GPU object.
61#[derive(Clone)]
62struct SharedGpu {
63 adapter: wgpu::Adapter,
64 device: wgpu::Device,
65 queue: wgpu::Queue,
66}
67
68/// The one wgpu instance for this process.
69///
70/// A surface has to come from the same instance that later enumerates adapters
71/// for it, so this is the root every window hangs off. `Instance::new` is
72/// synchronous, which is why this one can be a plain `OnceLock` while the
73/// adapter and device below cannot.
74fn shared_instance() -> &'static wgpu::Instance {
75 static INSTANCE: OnceLock<wgpu::Instance> = OnceLock::new();
76 INSTANCE
77 .get_or_init(|| wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()))
78}
79
80/// The adapter, device and queue every window shares.
81///
82/// One device per process, not one per window. A device is a heavyweight,
83/// process-level object and a second one buys nothing: each window still needs
84/// its own surface and its own [`Renderer`] (that is where the glyph and path
85/// atlases live), but the driver objects underneath are the same for every
86/// window on the same adapter. Opening one per window duplicated the entire
87/// pipeline set and both atlas textures for every window a user opened.
88///
89/// It also closes a latent crash. Two D3D12 **WARP** devices rasterizing at the
90/// same time fault inside `d3d10warp.dll` — Microsoft's software rasterizer,
91/// and what a GPU-less Windows host actually draws with. Teksilo renders its
92/// windows sequentially on the winit main thread, so that was not reachable
93/// here; it would have become reachable the moment any window work moved off
94/// that thread. `teksilo_render::test_support` shares its offscreen device for
95/// the same reason, where it *was* reachable and did crash.
96///
97/// `surface` is used only to pick an adapter that can actually present to it.
98/// If a later window's surface turns out to be incompatible with the adapter we
99/// cached — a genuinely multi-GPU machine, where the second window opens on the
100/// other GPU — that window quietly gets its own device rather than failing.
101async fn shared_gpu_for(surface: &wgpu::Surface<'static>) -> SharedGpu {
102 static SHARED: Mutex<Option<SharedGpu>> = Mutex::new(None);
103
104 // Clone out and release the lock: it is never held across the awaits below.
105 let cached = SHARED.lock().unwrap_or_else(|e| e.into_inner()).clone();
106 if let Some(gpu) = cached {
107 // A non-empty format list is wgpu's own answer to "can this adapter
108 // present to this surface".
109 if !surface.get_capabilities(&gpu.adapter).formats.is_empty() {
110 return gpu;
111 }
112 }
113
114 let adapter = shared_instance()
115 .request_adapter(&wgpu::RequestAdapterOptions {
116 power_preference: wgpu::PowerPreference::default(),
117 compatible_surface: Some(surface),
118 force_fallback_adapter: false,
119 ..Default::default()
120 })
121 .await
122 .expect("no compatible wgpu adapter available");
123
124 let (device, queue) = adapter
125 .request_device(&wgpu::DeviceDescriptor {
126 label: Some("teksilo_device"),
127 required_features: wgpu::Features::empty(),
128 required_limits: wgpu::Limits::default(),
129 ..Default::default()
130 })
131 .await
132 .expect("wgpu device request failed");
133
134 let gpu = SharedGpu {
135 adapter,
136 device,
137 queue,
138 };
139 // First one in becomes the shared device. Losing here is the multi-GPU case
140 // above (or a race that cannot happen while windows are created on one
141 // thread): the loser keeps the device it just opened, which is the old
142 // per-window behaviour and still correct.
143 let mut slot = SHARED.lock().unwrap_or_else(|e| e.into_inner());
144 if slot.is_none() {
145 *slot = Some(gpu.clone());
146 }
147 gpu
148}
149
150impl PlatformWindow {
151 /// Everything both constructors do: surface, shared device, swapchain
152 /// configuration, renderer. Kept in one place because the two entry points
153 /// differ only in whether they attach an AccessKit adapter, and sixty
154 /// duplicated lines of GPU setup is exactly the sort of thing that drifts.
155 async fn surface_and_renderer(
156 window: &Arc<Window>,
157 ) -> (wgpu::Surface<'static>, wgpu::SurfaceConfiguration, Renderer) {
158 let size = window.inner_size();
159 let surface = shared_instance()
160 .create_surface(window.clone())
161 .expect("wgpu surface creation failed for the platform window");
162
163 let gpu = shared_gpu_for(&surface).await;
164
165 let surface_caps = surface.get_capabilities(&gpu.adapter);
166 // Guard the index accesses: a degenerate adapter/surface (software
167 // fallback, headless) can report empty `formats` / `alpha_modes`, and
168 // `[0]` would panic with an opaque out-of-bounds instead of degrading.
169 let surface_format = surface_caps
170 .formats
171 .iter()
172 .find(|f| f.is_srgb())
173 .copied()
174 .or_else(|| surface_caps.formats.first().copied())
175 .unwrap_or(wgpu::TextureFormat::Rgba8UnormSrgb);
176
177 let surface_config = wgpu::SurfaceConfiguration {
178 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
179 format: surface_format,
180 width: size.width.max(1),
181 height: size.height.max(1),
182 present_mode: wgpu::PresentMode::Fifo,
183 alpha_mode: surface_caps
184 .alpha_modes
185 .first()
186 .copied()
187 .unwrap_or(wgpu::CompositeAlphaMode::Auto),
188 view_formats: vec![],
189 desired_maximum_frame_latency: 2,
190 // `Auto` reproduces wgpu's pre-30 behaviour: sRGB for the
191 // non-`Rgba16Float` formats we select above.
192 color_space: wgpu::SurfaceColorSpace::Auto,
193 };
194 surface.configure(&gpu.device, &surface_config);
195
196 // The renderer stays per-window: it owns the glyph atlas, the path
197 // atlas and the blur pool, and it is `!Sync` besides.
198 let renderer = Renderer::new(gpu.device, gpu.queue, surface_format);
199 (surface, surface_config, renderer)
200 }
201
202 /// Create a new platform window from a winit window.
203 /// The `event_loop` parameter is needed for the AccessKit adapter.
204 pub async fn new_with_a11y(
205 window: Window,
206 event_loop: &winit::event_loop::ActiveEventLoop,
207 ) -> Self {
208 let window = Arc::new(window);
209 let scale_factor = window.scale_factor();
210 let (surface, surface_config, renderer) = Self::surface_and_renderer(&window).await;
211
212 // Create AccessKit adapter with action channel
213 let (action_tx, action_rx) = mpsc::channel();
214
215 let a11y_needs_full_tree = Arc::new(AtomicBool::new(true));
216 let a11y_adapter = accesskit_winit::Adapter::with_direct_handlers(
217 event_loop,
218 &window,
219 TeksiloActivationHandler {
220 needs_full_tree: a11y_needs_full_tree.clone(),
221 },
222 TeksiloActionHandler { tx: action_tx },
223 TeksiloDeactivationHandler,
224 );
225
226 // Show the window now that the adapter is created
227 window.set_visible(true);
228
229 Self {
230 window,
231 surface,
232 surface_config,
233 renderer,
234 scale_factor,
235 a11y_adapter: Some(a11y_adapter),
236 a11y_action_rx: action_rx,
237 a11y_needs_full_tree,
238 }
239 }
240
241 /// Create a platform window without AccessKit (for contexts without ActiveEventLoop).
242 pub async fn new(window: Window) -> Self {
243 let window = Arc::new(window);
244 let scale_factor = window.scale_factor();
245 let (surface, surface_config, renderer) = Self::surface_and_renderer(&window).await;
246 let (_action_tx, action_rx) = mpsc::channel();
247
248 Self {
249 window,
250 surface,
251 surface_config,
252 renderer,
253 scale_factor,
254 a11y_adapter: None,
255 a11y_action_rx: action_rx,
256 a11y_needs_full_tree: Arc::new(AtomicBool::new(false)),
257 }
258 }
259
260 pub fn window(&self) -> &Window {
261 &self.window
262 }
263
264 /// Get a clonable `Arc` reference to the underlying winit window.
265 /// Used by `teksilo_platform::create_title_bar_host` and other components
266 /// that need shared ownership of the window.
267 pub fn window_arc(&self) -> Arc<Window> {
268 self.window.clone()
269 }
270
271 pub fn renderer(&self) -> &Renderer {
272 &self.renderer
273 }
274
275 pub fn renderer_mut(&mut self) -> &mut Renderer {
276 &mut self.renderer
277 }
278
279 pub fn scale_factor(&self) -> f64 {
280 self.scale_factor
281 }
282
283 pub fn set_scale_factor(&mut self, factor: f64) {
284 self.scale_factor = factor;
285 }
286
287 /// Resize the surface.
288 pub fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
289 if new_size.width > 0 && new_size.height > 0 {
290 self.surface_config.width = new_size.width;
291 self.surface_config.height = new_size.height;
292 self.surface
293 .configure(self.renderer.device(), &self.surface_config);
294 }
295 }
296
297 /// Get current surface dimensions.
298 pub fn surface_size(&self) -> (u32, u32) {
299 (self.surface_config.width, self.surface_config.height)
300 }
301
302 /// Reconfigure the surface with the current config.
303 /// Use after a Lost or Outdated surface error.
304 pub fn reconfigure_surface(&mut self) {
305 self.surface
306 .configure(self.renderer.device(), &self.surface_config);
307 }
308
309 /// Render a frame to the surface.
310 pub fn render_frame(
311 &mut self,
312 frame: &teksilo_canvas::RenderFrame,
313 clear_color: [f32; 4],
314 ) -> FrameOutcome {
315 let current = self.surface.get_current_texture();
316 let output = match current {
317 wgpu::CurrentSurfaceTexture::Success(tex)
318 | wgpu::CurrentSurfaceTexture::Suboptimal(tex) => tex,
319 wgpu::CurrentSurfaceTexture::Occluded | wgpu::CurrentSurfaceTexture::Timeout => {
320 return FrameOutcome::Skipped;
321 }
322 wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
323 return FrameOutcome::NeedsReconfigure;
324 }
325 other => return FrameOutcome::Error(SurfaceRenderError(format!("{other:?}"))),
326 };
327
328 let view = output
329 .texture
330 .create_view(&wgpu::TextureViewDescriptor::default());
331
332 let (w, h) = self.surface_size();
333 self.renderer
334 .render(frame, &view, self.scale_factor as f32, w, h, clear_color);
335
336 self.renderer.queue().present(output);
337 FrameOutcome::Rendered
338 }
339
340 /// Render `frame` into an offscreen texture and read it back as
341 /// tightly-packed RGBA8 bytes, returning `(rgba, width, height)`.
342 ///
343 /// Used by the debug-only automation bridge to capture a *live* window
344 /// without going through the swapchain — the surface texture is
345 /// configured `RENDER_ATTACHMENT` only (no `COPY_SRC`), so it can't be
346 /// read back directly. The offscreen texture uses the window's own
347 /// surface format so it matches the renderer's pipelines; a BGRA
348 /// readback is swizzled to RGBA here so the output is always RGBA. With
349 /// `crop = Some(rect)` (physical pixels, clamped to the surface) only
350 /// that sub-rectangle is returned. Returns an empty `(vec, 0, 0)` if
351 /// the crop is fully outside the surface.
352 ///
353 /// Note: a native `WebView` subview composites *on top of* the wgpu
354 /// surface and is invisible to this readback (a transparent hole).
355 pub fn capture_offscreen(
356 &mut self,
357 frame: &teksilo_canvas::RenderFrame,
358 clear_color: [f32; 4],
359 crop: Option<teksilo_canvas::Rect>,
360 ) -> (Vec<u8>, u32, u32) {
361 fn crop_rgba(
362 src: &[u8],
363 w: u32,
364 h: u32,
365 rect: teksilo_canvas::Rect,
366 ) -> (Vec<u8>, u32, u32) {
367 let x0 = (rect.x.floor().max(0.0) as u32).min(w);
368 let y0 = (rect.y.floor().max(0.0) as u32).min(h);
369 let x1 = ((rect.x + rect.width).ceil().max(0.0) as u32).min(w);
370 let y1 = ((rect.y + rect.height).ceil().max(0.0) as u32).min(h);
371 if x1 <= x0 || y1 <= y0 {
372 return (Vec::new(), 0, 0);
373 }
374 let cw = x1 - x0;
375 let ch = y1 - y0;
376 let mut out = Vec::with_capacity((cw * ch * 4) as usize);
377 for y in y0..y1 {
378 let row_start = ((y * w + x0) * 4) as usize;
379 let row_end = row_start + (cw * 4) as usize;
380 out.extend_from_slice(&src[row_start..row_end]);
381 }
382 (out, cw, ch)
383 }
384
385 let (w, h) = self.surface_size();
386 let format = self.surface_config.format;
387 // The readback assumes a 4-byte, 8-bit RGBA/BGRA layout (the BGRA
388 // swizzle below + `read_texture_rgba`'s fixed 4-bytes-per-pixel copy).
389 // Desktop wgpu surfaces are always one of these four; a packed
390 // (Rgb10a2) or wide (Rgba16Float) surface format would read back
391 // garbage, so flag it loudly in debug builds.
392 debug_assert!(
393 matches!(
394 format,
395 wgpu::TextureFormat::Rgba8Unorm
396 | wgpu::TextureFormat::Rgba8UnormSrgb
397 | wgpu::TextureFormat::Bgra8Unorm
398 | wgpu::TextureFormat::Bgra8UnormSrgb
399 ),
400 "capture_offscreen: unsupported surface format {format:?} (expected 8-bit RGBA/BGRA)"
401 );
402 let texture = self
403 .renderer
404 .device()
405 .create_texture(&wgpu::TextureDescriptor {
406 label: Some("teksilo-automation capture"),
407 size: wgpu::Extent3d {
408 width: w,
409 height: h,
410 depth_or_array_layers: 1,
411 },
412 mip_level_count: 1,
413 sample_count: 1,
414 dimension: wgpu::TextureDimension::D2,
415 format,
416 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
417 view_formats: &[],
418 });
419 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
420 self.renderer
421 .render(frame, &view, self.scale_factor as f32, w, h, clear_color);
422 let mut bytes = teksilo_render::test_support::read_texture_rgba(
423 self.renderer.device(),
424 self.renderer.queue(),
425 &texture,
426 w,
427 h,
428 );
429 // `read_texture_rgba` copies raw channel bytes; a BGRA surface
430 // needs its B/R swapped to become RGBA for PNG encoding.
431 if matches!(
432 format,
433 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
434 ) {
435 for px in bytes.as_chunks_mut::<4>().0 {
436 px.swap(0, 2);
437 }
438 }
439 match crop {
440 Some(rect) => crop_rgba(&bytes, w, h, rect),
441 None => (bytes, w, h),
442 }
443 }
444
445 pub fn request_redraw(&self) {
446 self.window.request_redraw();
447 }
448
449 /// Push an AccessKit TreeUpdate to the adapter (called after layout).
450 pub fn update_accessibility(&mut self, update: accesskit::TreeUpdate) {
451 if let Some(adapter) = &mut self.a11y_adapter {
452 adapter.update_if_active(|| update);
453 }
454 }
455
456 /// Push an update the adapter builds only when it is actually going to
457 /// be delivered, and only when `build` says there is one worth sending.
458 ///
459 /// The caller decides *inside* the closure, because that is where the
460 /// decision belongs: `update_if_active` runs its closure only when an
461 /// assistive technology is attached, and on Linux it runs it under the
462 /// adapter's own state lock. Deciding outside would build a tree for
463 /// nobody on every frame, and would make the throttle count frames
464 /// nothing was listening to.
465 ///
466 /// `build` returning `None` means "nothing to deliver"; the previously
467 /// delivered tree is re-sent, which the consumer treats as a no-op.
468 pub fn update_accessibility_with(
469 &mut self,
470 build: impl FnOnce() -> Option<accesskit::TreeUpdate>,
471 previous: impl FnOnce() -> accesskit::TreeUpdate,
472 ) {
473 if let Some(adapter) = &mut self.a11y_adapter {
474 adapter.update_if_active(|| build().unwrap_or_else(previous));
475 }
476 }
477
478 /// Whether an assistive technology has asked this window for its tree
479 /// and has not yet been given a full one.
480 ///
481 /// Set by the activation handler, which runs on whichever thread the
482 /// platform's accessibility layer calls it from, and cleared by the
483 /// first delivery after it — so a reader that attaches mid-session gets
484 /// a complete tree rather than a geometry patch onto a tree it has
485 /// never seen.
486 pub fn accessibility_needs_full_tree(&self) -> bool {
487 self.a11y_needs_full_tree.load(Ordering::Relaxed)
488 }
489
490 /// Clear the flag above, reporting what it was.
491 pub fn take_accessibility_needs_full_tree(&self) -> bool {
492 self.a11y_needs_full_tree.swap(false, Ordering::Relaxed)
493 }
494
495 /// Forward a winit WindowEvent to the AccessKit adapter.
496 pub fn process_accessibility_event(&mut self, event: &WindowEvent) {
497 if let Some(adapter) = &mut self.a11y_adapter {
498 adapter.process_event(&self.window, event);
499 }
500 }
501
502 /// Drain any pending AccessKit action requests from the adapter.
503 pub fn drain_accessibility_actions(&self) -> Vec<ActionRequest> {
504 let mut actions = Vec::new();
505 while let Ok(req) = self.a11y_action_rx.try_recv() {
506 actions.push(req);
507 }
508 actions
509 }
510}
511
512// --- AccessKit handler implementations ---
513
514/// Activation handler — returns an empty initial tree.
515/// The real tree is sent via `update_if_active` on the next frame.
516///
517/// The flag is what makes that next frame send a *full* tree: deliveries
518/// are otherwise throttled to the moves-only rate, and a reader that
519/// attaches mid-session would be handed a geometry patch onto a tree
520/// consisting of one empty window.
521struct TeksiloActivationHandler {
522 needs_full_tree: Arc<AtomicBool>,
523}
524
525impl accesskit::ActivationHandler for TeksiloActivationHandler {
526 fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
527 self.needs_full_tree.store(true, Ordering::Relaxed);
528 // Return a minimal tree; the real one arrives on the next frame
529 let root = accesskit::Node::new(accesskit::Role::Window);
530 Some(accesskit::TreeUpdate {
531 nodes: vec![(accesskit::NodeId(0), root)],
532 tree: Some(accesskit::TreeInfo::new(accesskit::NodeId(0))),
533 tree_id: accesskit::TreeId::ROOT,
534 focus: accesskit::NodeId(0),
535 })
536 }
537}
538
539/// Action handler — forwards action requests to the main thread via a channel.
540struct TeksiloActionHandler {
541 tx: mpsc::Sender<ActionRequest>,
542}
543
544impl accesskit::ActionHandler for TeksiloActionHandler {
545 fn do_action(&mut self, request: ActionRequest) {
546 let _ = self.tx.send(request);
547 }
548}
549
550/// Deactivation handler — no-op.
551struct TeksiloDeactivationHandler;
552
553impl accesskit::DeactivationHandler for TeksiloDeactivationHandler {
554 fn deactivate_accessibility(&mut self) {
555 // Nothing to clean up
556 }
557}