Skip to main content

truce_gpu/
backend.rs

1//! GPU rendering backend using wgpu.
2//!
3//! Renders via Metal (macOS), DX12 (Windows), or Vulkan (Linux).
4//! Uses immediate-mode geometry: each frame rebuilds the vertex buffer
5//! from `RenderBackend` draw calls, then flushes in `present()`.
6
7use std::collections::HashMap;
8#[cfg(target_os = "macos")]
9use std::ffi::c_void;
10use std::sync::Arc;
11
12use bytemuck::{Pod, Zeroable};
13use lyon_tessellation::geom::point;
14use lyon_tessellation::path::Path;
15use lyon_tessellation::{
16    BuffersBuilder, FillOptions, FillTessellator, FillVertex, StrokeOptions, StrokeTessellator,
17    StrokeVertex, VertexBuffers,
18};
19use wgpu::util::DeviceExt;
20
21use truce_core::cast::len_u32;
22use truce_gui::render::{ImageId, RenderBackend};
23use truce_gui::theme::Color;
24
25// ---------------------------------------------------------------------------
26// Vertex format
27// ---------------------------------------------------------------------------
28
29#[repr(C)]
30#[derive(Copy, Clone, Pod, Zeroable)]
31struct Vertex {
32    position: [f32; 2],
33    color: [f32; 4],
34    uv: [f32; 2],
35    /// 0.0 = solid color; 1.0 = glyph atlas (R8, .r is alpha);
36    /// 2.0 = RGBA image (tex * color, both premultiplied).
37    tex_mode: f32,
38    _pad: f32,
39}
40
41impl Vertex {
42    fn solid(x: f32, y: f32, color: [f32; 4]) -> Self {
43        Self {
44            position: [x, y],
45            color,
46            uv: [0.0, 0.0],
47            tex_mode: 0.0,
48            _pad: 0.0,
49        }
50    }
51
52    fn glyph(x: f32, y: f32, color: [f32; 4], u: f32, v: f32) -> Self {
53        Self {
54            position: [x, y],
55            color,
56            uv: [u, v],
57            tex_mode: 1.0,
58            _pad: 0.0,
59        }
60    }
61
62    fn image(x: f32, y: f32, color: [f32; 4], u: f32, v: f32) -> Self {
63        Self {
64            position: [x, y],
65            color,
66            uv: [u, v],
67            tex_mode: 2.0,
68            _pad: 0.0,
69        }
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Glyph atlas
75// ---------------------------------------------------------------------------
76
77const ATLAS_SIZE: u32 = 512;
78
79struct GlyphUV {
80    u0: f32,
81    v0: f32,
82    u1: f32,
83    v1: f32,
84    advance: f32,
85    width: f32,
86    height: f32,
87    y_offset: f32,
88}
89
90struct GlyphAtlas {
91    /// Shelf-packing state.
92    shelf_y: u32,
93    shelf_h: u32,
94    cursor_x: u32,
95    /// Cached glyph UVs keyed by (char, `size_tenths`).
96    glyphs: HashMap<(char, u32), GlyphUV>,
97    /// Pending pixel uploads: (x, y, w, h, data).
98    pending: Vec<(u32, u32, u32, u32, Vec<u8>)>,
99    /// Set when `ensure_glyph` couldn't fit a new glyph. The next call to
100    /// `WgpuBackend::clear` evicts the cache so subsequent frames can
101    /// re-rasterize from scratch - never mid-frame, which would invalidate
102    /// UVs the current frame's vertex buffer already references.
103    overflow_pending: bool,
104}
105
106impl GlyphAtlas {
107    fn new() -> Self {
108        Self {
109            shelf_y: 0,
110            shelf_h: 0,
111            cursor_x: 0,
112            glyphs: HashMap::new(),
113            pending: Vec::new(),
114            overflow_pending: false,
115        }
116    }
117
118    fn clear(&mut self) {
119        self.shelf_y = 0;
120        self.shelf_h = 0;
121        self.cursor_x = 0;
122        self.glyphs.clear();
123        self.overflow_pending = false;
124    }
125
126    /// Try to place a glyph in the atlas. On overflow, sets
127    /// `overflow_pending` and returns without inserting; caller must
128    /// tolerate a missing entry for the rest of this frame. Subsequent
129    /// frames clear the atlas at frame start and re-rasterize.
130    // Quantized cache key - `(size * 10.0) as u32` deliberately
131    // truncates to one decimal place for HashMap stability. Atlas
132    // dimensions and glyph metrics fit comfortably in f32.
133    #[allow(
134        clippy::cast_possible_truncation,
135        clippy::cast_sign_loss,
136        clippy::cast_precision_loss
137    )]
138    fn ensure_glyph(&mut self, font: &fontdue::Font, ch: char, size: f32) {
139        let key = (ch, (size * 10.0) as u32);
140        if self.glyphs.contains_key(&key) {
141            return;
142        }
143        let (metrics, bitmap) = font.rasterize(ch, size);
144        let gw = len_u32(metrics.width);
145        let gh = len_u32(metrics.height);
146
147        // Shelf-pack: does it fit on the current shelf?
148        if self.cursor_x + gw > ATLAS_SIZE {
149            self.shelf_y += self.shelf_h;
150            self.shelf_h = 0;
151            self.cursor_x = 0;
152        }
153        if self.shelf_y + gh > ATLAS_SIZE {
154            // Atlas full. Calling self.clear() here would wipe entries
155            // the current frame's vertex buffer still references and
156            // evict glyphs that earlier draw_text iterations expect to
157            // look up - at best wrong UVs, at worst a HashMap lookup
158            // panic. Defer the clear to the next frame boundary.
159            self.overflow_pending = true;
160            return;
161        }
162
163        let x = self.cursor_x;
164        let y = self.shelf_y;
165        self.cursor_x += gw;
166        self.shelf_h = self.shelf_h.max(gh);
167
168        let u0 = x as f32 / ATLAS_SIZE as f32;
169        let v0 = y as f32 / ATLAS_SIZE as f32;
170        let u1 = (x + gw) as f32 / ATLAS_SIZE as f32;
171        let v1 = (y + gh) as f32 / ATLAS_SIZE as f32;
172
173        self.pending.push((x, y, gw, gh, bitmap));
174
175        self.glyphs.insert(
176            key,
177            GlyphUV {
178                u0,
179                v0,
180                u1,
181                v1,
182                advance: metrics.advance_width,
183                width: gw as f32,
184                height: gh as f32,
185                y_offset: metrics.ymin as f32,
186            },
187        );
188    }
189}
190
191// ---------------------------------------------------------------------------
192// WGSL shader
193// ---------------------------------------------------------------------------
194
195const SHADER_SRC: &str = r"
196struct Viewport {
197    transform: mat4x4<f32>,
198};
199@group(0) @binding(0) var<uniform> viewport: Viewport;
200
201// At group 1 slot 0 we bind either the R8 glyph atlas (tex_mode == 1.0)
202// or an RGBA image (tex_mode == 2.0). For solid draws (tex_mode == 0.0)
203// the texture is not sampled; any compatible binding works.
204@group(1) @binding(0) var main_tex: texture_2d<f32>;
205@group(1) @binding(1) var main_samp: sampler;
206
207struct VsIn {
208    @location(0) position: vec2<f32>,
209    @location(1) color: vec4<f32>,
210    @location(2) uv: vec2<f32>,
211    @location(3) tex_mode: f32,
212};
213
214struct VsOut {
215    @builtin(position) clip_pos: vec4<f32>,
216    @location(0) color: vec4<f32>,
217    @location(1) uv: vec2<f32>,
218    @location(2) tex_mode: f32,
219};
220
221@vertex
222fn vs_main(in: VsIn) -> VsOut {
223    var out: VsOut;
224    out.clip_pos = viewport.transform * vec4<f32>(in.position, 0.0, 1.0);
225    out.color = in.color;
226    out.uv = in.uv;
227    out.tex_mode = in.tex_mode;
228    return out;
229}
230
231@fragment
232fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
233    let tex = textureSample(main_tex, main_samp, in.uv);
234    if (in.tex_mode > 1.5) {
235        // Image: RGBA texture tinted by vertex color. Both sides are
236        // treated as premultiplied; output is premultiplied.
237        return tex * in.color;
238    }
239    // Glyph (tex_mode == 1) uses .r as coverage; solid (tex_mode == 0)
240    // bypasses the sample. mix(1.0, tex.r, tex_mode) handles both.
241    let alpha = mix(1.0, tex.r, in.tex_mode);
242    return vec4<f32>(in.color.rgb * in.color.a * alpha, in.color.a * alpha);
243}
244";
245
246// ---------------------------------------------------------------------------
247// WgpuBackend
248// ---------------------------------------------------------------------------
249
250/// One image registered via `register_image`. Owns its wgpu texture
251/// (kept alive so the bind group's view stays valid) and the bind group.
252struct ImageEntry {
253    _texture: wgpu::Texture,
254    bind_group: wgpu::BindGroup,
255}
256
257/// A contiguous run of indices that share a single bind group.
258///
259/// `image` is `None` for primitives and glyphs (which use the atlas bind
260/// group) and `Some(id)` for RGBA image draws. Batches are closed and a
261/// new one started whenever the target bind group changes.
262#[derive(Clone, Copy)]
263struct DrawBatch {
264    index_start: u32,
265    image: Option<ImageId>,
266}
267
268/// GPU-based rendering backend.
269///
270/// Creates a wgpu device and surface from a platform-provided Metal layer
271/// (macOS) or window handle. Implements `RenderBackend` by accumulating
272/// geometry per frame, then flushing it in `present()`.
273pub struct WgpuBackend {
274    device: Arc<wgpu::Device>,
275    queue: Arc<wgpu::Queue>,
276    /// None for headless mode (snapshot testing) or when using the
277    /// standalone `new()` constructor (caller owns the surface). When
278    /// present, `present()` renders to the surface frame.
279    surface: Option<wgpu::Surface<'static>>,
280    surface_config: Option<wgpu::SurfaceConfiguration>,
281    pipeline: wgpu::RenderPipeline,
282    /// Format of the eventual color target. Used to (re)build the MSAA
283    /// texture on resize / `begin_frame` size changes.
284    target_format: wgpu::TextureFormat,
285    msaa_texture: wgpu::TextureView,
286    /// Current physical dimensions of the MSAA texture. `begin_frame`
287    /// rebuilds the texture if these no longer match the target view.
288    msaa_width: u32,
289    msaa_height: u32,
290    vertices: Vec<Vertex>,
291    indices: Vec<u32>,
292    /// Ordered list of bind-group switches within the current frame. Always
293    /// starts with one batch referencing the atlas; additional entries are
294    /// appended when `draw_image` needs to switch to an image bind group.
295    batches: Vec<DrawBatch>,
296    glyph_atlas: GlyphAtlas,
297    font: fontdue::Font,
298    atlas_texture: wgpu::Texture,
299    atlas_bind_group: wgpu::BindGroup,
300    /// Layout shared between the atlas bind group and every per-image
301    /// bind group (same `texture2d<f32>` + sampler layout).
302    tex_bind_group_layout: wgpu::BindGroupLayout,
303    /// Shared linear sampler used for both the glyph atlas and images.
304    sampler: wgpu::Sampler,
305    /// Registered images indexed by `ImageId.0`. `None` = free slot.
306    images: Vec<Option<ImageEntry>>,
307    viewport_buffer: wgpu::Buffer,
308    viewport_bind_group: wgpu::BindGroup,
309    /// Pending clear request for the next render pass. `Some(c)` means
310    /// the next `finish()` clears the target to `c`; `None` means it
311    /// loads existing contents (the common case when widgets overlay a
312    /// custom render). Set by [`RenderBackend::clear`] and consumed by
313    /// `finish()` / `present()`.
314    clear_color: Option<wgpu::Color>,
315    /// Fallback clear color for the present path (which can't `Load` -
316    /// the swap-chain texture would surface stale prior-frame content).
317    /// Used when `clear_color` is `None`. The Metal layer path defaults
318    /// to `TRANSPARENT` so the host's compositor sees through; other
319    /// backends default to `BLACK`.
320    present_clear_default: wgpu::Color,
321    width: u32,
322    height: u32,
323    /// Scale factor: logical points × scale = physical pixels.
324    scale: f32,
325}
326
327fn ortho_matrix(w: f32, h: f32) -> [[f32; 4]; 4] {
328    [
329        [2.0 / w, 0.0, 0.0, 0.0],
330        [0.0, -2.0 / h, 0.0, 0.0],
331        [0.0, 0.0, 1.0, 0.0],
332        [-1.0, 1.0, 0.0, 1.0],
333    ]
334}
335
336impl WgpuBackend {
337    /// Create a GPU backend from a pre-created wgpu surface.
338    ///
339    /// `logical_w` and `logical_h` are in logical points. `scale` is the
340    /// display scale factor (2.0 on Retina). The surface is configured at
341    /// `logical × scale` physical pixels.
342    ///
343    /// # Panics
344    ///
345    /// Panics if the embedded font fails to parse (a bug in the
346    /// bundled font asset, never user input).
347    // Surface dimensions in pixels stay below 2^23, well within f32.
348    #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
349    pub fn from_surface(
350        instance: &wgpu::Instance,
351        surface: wgpu::Surface<'static>,
352        logical_w: u32,
353        logical_h: u32,
354        scale: f32,
355    ) -> Option<Self> {
356        let width = truce_gui::to_physical_px(logical_w, f64::from(scale));
357        let height = truce_gui::to_physical_px(logical_h, f64::from(scale));
358
359        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
360            power_preference: wgpu::PowerPreference::HighPerformance,
361            compatible_surface: Some(&surface),
362            force_fallback_adapter: false,
363        }))
364        .ok()?;
365
366        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
367            label: Some("truce-gpu"),
368            required_features: wgpu::Features::empty(),
369            required_limits: wgpu::Limits::downlevel_defaults(),
370            experimental_features: wgpu::ExperimentalFeatures::default(),
371            memory_hints: wgpu::MemoryHints::Performance,
372            trace: wgpu::Trace::Off,
373        }))
374        .ok()?;
375        let device = Arc::new(device);
376        let queue = Arc::new(queue);
377
378        // Prefer `Rgba8Unorm` so the surface format matches
379        // `read_pixels` and the headless screenshot path; fall back to
380        // any non-sRGB format the surface advertises, then to whatever
381        // the surface lists first. Keeping the format aligned across
382        // windowed and headless paths means the same shader-side
383        // gamma/blend assumptions hold.
384        let surface_caps = surface.get_capabilities(&adapter);
385        let surface_format = surface_caps
386            .formats
387            .iter()
388            .find(|f| **f == wgpu::TextureFormat::Rgba8Unorm)
389            .or_else(|| surface_caps.formats.iter().find(|f| !f.is_srgb()))
390            .copied()
391            .unwrap_or(surface_caps.formats[0]);
392
393        let surface_config = wgpu::SurfaceConfiguration {
394            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
395            format: surface_format,
396            width,
397            height,
398            present_mode: wgpu::PresentMode::AutoVsync,
399            desired_maximum_frame_latency: 2,
400            alpha_mode: wgpu::CompositeAlphaMode::Auto,
401            view_formats: vec![],
402        };
403        surface.configure(&device, &surface_config);
404
405        // MSAA texture
406        let msaa_texture = Self::create_msaa_texture(&device, &surface_config);
407
408        // Shader
409        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
410            label: Some("truce-gpu-shader"),
411            source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
412        });
413
414        // Viewport uniform
415        let matrix = ortho_matrix(width as f32, height as f32);
416        let viewport_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
417            label: Some("viewport"),
418            contents: bytemuck::cast_slice(&matrix),
419            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
420        });
421
422        let viewport_bind_group_layout =
423            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
424                label: Some("viewport-layout"),
425                entries: &[wgpu::BindGroupLayoutEntry {
426                    binding: 0,
427                    visibility: wgpu::ShaderStages::VERTEX,
428                    ty: wgpu::BindingType::Buffer {
429                        ty: wgpu::BufferBindingType::Uniform,
430                        has_dynamic_offset: false,
431                        min_binding_size: None,
432                    },
433                    count: None,
434                }],
435            });
436
437        let viewport_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
438            label: Some("viewport-bg"),
439            layout: &viewport_bind_group_layout,
440            entries: &[wgpu::BindGroupEntry {
441                binding: 0,
442                resource: viewport_buffer.as_entire_binding(),
443            }],
444        });
445
446        // Atlas texture (R8Unorm, 512x512)
447        let atlas_texture = device.create_texture(&wgpu::TextureDescriptor {
448            label: Some("glyph-atlas"),
449            size: wgpu::Extent3d {
450                width: ATLAS_SIZE,
451                height: ATLAS_SIZE,
452                depth_or_array_layers: 1,
453            },
454            mip_level_count: 1,
455            sample_count: 1,
456            dimension: wgpu::TextureDimension::D2,
457            format: wgpu::TextureFormat::R8Unorm,
458            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
459            view_formats: &[],
460        });
461
462        let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
463        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
464            mag_filter: wgpu::FilterMode::Linear,
465            min_filter: wgpu::FilterMode::Linear,
466            ..Default::default()
467        });
468
469        let tex_bind_group_layout =
470            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
471                label: Some("tex-layout"),
472                entries: &[
473                    wgpu::BindGroupLayoutEntry {
474                        binding: 0,
475                        visibility: wgpu::ShaderStages::FRAGMENT,
476                        ty: wgpu::BindingType::Texture {
477                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
478                            view_dimension: wgpu::TextureViewDimension::D2,
479                            multisampled: false,
480                        },
481                        count: None,
482                    },
483                    wgpu::BindGroupLayoutEntry {
484                        binding: 1,
485                        visibility: wgpu::ShaderStages::FRAGMENT,
486                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
487                        count: None,
488                    },
489                ],
490            });
491
492        let atlas_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
493            label: Some("atlas-bg"),
494            layout: &tex_bind_group_layout,
495            entries: &[
496                wgpu::BindGroupEntry {
497                    binding: 0,
498                    resource: wgpu::BindingResource::TextureView(&atlas_view),
499                },
500                wgpu::BindGroupEntry {
501                    binding: 1,
502                    resource: wgpu::BindingResource::Sampler(&sampler),
503                },
504            ],
505        });
506
507        // Pipeline
508        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
509            label: Some("truce-gpu-pipeline-layout"),
510            bind_group_layouts: &[
511                Some(&viewport_bind_group_layout),
512                Some(&tex_bind_group_layout),
513            ],
514            immediate_size: 0,
515        });
516
517        let vertex_layout = wgpu::VertexBufferLayout {
518            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
519            step_mode: wgpu::VertexStepMode::Vertex,
520            attributes: &[
521                // position
522                wgpu::VertexAttribute {
523                    offset: 0,
524                    shader_location: 0,
525                    format: wgpu::VertexFormat::Float32x2,
526                },
527                // color
528                wgpu::VertexAttribute {
529                    offset: 8,
530                    shader_location: 1,
531                    format: wgpu::VertexFormat::Float32x4,
532                },
533                // uv
534                wgpu::VertexAttribute {
535                    offset: 24,
536                    shader_location: 2,
537                    format: wgpu::VertexFormat::Float32x2,
538                },
539                // tex_mode
540                wgpu::VertexAttribute {
541                    offset: 32,
542                    shader_location: 3,
543                    format: wgpu::VertexFormat::Float32,
544                },
545            ],
546        };
547
548        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
549            label: Some("truce-gpu-pipeline"),
550            layout: Some(&pipeline_layout),
551            vertex: wgpu::VertexState {
552                module: &shader,
553                entry_point: Some("vs_main"),
554                buffers: &[vertex_layout],
555                compilation_options: wgpu::PipelineCompilationOptions::default(),
556            },
557            fragment: Some(wgpu::FragmentState {
558                module: &shader,
559                entry_point: Some("fs_main"),
560                targets: &[Some(wgpu::ColorTargetState {
561                    format: surface_format,
562                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
563                    write_mask: wgpu::ColorWrites::ALL,
564                })],
565                compilation_options: wgpu::PipelineCompilationOptions::default(),
566            }),
567            primitive: wgpu::PrimitiveState {
568                topology: wgpu::PrimitiveTopology::TriangleList,
569                strip_index_format: None,
570                front_face: wgpu::FrontFace::Ccw,
571                cull_mode: None,
572                unclipped_depth: false,
573                polygon_mode: wgpu::PolygonMode::Fill,
574                conservative: false,
575            },
576            depth_stencil: None,
577            multisample: wgpu::MultisampleState {
578                count: 4,
579                mask: !0,
580                alpha_to_coverage_enabled: false,
581            },
582            multiview_mask: None,
583            cache: None,
584        });
585
586        // Font
587        let font = fontdue::Font::from_bytes(
588            truce_gui::font::JETBRAINS_MONO,
589            fontdue::FontSettings::default(),
590        )
591        .expect("failed to parse embedded font");
592
593        Some(Self {
594            device,
595            queue,
596            surface: Some(surface),
597            surface_config: Some(surface_config),
598            pipeline,
599            target_format: surface_format,
600            msaa_texture,
601            msaa_width: width,
602            msaa_height: height,
603            vertices: Vec::with_capacity(4096),
604            indices: Vec::with_capacity(8192),
605            batches: Vec::new(),
606            glyph_atlas: GlyphAtlas::new(),
607            font,
608            atlas_texture,
609            atlas_bind_group,
610            tex_bind_group_layout,
611            sampler,
612            images: Vec::new(),
613            viewport_buffer,
614            viewport_bind_group,
615            clear_color: None,
616            present_clear_default: wgpu::Color::BLACK,
617            width,
618            height,
619            scale,
620        })
621    }
622
623    /// Create a GPU backend from a raw `CAMetalLayer` pointer (macOS).
624    ///
625    /// `logical_w` / `logical_h` are in logical points; `scale` is the
626    /// layer's `contentsScale` (2.0 on Retina). The surface is
627    /// configured at `logical × scale` physical pixels, matching the
628    /// contract of [`Self::from_surface`] / [`Self::from_window`].
629    ///
630    /// # Safety
631    /// `metal_layer` must be a valid `CAMetalLayer*` that outlives the backend.
632    #[cfg(target_os = "macos")]
633    pub unsafe fn from_metal_layer(
634        metal_layer: *mut c_void,
635        logical_w: u32,
636        logical_h: u32,
637        scale: f32,
638    ) -> Option<Self> {
639        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
640        desc.backends = wgpu::Backends::METAL;
641        let instance = wgpu::Instance::new(desc);
642
643        let surface = unsafe {
644            instance
645                .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(metal_layer))
646        }
647        .ok()?;
648
649        Self::from_surface(&instance, surface, logical_w, logical_h, scale)
650    }
651
652    /// Create a GPU backend from a baseview window handle. baseview
653    /// is the macOS / Windows / Linux windowing layer - iOS does not
654    /// compile this constructor (the iOS editor builds its surface
655    /// directly from a `CAMetalLayer` attached to a `UIView`).
656    ///
657    /// # Safety
658    /// The window must remain valid for the lifetime of the backend.
659    #[cfg(not(target_os = "ios"))]
660    #[must_use]
661    pub unsafe fn from_window(
662        window: &baseview::Window,
663        logical_w: u32,
664        logical_h: u32,
665        scale: f32,
666    ) -> Option<Self> {
667        unsafe {
668            let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
669            desc.backends = wgpu::Backends::PRIMARY;
670            let instance = wgpu::Instance::new(desc);
671
672            let surface = crate::platform::create_wgpu_surface(&instance, window)?;
673            Self::from_surface(&instance, surface, logical_w, logical_h, scale)
674        }
675    }
676
677    /// Build a standalone `WgpuBackend` that records into encoders
678    /// supplied per-frame by the caller.
679    ///
680    /// Unlike [`Self::from_surface`] / `from_metal_layer` / [`Self::from_window`],
681    /// this constructor does **not** own a `wgpu::Surface` or manage
682    /// frame acquisition. The caller is expected to have its own render
683    /// loop, allocate command encoders, and present - this backend only
684    /// supplies the 2D widget pipeline, glyph atlas, and lyon-tessellated
685    /// primitive recording.
686    ///
687    /// Usage:
688    ///
689    /// ```ignore
690    /// let mut backend = WgpuBackend::new(
691    ///     device.clone(), queue.clone(),
692    ///     target_format, max_w, max_h,
693    /// ).expect("backend init");
694    ///
695    /// // per-frame, after the caller has drawn its own content into `view`:
696    /// backend.begin_frame(w, h);
697    /// truce_gui::widgets::draw(&mut backend, &layout, &theme, &snap, &mut state);
698    /// backend.finish(&mut encoder, &view);
699    /// // caller submits encoder + presents.
700    /// ```
701    ///
702    /// `max_logical_w` / `max_logical_h` are in logical points; `scale`
703    /// is the display scale factor (2.0 on Retina, 1.0 otherwise). The
704    /// MSAA texture is seeded at `logical × scale` physical pixels; if
705    /// a subsequent `begin_frame(logical_w, logical_h)` exceeds the
706    /// seed, the MSAA texture is reallocated transparently.
707    ///
708    /// Matches the coordinate contract of [`Self::from_surface`] /
709    /// [`Self::from_window`]: draw calls and event coordinates are logical
710    /// points; the backend multiplies by `scale` internally when
711    /// rasterizing.
712    ///
713    /// # Panics
714    ///
715    /// Panics if the embedded font fails to parse (bundled-asset
716    /// bug, never user input).
717    // Surface dimensions in pixels stay below 2^23, well within f32.
718    #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
719    #[must_use]
720    pub fn new(
721        device: Arc<wgpu::Device>,
722        queue: Arc<wgpu::Queue>,
723        target_format: wgpu::TextureFormat,
724        max_logical_w: u32,
725        max_logical_h: u32,
726        scale: f32,
727    ) -> Option<Self> {
728        let scale = scale.max(0.0);
729        let width = truce_gui::to_physical_px(max_logical_w, f64::from(scale));
730        let height = truce_gui::to_physical_px(max_logical_h, f64::from(scale));
731
732        // Shader
733        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
734            label: Some("truce-gpu-shader"),
735            source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
736        });
737
738        // Viewport uniform
739        let matrix = ortho_matrix(width as f32, height as f32);
740        let viewport_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
741            label: Some("viewport"),
742            contents: bytemuck::cast_slice(&matrix),
743            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
744        });
745
746        let viewport_bind_group_layout =
747            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
748                label: Some("viewport-layout"),
749                entries: &[wgpu::BindGroupLayoutEntry {
750                    binding: 0,
751                    visibility: wgpu::ShaderStages::VERTEX,
752                    ty: wgpu::BindingType::Buffer {
753                        ty: wgpu::BufferBindingType::Uniform,
754                        has_dynamic_offset: false,
755                        min_binding_size: None,
756                    },
757                    count: None,
758                }],
759            });
760
761        let viewport_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
762            label: Some("viewport-bg"),
763            layout: &viewport_bind_group_layout,
764            entries: &[wgpu::BindGroupEntry {
765                binding: 0,
766                resource: viewport_buffer.as_entire_binding(),
767            }],
768        });
769
770        // Glyph atlas
771        let atlas_texture = device.create_texture(&wgpu::TextureDescriptor {
772            label: Some("glyph-atlas"),
773            size: wgpu::Extent3d {
774                width: ATLAS_SIZE,
775                height: ATLAS_SIZE,
776                depth_or_array_layers: 1,
777            },
778            mip_level_count: 1,
779            sample_count: 1,
780            dimension: wgpu::TextureDimension::D2,
781            format: wgpu::TextureFormat::R8Unorm,
782            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
783            view_formats: &[],
784        });
785        let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
786        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
787            mag_filter: wgpu::FilterMode::Linear,
788            min_filter: wgpu::FilterMode::Linear,
789            ..Default::default()
790        });
791        let tex_bind_group_layout =
792            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
793                label: Some("tex-layout"),
794                entries: &[
795                    wgpu::BindGroupLayoutEntry {
796                        binding: 0,
797                        visibility: wgpu::ShaderStages::FRAGMENT,
798                        ty: wgpu::BindingType::Texture {
799                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
800                            view_dimension: wgpu::TextureViewDimension::D2,
801                            multisampled: false,
802                        },
803                        count: None,
804                    },
805                    wgpu::BindGroupLayoutEntry {
806                        binding: 1,
807                        visibility: wgpu::ShaderStages::FRAGMENT,
808                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
809                        count: None,
810                    },
811                ],
812            });
813        let atlas_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
814            label: Some("atlas-bg"),
815            layout: &tex_bind_group_layout,
816            entries: &[
817                wgpu::BindGroupEntry {
818                    binding: 0,
819                    resource: wgpu::BindingResource::TextureView(&atlas_view),
820                },
821                wgpu::BindGroupEntry {
822                    binding: 1,
823                    resource: wgpu::BindingResource::Sampler(&sampler),
824                },
825            ],
826        });
827
828        // Pipeline
829        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
830            label: Some("truce-gpu-pipeline-layout"),
831            bind_group_layouts: &[
832                Some(&viewport_bind_group_layout),
833                Some(&tex_bind_group_layout),
834            ],
835            immediate_size: 0,
836        });
837
838        let vertex_layout = wgpu::VertexBufferLayout {
839            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
840            step_mode: wgpu::VertexStepMode::Vertex,
841            attributes: &[
842                wgpu::VertexAttribute {
843                    offset: 0,
844                    shader_location: 0,
845                    format: wgpu::VertexFormat::Float32x2,
846                },
847                wgpu::VertexAttribute {
848                    offset: 8,
849                    shader_location: 1,
850                    format: wgpu::VertexFormat::Float32x4,
851                },
852                wgpu::VertexAttribute {
853                    offset: 24,
854                    shader_location: 2,
855                    format: wgpu::VertexFormat::Float32x2,
856                },
857                wgpu::VertexAttribute {
858                    offset: 32,
859                    shader_location: 3,
860                    format: wgpu::VertexFormat::Float32,
861                },
862            ],
863        };
864
865        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
866            label: Some("truce-gpu-pipeline"),
867            layout: Some(&pipeline_layout),
868            vertex: wgpu::VertexState {
869                module: &shader,
870                entry_point: Some("vs_main"),
871                buffers: &[vertex_layout],
872                compilation_options: wgpu::PipelineCompilationOptions::default(),
873            },
874            fragment: Some(wgpu::FragmentState {
875                module: &shader,
876                entry_point: Some("fs_main"),
877                targets: &[Some(wgpu::ColorTargetState {
878                    format: target_format,
879                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
880                    write_mask: wgpu::ColorWrites::ALL,
881                })],
882                compilation_options: wgpu::PipelineCompilationOptions::default(),
883            }),
884            primitive: wgpu::PrimitiveState {
885                topology: wgpu::PrimitiveTopology::TriangleList,
886                ..Default::default()
887            },
888            depth_stencil: None,
889            multisample: wgpu::MultisampleState {
890                count: 4,
891                mask: !0,
892                alpha_to_coverage_enabled: false,
893            },
894            multiview_mask: None,
895            cache: None,
896        });
897
898        // MSAA
899        let msaa_texture = Self::create_msaa_view(&device, target_format, width, height);
900
901        let font = fontdue::Font::from_bytes(
902            truce_gui::font::JETBRAINS_MONO,
903            fontdue::FontSettings::default(),
904        )
905        .expect("failed to parse embedded font");
906
907        Some(Self {
908            device,
909            queue,
910            surface: None,
911            surface_config: None,
912            pipeline,
913            target_format,
914            msaa_texture,
915            msaa_width: width,
916            msaa_height: height,
917            vertices: Vec::with_capacity(4096),
918            indices: Vec::with_capacity(8192),
919            batches: Vec::new(),
920            glyph_atlas: GlyphAtlas::new(),
921            font,
922            atlas_texture,
923            atlas_bind_group,
924            tex_bind_group_layout,
925            sampler,
926            images: Vec::new(),
927            viewport_buffer,
928            viewport_bind_group,
929            clear_color: None,
930            present_clear_default: wgpu::Color::TRANSPARENT,
931            width,
932            height,
933            scale,
934        })
935    }
936
937    /// Prepare for recording a frame of `logical_w × logical_h` logical
938    /// points. The MSAA target and ortho matrix are sized at
939    /// `logical × self.scale()` physical pixels; widget draw calls use
940    /// logical coordinates.
941    ///
942    /// Resets accumulated geometry and the clear flag. Rebuilds the MSAA
943    /// texture if the physical size differs from the previous frame.
944    ///
945    /// Only meaningful when the backend was built via [`Self::new`]; the
946    /// surface-owning constructors drive their own frame lifecycle.
947    #[allow(clippy::cast_precision_loss)]
948    pub fn begin_frame(&mut self, logical_w: u32, logical_h: u32) {
949        let phys_w = truce_gui::to_physical_px(logical_w, f64::from(self.scale));
950        let phys_h = truce_gui::to_physical_px(logical_h, f64::from(self.scale));
951        self.vertices.clear();
952        self.indices.clear();
953        self.batches.clear();
954        self.clear_color = None;
955
956        if phys_w != self.width || phys_h != self.height {
957            self.width = phys_w;
958            self.height = phys_h;
959            let matrix = ortho_matrix(phys_w as f32, phys_h as f32);
960            self.queue
961                .write_buffer(&self.viewport_buffer, 0, bytemuck::cast_slice(&matrix));
962        }
963
964        if phys_w != self.msaa_width || phys_h != self.msaa_height {
965            self.msaa_texture =
966                Self::create_msaa_view(&self.device, self.target_format, phys_w, phys_h);
967            self.msaa_width = phys_w;
968            self.msaa_height = phys_h;
969        }
970    }
971
972    /// Display scale factor: `logical × scale = physical`. Callers
973    /// sizing sibling GPU resources (e.g. an intermediate texture that
974    /// the backend will resolve into) should use this to stay
975    /// consistent with the backend's raster dimensions.
976    pub fn scale(&self) -> f32 {
977        self.scale
978    }
979
980    /// Update the display scale factor. The next [`Self::resize`] (or
981    /// [`Self::begin_frame`] in headless mode) recomputes physical
982    /// dimensions and reconfigures the surface / MSAA target. Callers
983    /// driving a windowed surface should follow with a `resize` so the
984    /// `surface_config` picks up the new size on the same frame; the
985    /// short-circuit in `resize` doesn't trigger because the scale
986    /// change makes the new physical dims differ from the old.
987    pub fn set_scale(&mut self, scale: f32) {
988        if scale.is_finite() && scale > 0.0 {
989            self.scale = scale;
990        }
991    }
992
993    /// Flush accumulated geometry into a single render pass on `view`,
994    /// recorded into `encoder`. The caller retains ownership of both -
995    /// this method neither submits the encoder nor calls `present()`.
996    ///
997    /// If `clear()` was called since the last `begin_frame`, the pass
998    /// uses `LoadOp::Clear(clear_color)`; otherwise `LoadOp::Load` so
999    /// any prior content in `view` is preserved (the common case when
1000    /// widgets overlay a custom render).
1001    pub fn finish(&mut self, encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
1002        self.flush_atlas();
1003
1004        if self.indices.is_empty() {
1005            self.clear_color = None;
1006            return;
1007        }
1008
1009        let vertex_buffer = self
1010            .device
1011            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1012                label: Some("vertices"),
1013                contents: bytemuck::cast_slice(&self.vertices),
1014                usage: wgpu::BufferUsages::VERTEX,
1015            });
1016
1017        let index_buffer = self
1018            .device
1019            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1020                label: Some("indices"),
1021                contents: bytemuck::cast_slice(&self.indices),
1022                usage: wgpu::BufferUsages::INDEX,
1023            });
1024
1025        // MSAA load/store must agree across frames: if we plan to `Load`
1026        // next pass, this pass must `Store` (otherwise the next pass loads
1027        // undefined contents). With a `resolve_target` set, `Discard` is
1028        // standard for MSAA - but it's only well-defined when we also
1029        // `Clear` on entry, since a fresh load after a discard is UB.
1030        let (load, store) = match self.clear_color {
1031            Some(c) => (wgpu::LoadOp::Clear(c), wgpu::StoreOp::Discard),
1032            None => (wgpu::LoadOp::Load, wgpu::StoreOp::Store),
1033        };
1034
1035        {
1036            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1037                label: Some("truce-gpu-frame"),
1038                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1039                    view: &self.msaa_texture,
1040                    resolve_target: Some(view),
1041                    ops: wgpu::Operations { load, store },
1042                    depth_slice: None,
1043                })],
1044                depth_stencil_attachment: None,
1045                timestamp_writes: None,
1046                occlusion_query_set: None,
1047                multiview_mask: None,
1048            });
1049
1050            pass.set_pipeline(&self.pipeline);
1051            pass.set_bind_group(0, &self.viewport_bind_group, &[]);
1052            pass.set_vertex_buffer(0, vertex_buffer.slice(..));
1053            pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1054
1055            let total_indices = len_u32(self.indices.len());
1056            if self.batches.is_empty() {
1057                pass.set_bind_group(1, &self.atlas_bind_group, &[]);
1058                pass.draw_indexed(0..total_indices, 0, 0..1);
1059            } else {
1060                for i in 0..self.batches.len() {
1061                    let b = self.batches[i];
1062                    let end = self
1063                        .batches
1064                        .get(i + 1)
1065                        .map_or(total_indices, |n| n.index_start);
1066                    if end <= b.index_start {
1067                        continue;
1068                    }
1069                    let bg = match b.image {
1070                        None => &self.atlas_bind_group,
1071                        Some(img_id) => {
1072                            match self.images.get(img_id.0 as usize).and_then(|s| s.as_ref()) {
1073                                Some(entry) => &entry.bind_group,
1074                                None => continue,
1075                            }
1076                        }
1077                    };
1078                    pass.set_bind_group(1, bg, &[]);
1079                    pass.draw_indexed(b.index_start..end, 0, 0..1);
1080                }
1081            }
1082        }
1083
1084        self.clear_color = None;
1085    }
1086
1087    fn create_msaa_view(
1088        device: &wgpu::Device,
1089        format: wgpu::TextureFormat,
1090        width: u32,
1091        height: u32,
1092    ) -> wgpu::TextureView {
1093        let tex = device.create_texture(&wgpu::TextureDescriptor {
1094            label: Some("msaa"),
1095            size: wgpu::Extent3d {
1096                width,
1097                height,
1098                depth_or_array_layers: 1,
1099            },
1100            mip_level_count: 1,
1101            sample_count: 4,
1102            dimension: wgpu::TextureDimension::D2,
1103            format,
1104            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1105            view_formats: &[],
1106        });
1107        tex.create_view(&wgpu::TextureViewDescriptor::default())
1108    }
1109
1110    fn create_msaa_texture(
1111        device: &wgpu::Device,
1112        config: &wgpu::SurfaceConfiguration,
1113    ) -> wgpu::TextureView {
1114        let tex = device.create_texture(&wgpu::TextureDescriptor {
1115            label: Some("msaa"),
1116            size: wgpu::Extent3d {
1117                width: config.width,
1118                height: config.height,
1119                depth_or_array_layers: 1,
1120            },
1121            mip_level_count: 1,
1122            sample_count: 4,
1123            dimension: wgpu::TextureDimension::D2,
1124            format: config.format,
1125            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1126            view_formats: &[],
1127        });
1128        tex.create_view(&wgpu::TextureViewDescriptor::default())
1129    }
1130
1131    /// Resize the wgpu surface, MSAA texture, and viewport projection.
1132    ///
1133    /// `logical_w` and `logical_h` are in logical points (same coordinate
1134    /// space as `BuiltinEditor::size()`). Returns `true` if the surface
1135    /// was actually reconfigured.
1136    #[allow(clippy::cast_precision_loss)]
1137    pub fn resize(&mut self, logical_w: u32, logical_h: u32) -> bool {
1138        let new_w = truce_gui::to_physical_px(logical_w, f64::from(self.scale));
1139        let new_h = truce_gui::to_physical_px(logical_h, f64::from(self.scale));
1140        if new_w == self.width && new_h == self.height {
1141            return false;
1142        }
1143        self.width = new_w;
1144        self.height = new_h;
1145
1146        if let Some(ref surface) = self.surface
1147            && let Some(ref mut config) = self.surface_config
1148        {
1149            config.width = new_w;
1150            config.height = new_h;
1151            surface.configure(&self.device, config);
1152            self.msaa_texture = Self::create_msaa_texture(&self.device, config);
1153        }
1154
1155        // Update the orthographic projection matrix.
1156        let matrix = ortho_matrix(new_w as f32, new_h as f32);
1157        self.queue
1158            .write_buffer(&self.viewport_buffer, 0, bytemuck::cast_slice(&matrix));
1159
1160        true
1161    }
1162
1163    // --- Geometry helpers ---
1164
1165    fn color_arr(c: Color) -> [f32; 4] {
1166        [c.r, c.g, c.b, c.a]
1167    }
1168
1169    /// Ensure the current (last) batch targets `image`. If not, close the
1170    /// current batch and open a new one. Call before pushing indices.
1171    fn ensure_batch(&mut self, image: Option<ImageId>) {
1172        let needs_new = self.batches.last().is_none_or(|last| last.image != image);
1173        if needs_new {
1174            self.batches.push(DrawBatch {
1175                index_start: len_u32(self.indices.len()),
1176                image,
1177            });
1178        }
1179    }
1180
1181    fn push_quad(&mut self, v0: Vertex, v1: Vertex, v2: Vertex, v3: Vertex) {
1182        self.ensure_batch(None);
1183        let base = len_u32(self.vertices.len());
1184        self.vertices.extend_from_slice(&[v0, v1, v2, v3]);
1185        self.indices
1186            .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
1187    }
1188
1189    /// Tessellate a lyon path as a filled shape and append to vertex/index buffers.
1190    fn fill_path(&mut self, path: &Path, color: [f32; 4]) {
1191        self.ensure_batch(None);
1192        let mut buffers: VertexBuffers<Vertex, u32> = VertexBuffers::new();
1193        let mut tessellator = FillTessellator::new();
1194        let _ = tessellator.tessellate_path(
1195            path,
1196            &FillOptions::tolerance(0.5),
1197            &mut BuffersBuilder::new(&mut buffers, |vertex: FillVertex| {
1198                let p = vertex.position();
1199                Vertex::solid(p.x, p.y, color)
1200            }),
1201        );
1202        let base = len_u32(self.vertices.len());
1203        self.vertices.extend_from_slice(&buffers.vertices);
1204        self.indices
1205            .extend(buffers.indices.iter().map(|i| i + base));
1206    }
1207
1208    /// Tessellate a lyon path as a stroked shape and append to vertex/index buffers.
1209    fn stroke_path(&mut self, path: &Path, color: [f32; 4], opts: &StrokeOptions) {
1210        self.ensure_batch(None);
1211        let mut buffers: VertexBuffers<Vertex, u32> = VertexBuffers::new();
1212        let mut tessellator = StrokeTessellator::new();
1213        let _ = tessellator.tessellate_path(
1214            path,
1215            opts,
1216            &mut BuffersBuilder::new(&mut buffers, |vertex: StrokeVertex| {
1217                let p = vertex.position();
1218                Vertex::solid(p.x, p.y, color)
1219            }),
1220        );
1221        let base = len_u32(self.vertices.len());
1222        self.vertices.extend_from_slice(&buffers.vertices);
1223        self.indices
1224            .extend(buffers.indices.iter().map(|i| i + base));
1225    }
1226
1227    /// Upload pending glyph atlas writes to the GPU.
1228    fn flush_atlas(&mut self) {
1229        for (x, y, w, h, data) in self.glyph_atlas.pending.drain(..) {
1230            if w == 0 || h == 0 {
1231                continue;
1232            }
1233            self.queue.write_texture(
1234                wgpu::TexelCopyTextureInfo {
1235                    texture: &self.atlas_texture,
1236                    mip_level: 0,
1237                    origin: wgpu::Origin3d { x, y, z: 0 },
1238                    aspect: wgpu::TextureAspect::All,
1239                },
1240                &data,
1241                wgpu::TexelCopyBufferLayout {
1242                    offset: 0,
1243                    bytes_per_row: Some(w),
1244                    rows_per_image: Some(h),
1245                },
1246                wgpu::Extent3d {
1247                    width: w,
1248                    height: h,
1249                    depth_or_array_layers: 1,
1250                },
1251            );
1252        }
1253    }
1254}
1255
1256// ---------------------------------------------------------------------------
1257// RenderBackend implementation
1258// ---------------------------------------------------------------------------
1259
1260/// All `RenderBackend` methods accept coordinates in **logical points**.
1261/// The backend multiplies by `self.scale` to get physical pixel positions.
1262/// Font glyphs are rasterized at physical resolution for sharp text.
1263// Rasterizer math uses standard short names (`x`, `y`, `w`, `h`,
1264// `r`, `g`, `b`, `s` = scale, etc.).
1265#[allow(clippy::many_single_char_names)]
1266impl RenderBackend for WgpuBackend {
1267    fn clear(&mut self, color: Color) {
1268        self.clear_color = Some(wgpu::Color {
1269            r: f64::from(color.r),
1270            g: f64::from(color.g),
1271            b: f64::from(color.b),
1272            a: f64::from(color.a),
1273        });
1274        self.vertices.clear();
1275        self.indices.clear();
1276        self.batches.clear();
1277        // If a previous frame hit atlas overflow, do the eviction at the
1278        // frame boundary now - past frames' vertex buffers are gone, so
1279        // dropping the UV cache is safe. Glyphs re-rasterize lazily as
1280        // draw_text walks them this frame.
1281        if self.glyph_atlas.overflow_pending {
1282            self.glyph_atlas.clear();
1283        }
1284    }
1285
1286    fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) {
1287        let s = self.scale;
1288        let c = Self::color_arr(color);
1289        self.push_quad(
1290            Vertex::solid(x * s, y * s, c),
1291            Vertex::solid((x + w) * s, y * s, c),
1292            Vertex::solid((x + w) * s, (y + h) * s, c),
1293            Vertex::solid(x * s, (y + h) * s, c),
1294        );
1295    }
1296
1297    fn fill_circle(&mut self, cx: f32, cy: f32, radius: f32, color: Color) {
1298        let s = self.scale;
1299        let c = Self::color_arr(color);
1300        let mut builder = Path::builder();
1301        builder.add_circle(
1302            point(cx * s, cy * s),
1303            radius * s,
1304            lyon_tessellation::path::Winding::Positive,
1305        );
1306        let path = builder.build();
1307        self.fill_path(&path, c);
1308    }
1309
1310    fn stroke_circle(&mut self, cx: f32, cy: f32, radius: f32, color: Color, width: f32) {
1311        let s = self.scale;
1312        let c = Self::color_arr(color);
1313        let mut builder = Path::builder();
1314        builder.add_circle(
1315            point(cx * s, cy * s),
1316            radius * s,
1317            lyon_tessellation::path::Winding::Positive,
1318        );
1319        let path = builder.build();
1320        let opts = StrokeOptions::tolerance(0.5).with_line_width(width * s);
1321        self.stroke_path(&path, c, &opts);
1322    }
1323
1324    #[allow(clippy::cast_precision_loss)]
1325    fn stroke_arc(
1326        &mut self,
1327        cx: f32,
1328        cy: f32,
1329        radius: f32,
1330        start_angle: f32,
1331        end_angle: f32,
1332        color: Color,
1333        width: f32,
1334    ) {
1335        let s = self.scale;
1336        let c = Self::color_arr(color);
1337        let segments = 64u32;
1338        let sweep = end_angle - start_angle;
1339        let step = sweep / segments as f32;
1340
1341        let mut builder = Path::builder();
1342        builder.begin(point(
1343            cx * s + radius * s * start_angle.cos(),
1344            cy * s + radius * s * start_angle.sin(),
1345        ));
1346        for i in 1..=segments {
1347            let angle = start_angle + step * i as f32;
1348            builder.line_to(point(
1349                cx * s + radius * s * angle.cos(),
1350                cy * s + radius * s * angle.sin(),
1351            ));
1352        }
1353        builder.end(false);
1354        let path = builder.build();
1355
1356        let opts = StrokeOptions::tolerance(0.5)
1357            .with_line_width(width * s)
1358            .with_line_cap(lyon_tessellation::LineCap::Round);
1359        self.stroke_path(&path, c, &opts);
1360    }
1361
1362    fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, color: Color, width: f32) {
1363        let s = self.scale;
1364        let c = Self::color_arr(color);
1365        let mut builder = Path::builder();
1366        builder.begin(point(x1 * s, y1 * s));
1367        builder.line_to(point(x2 * s, y2 * s));
1368        builder.end(false);
1369        let path = builder.build();
1370
1371        let opts = StrokeOptions::tolerance(0.5)
1372            .with_line_width(width * s)
1373            .with_line_cap(lyon_tessellation::LineCap::Round);
1374        self.stroke_path(&path, c, &opts);
1375    }
1376
1377    // Glyph cache key uses `(phys_size * 10.0) as u32` quantization,
1378    // matching `ensure_glyph`. Window-bounded coordinates fit in u32.
1379    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1380    fn draw_text(&mut self, text: &str, x: f32, y: f32, size: f32, color: Color) {
1381        let s = self.scale;
1382        let phys_size = size * s;
1383        let c = Self::color_arr(color);
1384        let line_metrics = self.font.horizontal_line_metrics(phys_size);
1385        let ascent = line_metrics.map_or(phys_size * 0.8, |m| m.ascent);
1386
1387        let mut cursor_x = x * s;
1388
1389        let chars: Vec<char> = text.chars().collect();
1390        for &ch in &chars {
1391            self.glyph_atlas.ensure_glyph(&self.font, ch, phys_size);
1392        }
1393
1394        // `.get` rather than `[..]` - when the atlas overflows mid-frame,
1395        // `ensure_glyph` skips the insert (see GlyphAtlas::ensure_glyph)
1396        // and we want to drop the missing glyph silently rather than
1397        // panic on lookup. The next frame clears the atlas and these
1398        // glyphs come back.
1399        for &ch in &chars {
1400            let key = (ch, (phys_size * 10.0) as u32);
1401            let Some(g) = self.glyph_atlas.glyphs.get(&key) else {
1402                continue;
1403            };
1404            let (u0, v0, u1, v1, gw, gh, y_off, advance) = (
1405                g.u0, g.v0, g.u1, g.v1, g.width, g.height, g.y_offset, g.advance,
1406            );
1407            let gx = cursor_x;
1408            let gy = y * s + ascent - y_off - gh;
1409
1410            self.push_quad(
1411                Vertex::glyph(gx, gy, c, u0, v0),
1412                Vertex::glyph(gx + gw, gy, c, u1, v0),
1413                Vertex::glyph(gx + gw, gy + gh, c, u1, v1),
1414                Vertex::glyph(gx, gy + gh, c, u0, v1),
1415            );
1416
1417            cursor_x += advance;
1418        }
1419    }
1420
1421    fn text_width(&self, text: &str, size: f32) -> f32 {
1422        let phys_size = size * self.scale;
1423        truce_gui::font::text_width_fontdue(text, phys_size) / self.scale
1424    }
1425
1426    fn register_image(&mut self, rgba: &[u8], width: u32, height: u32) -> ImageId {
1427        let expected = (width as usize) * (height as usize) * 4;
1428        if width == 0 || height == 0 || rgba.len() < expected {
1429            return ImageId::INVALID;
1430        }
1431
1432        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1433            label: Some("image"),
1434            size: wgpu::Extent3d {
1435                width,
1436                height,
1437                depth_or_array_layers: 1,
1438            },
1439            mip_level_count: 1,
1440            sample_count: 1,
1441            dimension: wgpu::TextureDimension::D2,
1442            format: wgpu::TextureFormat::Rgba8Unorm,
1443            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1444            view_formats: &[],
1445        });
1446
1447        self.queue.write_texture(
1448            wgpu::TexelCopyTextureInfo {
1449                texture: &texture,
1450                mip_level: 0,
1451                origin: wgpu::Origin3d::ZERO,
1452                aspect: wgpu::TextureAspect::All,
1453            },
1454            &rgba[..expected],
1455            wgpu::TexelCopyBufferLayout {
1456                offset: 0,
1457                bytes_per_row: Some(width * 4),
1458                rows_per_image: Some(height),
1459            },
1460            wgpu::Extent3d {
1461                width,
1462                height,
1463                depth_or_array_layers: 1,
1464            },
1465        );
1466
1467        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1468        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1469            label: Some("image-bg"),
1470            layout: &self.tex_bind_group_layout,
1471            entries: &[
1472                wgpu::BindGroupEntry {
1473                    binding: 0,
1474                    resource: wgpu::BindingResource::TextureView(&view),
1475                },
1476                wgpu::BindGroupEntry {
1477                    binding: 1,
1478                    resource: wgpu::BindingResource::Sampler(&self.sampler),
1479                },
1480            ],
1481        });
1482
1483        let entry = ImageEntry {
1484            _texture: texture,
1485            bind_group,
1486        };
1487
1488        if let Some((idx, slot)) = self
1489            .images
1490            .iter_mut()
1491            .enumerate()
1492            .find(|(_, s)| s.is_none())
1493        {
1494            *slot = Some(entry);
1495            return ImageId(len_u32(idx));
1496        }
1497        let id = len_u32(self.images.len());
1498        self.images.push(Some(entry));
1499        ImageId(id)
1500    }
1501
1502    fn unregister_image(&mut self, id: ImageId) {
1503        if let Some(slot) = self.images.get_mut(id.0 as usize) {
1504            *slot = None;
1505        }
1506    }
1507
1508    fn draw_image(&mut self, id: ImageId, x: f32, y: f32, w: f32, h: f32) {
1509        if self
1510            .images
1511            .get(id.0 as usize)
1512            .and_then(|s| s.as_ref())
1513            .is_none()
1514        {
1515            return;
1516        }
1517        self.ensure_batch(Some(id));
1518
1519        let s = self.scale;
1520        let c = [1.0, 1.0, 1.0, 1.0];
1521        let base = len_u32(self.vertices.len());
1522        self.vertices.extend_from_slice(&[
1523            Vertex::image(x * s, y * s, c, 0.0, 0.0),
1524            Vertex::image((x + w) * s, y * s, c, 1.0, 0.0),
1525            Vertex::image((x + w) * s, (y + h) * s, c, 1.0, 1.0),
1526            Vertex::image(x * s, (y + h) * s, c, 0.0, 1.0),
1527        ]);
1528        self.indices
1529            .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
1530    }
1531
1532    fn present(&mut self) {
1533        // Upload any pending glyph atlas writes (before borrowing surface)
1534        self.flush_atlas();
1535
1536        let Some(surface) = &self.surface else {
1537            return; // headless - no surface to present to
1538        };
1539
1540        let (wgpu::CurrentSurfaceTexture::Success(frame)
1541        | wgpu::CurrentSurfaceTexture::Suboptimal(frame)) = surface.get_current_texture()
1542        else {
1543            return;
1544        };
1545        let frame_view = frame
1546            .texture
1547            .create_view(&wgpu::TextureViewDescriptor::default());
1548
1549        if self.vertices.is_empty() {
1550            // No draws this frame, but the surface is double/triple-buffered
1551            // - without a render pass the swap chain shows whatever was
1552            // there before (often the second-prior frame, producing a
1553            // visible flicker on idle). Issue a clear-only pass so the
1554            // surface ends up at `clear_color`.
1555            self.clear_only_pass(&frame_view);
1556            frame.present();
1557            return;
1558        }
1559
1560        self.render_pass(&frame_view);
1561        frame.present();
1562    }
1563}
1564
1565impl WgpuBackend {
1566    /// Issue a render pass that only clears the surface. Used by
1567    /// `present()` when there is no geometry - without it the swap chain
1568    /// would show stale buffer contents. Always clears (`Load` would
1569    /// surface prior-frame garbage), falling back to
1570    /// `present_clear_default` when no `clear()` was requested.
1571    fn clear_only_pass(&mut self, resolve_target: &wgpu::TextureView) {
1572        let clear_color = self.clear_color.unwrap_or(self.present_clear_default);
1573        let mut encoder = self
1574            .device
1575            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1576                label: Some("clear-only"),
1577            });
1578        {
1579            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1580                label: Some("clear-only"),
1581                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1582                    view: &self.msaa_texture,
1583                    resolve_target: Some(resolve_target),
1584                    ops: wgpu::Operations {
1585                        load: wgpu::LoadOp::Clear(clear_color),
1586                        store: wgpu::StoreOp::Discard,
1587                    },
1588                    depth_slice: None,
1589                })],
1590                depth_stencil_attachment: None,
1591                timestamp_writes: None,
1592                occlusion_query_set: None,
1593                multiview_mask: None,
1594            });
1595        }
1596        self.queue.submit(std::iter::once(encoder.finish()));
1597    }
1598
1599    /// Render accumulated geometry to a texture view (shared by present + headless).
1600    fn render_pass(&mut self, resolve_target: &wgpu::TextureView) {
1601        let clear_color = self.clear_color.unwrap_or(self.present_clear_default);
1602        let vertex_buffer = self
1603            .device
1604            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1605                label: Some("vertices"),
1606                contents: bytemuck::cast_slice(&self.vertices),
1607                usage: wgpu::BufferUsages::VERTEX,
1608            });
1609
1610        let index_buffer = self
1611            .device
1612            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1613                label: Some("indices"),
1614                contents: bytemuck::cast_slice(&self.indices),
1615                usage: wgpu::BufferUsages::INDEX,
1616            });
1617
1618        let mut encoder = self
1619            .device
1620            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1621                label: Some("frame"),
1622            });
1623
1624        {
1625            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1626                label: Some("main"),
1627                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1628                    view: &self.msaa_texture,
1629                    resolve_target: Some(resolve_target),
1630                    ops: wgpu::Operations {
1631                        load: wgpu::LoadOp::Clear(clear_color),
1632                        store: wgpu::StoreOp::Discard,
1633                    },
1634                    depth_slice: None,
1635                })],
1636                depth_stencil_attachment: None,
1637                timestamp_writes: None,
1638                occlusion_query_set: None,
1639                multiview_mask: None,
1640            });
1641
1642            pass.set_pipeline(&self.pipeline);
1643            pass.set_bind_group(0, &self.viewport_bind_group, &[]);
1644            pass.set_vertex_buffer(0, vertex_buffer.slice(..));
1645            pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1646
1647            let total_indices = len_u32(self.indices.len());
1648            if self.batches.is_empty() {
1649                // Backwards-compatible path: no batching recorded (e.g. a
1650                // caller that bypassed clear()). Draw everything with the
1651                // atlas bind group.
1652                pass.set_bind_group(1, &self.atlas_bind_group, &[]);
1653                pass.draw_indexed(0..total_indices, 0, 0..1);
1654            } else {
1655                for i in 0..self.batches.len() {
1656                    let b = self.batches[i];
1657                    let end = self
1658                        .batches
1659                        .get(i + 1)
1660                        .map_or(total_indices, |n| n.index_start);
1661                    if end <= b.index_start {
1662                        continue;
1663                    }
1664                    let bg = match b.image {
1665                        None => &self.atlas_bind_group,
1666                        Some(img_id) => {
1667                            match self.images.get(img_id.0 as usize).and_then(|s| s.as_ref()) {
1668                                Some(entry) => &entry.bind_group,
1669                                // Image was unregistered mid-frame; skip draw.
1670                                None => continue,
1671                            }
1672                        }
1673                    };
1674                    pass.set_bind_group(1, bg, &[]);
1675                    pass.draw_indexed(b.index_start..end, 0, 0..1);
1676                }
1677            }
1678        }
1679
1680        self.queue.submit(std::iter::once(encoder.finish()));
1681    }
1682
1683    /// Create a headless GPU backend (no window or surface).
1684    /// Used for snapshot testing.
1685    ///
1686    /// # Panics
1687    ///
1688    /// Panics if the embedded font fails to parse (bundled-asset
1689    /// bug, never user input).
1690    // Surface dimensions in pixels stay below 2^23, well within f32.
1691    #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
1692    #[must_use]
1693    pub fn headless(width: u32, height: u32, scale: f32) -> Option<Self> {
1694        let phys_w = truce_gui::to_physical_px(width, f64::from(scale));
1695        let phys_h = truce_gui::to_physical_px(height, f64::from(scale));
1696
1697        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
1698        desc.backends = wgpu::Backends::PRIMARY;
1699        let instance = wgpu::Instance::new(desc);
1700
1701        // Headless: there is no `wgpu::Surface` to constrain the adapter
1702        // pick against, so `compatible_surface` is `None`. On a multi-GPU
1703        // host (e.g. discrete + integrated, or NVIDIA Optimus) wgpu may
1704        // pick a different physical adapter than the live render path's
1705        // `compatible_surface: Some(&surface)`, which can produce subtle
1706        // rasterization differences (driver-specific shader compile, MSAA
1707        // resolve, sRGB rounding). Bake screenshot baselines on the host
1708        // you gate from - this is the same constraint already documented
1709        // in `cargo truce screenshot --check`.
1710        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1711            power_preference: wgpu::PowerPreference::HighPerformance,
1712            compatible_surface: None,
1713            force_fallback_adapter: false,
1714        }))
1715        .ok()?;
1716
1717        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
1718            label: Some("truce-gpu-headless"),
1719            required_features: wgpu::Features::empty(),
1720            required_limits: wgpu::Limits::downlevel_defaults(),
1721            experimental_features: wgpu::ExperimentalFeatures::default(),
1722            memory_hints: wgpu::MemoryHints::Performance,
1723            trace: wgpu::Trace::Off,
1724        }))
1725        .ok()?;
1726        let device = Arc::new(device);
1727        let queue = Arc::new(queue);
1728
1729        // Use non-sRGB to match the windowed path (which picks !is_srgb())
1730        let texture_format = wgpu::TextureFormat::Rgba8Unorm;
1731
1732        // MSAA texture
1733        let msaa_texture = device.create_texture(&wgpu::TextureDescriptor {
1734            label: Some("msaa"),
1735            size: wgpu::Extent3d {
1736                width: phys_w,
1737                height: phys_h,
1738                depth_or_array_layers: 1,
1739            },
1740            mip_level_count: 1,
1741            sample_count: 4,
1742            dimension: wgpu::TextureDimension::D2,
1743            format: texture_format,
1744            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1745            view_formats: &[],
1746        });
1747        let msaa_view = msaa_texture.create_view(&wgpu::TextureViewDescriptor::default());
1748
1749        // Shader
1750        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1751            label: Some("truce-gpu-shader"),
1752            source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
1753        });
1754
1755        // Viewport
1756        let matrix = ortho_matrix(phys_w as f32, phys_h as f32);
1757        let viewport_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1758            label: Some("viewport"),
1759            contents: bytemuck::cast_slice(&matrix),
1760            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1761        });
1762
1763        let viewport_bind_group_layout =
1764            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1765                label: Some("viewport-layout"),
1766                entries: &[wgpu::BindGroupLayoutEntry {
1767                    binding: 0,
1768                    visibility: wgpu::ShaderStages::VERTEX,
1769                    ty: wgpu::BindingType::Buffer {
1770                        ty: wgpu::BufferBindingType::Uniform,
1771                        has_dynamic_offset: false,
1772                        min_binding_size: None,
1773                    },
1774                    count: None,
1775                }],
1776            });
1777
1778        let viewport_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1779            label: Some("viewport-bg"),
1780            layout: &viewport_bind_group_layout,
1781            entries: &[wgpu::BindGroupEntry {
1782                binding: 0,
1783                resource: viewport_buffer.as_entire_binding(),
1784            }],
1785        });
1786
1787        // Atlas
1788        let atlas_texture = device.create_texture(&wgpu::TextureDescriptor {
1789            label: Some("glyph-atlas"),
1790            size: wgpu::Extent3d {
1791                width: ATLAS_SIZE,
1792                height: ATLAS_SIZE,
1793                depth_or_array_layers: 1,
1794            },
1795            mip_level_count: 1,
1796            sample_count: 1,
1797            dimension: wgpu::TextureDimension::D2,
1798            format: wgpu::TextureFormat::R8Unorm,
1799            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1800            view_formats: &[],
1801        });
1802        let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
1803        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1804            mag_filter: wgpu::FilterMode::Linear,
1805            min_filter: wgpu::FilterMode::Linear,
1806            ..Default::default()
1807        });
1808        let tex_bind_group_layout =
1809            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1810                label: Some("tex-layout"),
1811                entries: &[
1812                    wgpu::BindGroupLayoutEntry {
1813                        binding: 0,
1814                        visibility: wgpu::ShaderStages::FRAGMENT,
1815                        ty: wgpu::BindingType::Texture {
1816                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
1817                            view_dimension: wgpu::TextureViewDimension::D2,
1818                            multisampled: false,
1819                        },
1820                        count: None,
1821                    },
1822                    wgpu::BindGroupLayoutEntry {
1823                        binding: 1,
1824                        visibility: wgpu::ShaderStages::FRAGMENT,
1825                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
1826                        count: None,
1827                    },
1828                ],
1829            });
1830        let atlas_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1831            label: Some("atlas-bg"),
1832            layout: &tex_bind_group_layout,
1833            entries: &[
1834                wgpu::BindGroupEntry {
1835                    binding: 0,
1836                    resource: wgpu::BindingResource::TextureView(&atlas_view),
1837                },
1838                wgpu::BindGroupEntry {
1839                    binding: 1,
1840                    resource: wgpu::BindingResource::Sampler(&sampler),
1841                },
1842            ],
1843        });
1844
1845        // Pipeline
1846        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1847            label: Some("truce-gpu-pipeline-layout"),
1848            bind_group_layouts: &[
1849                Some(&viewport_bind_group_layout),
1850                Some(&tex_bind_group_layout),
1851            ],
1852            immediate_size: 0,
1853        });
1854
1855        let vertex_layout = wgpu::VertexBufferLayout {
1856            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
1857            step_mode: wgpu::VertexStepMode::Vertex,
1858            attributes: &[
1859                wgpu::VertexAttribute {
1860                    offset: 0,
1861                    shader_location: 0,
1862                    format: wgpu::VertexFormat::Float32x2,
1863                },
1864                wgpu::VertexAttribute {
1865                    offset: 8,
1866                    shader_location: 1,
1867                    format: wgpu::VertexFormat::Float32x4,
1868                },
1869                wgpu::VertexAttribute {
1870                    offset: 24,
1871                    shader_location: 2,
1872                    format: wgpu::VertexFormat::Float32x2,
1873                },
1874                wgpu::VertexAttribute {
1875                    offset: 32,
1876                    shader_location: 3,
1877                    format: wgpu::VertexFormat::Float32,
1878                },
1879            ],
1880        };
1881
1882        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1883            label: Some("truce-gpu-pipeline"),
1884            layout: Some(&pipeline_layout),
1885            vertex: wgpu::VertexState {
1886                module: &shader,
1887                entry_point: Some("vs_main"),
1888                buffers: &[vertex_layout],
1889                compilation_options: wgpu::PipelineCompilationOptions::default(),
1890            },
1891            fragment: Some(wgpu::FragmentState {
1892                module: &shader,
1893                entry_point: Some("fs_main"),
1894                targets: &[Some(wgpu::ColorTargetState {
1895                    format: texture_format,
1896                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1897                    write_mask: wgpu::ColorWrites::ALL,
1898                })],
1899                compilation_options: wgpu::PipelineCompilationOptions::default(),
1900            }),
1901            primitive: wgpu::PrimitiveState {
1902                topology: wgpu::PrimitiveTopology::TriangleList,
1903                ..Default::default()
1904            },
1905            depth_stencil: None,
1906            multisample: wgpu::MultisampleState {
1907                count: 4,
1908                mask: !0,
1909                alpha_to_coverage_enabled: false,
1910            },
1911            multiview_mask: None,
1912            cache: None,
1913        });
1914
1915        let font = fontdue::Font::from_bytes(
1916            truce_gui::font::JETBRAINS_MONO,
1917            fontdue::FontSettings::default(),
1918        )
1919        .expect("failed to parse embedded font");
1920
1921        Some(Self {
1922            device,
1923            queue,
1924            surface: None,
1925            surface_config: None,
1926            pipeline,
1927            target_format: texture_format,
1928            msaa_texture: msaa_view,
1929            msaa_width: phys_w,
1930            msaa_height: phys_h,
1931            vertices: Vec::with_capacity(4096),
1932            indices: Vec::with_capacity(8192),
1933            batches: Vec::new(),
1934            glyph_atlas: GlyphAtlas::new(),
1935            font,
1936            atlas_texture,
1937            atlas_bind_group,
1938            tex_bind_group_layout,
1939            sampler,
1940            images: Vec::new(),
1941            viewport_buffer,
1942            viewport_bind_group,
1943            clear_color: None,
1944            present_clear_default: wgpu::Color::BLACK,
1945            width: phys_w,
1946            height: phys_h,
1947            scale,
1948        })
1949    }
1950
1951    /// Render to an offscreen texture and read back RGBA pixels.
1952    /// Only works for headless backends (no surface).
1953    ///
1954    /// # Panics
1955    ///
1956    /// Panics if `wgpu::Buffer::map_async` reports failure when reading
1957    /// back the GPU readback buffer - that indicates an adapter / driver
1958    /// fault rather than a recoverable runtime condition, so the
1959    /// snapshot path bubbles it up rather than papering over it.
1960    pub fn read_pixels(&mut self) -> Vec<u8> {
1961        self.flush_atlas();
1962
1963        let w = self.width;
1964        let h = self.height;
1965        let format = wgpu::TextureFormat::Rgba8Unorm;
1966
1967        // Offscreen resolve target
1968        let target_texture = self.device.create_texture(&wgpu::TextureDescriptor {
1969            label: Some("offscreen"),
1970            size: wgpu::Extent3d {
1971                width: w,
1972                height: h,
1973                depth_or_array_layers: 1,
1974            },
1975            mip_level_count: 1,
1976            sample_count: 1,
1977            dimension: wgpu::TextureDimension::D2,
1978            format,
1979            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1980            view_formats: &[],
1981        });
1982        let target_view = target_texture.create_view(&wgpu::TextureViewDescriptor::default());
1983
1984        // Render
1985        if !self.vertices.is_empty() {
1986            self.render_pass(&target_view);
1987        }
1988
1989        // Readback
1990        let bytes_per_row = (w * 4 + 255) & !255; // 256-byte aligned
1991        let readback_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
1992            label: Some("readback"),
1993            size: u64::from(bytes_per_row * h),
1994            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1995            mapped_at_creation: false,
1996        });
1997
1998        let mut encoder = self
1999            .device
2000            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2001                label: Some("readback"),
2002            });
2003        encoder.copy_texture_to_buffer(
2004            wgpu::TexelCopyTextureInfo {
2005                texture: &target_texture,
2006                mip_level: 0,
2007                origin: wgpu::Origin3d::ZERO,
2008                aspect: wgpu::TextureAspect::All,
2009            },
2010            wgpu::TexelCopyBufferInfo {
2011                buffer: &readback_buf,
2012                layout: wgpu::TexelCopyBufferLayout {
2013                    offset: 0,
2014                    bytes_per_row: Some(bytes_per_row),
2015                    rows_per_image: None,
2016                },
2017            },
2018            wgpu::Extent3d {
2019                width: w,
2020                height: h,
2021                depth_or_array_layers: 1,
2022            },
2023        );
2024        self.queue.submit(std::iter::once(encoder.finish()));
2025
2026        // Map and copy
2027        let buf_slice = readback_buf.slice(..);
2028        let (tx, rx) = std::sync::mpsc::channel();
2029        buf_slice.map_async(wgpu::MapMode::Read, move |result| {
2030            tx.send(result).unwrap();
2031        });
2032        let _ = self.device.poll(wgpu::PollType::Wait {
2033            submission_index: None,
2034            timeout: None,
2035        });
2036        rx.recv().unwrap().expect("buffer map failed");
2037
2038        let mapped = buf_slice.get_mapped_range();
2039        let mut pixels = Vec::with_capacity((w * h * 4) as usize);
2040        for row in 0..h {
2041            let start = (row * bytes_per_row) as usize;
2042            let end = start + (w * 4) as usize;
2043            pixels.extend_from_slice(&mapped[start..end]);
2044        }
2045        drop(mapped);
2046        readback_buf.unmap();
2047
2048        // Shader writes premultiplied alpha (BlendState::PREMULTIPLIED_ALPHA_BLENDING),
2049        // but downstream consumers - `truce-test`'s reference-PNG comparison
2050        // and `cargo truce screenshot` output - assume straight RGBA, the
2051        // same convention `truce-slint::screenshot::render_with_state` uses.
2052        // Un-premultiply here so the GPU readback matches the headless
2053        // contract instead of leaking the GPU's internal format.
2054        for px in pixels.chunks_exact_mut(4) {
2055            let a = px[3];
2056            if a == 0 || a == 255 {
2057                continue;
2058            }
2059            let a16 = u16::from(a);
2060            // Round to nearest: (c * 255 + a/2) / a.
2061            px[0] = ((u16::from(px[0]) * 255 + a16 / 2) / a16).min(255) as u8;
2062            px[1] = ((u16::from(px[1]) * 255 + a16 / 2) / a16).min(255) as u8;
2063            px[2] = ((u16::from(px[2]) * 255 + a16 / 2) / a16).min(255) as u8;
2064        }
2065
2066        pixels
2067    }
2068}
2069
2070// ---------------------------------------------------------------------------
2071// Tests
2072// ---------------------------------------------------------------------------
2073
2074#[cfg(test)]
2075mod tests {
2076    use super::*;
2077
2078    #[test]
2079    fn vertex_size() {
2080        // 2 (pos) + 4 (color) + 2 (uv) + 1 (tex_mix) + 1 (pad) = 10 floats = 40 bytes
2081        let size = std::mem::size_of::<Vertex>();
2082        assert!(size > 0, "Vertex should have non-zero size: {size}");
2083    }
2084
2085    // Both ortho-matrix tests check that the helper maps top-left
2086    // screen-space origin (0, 0) to wgpu's top-left clip-space corner
2087    // (-1, +1) and bottom-right screen (w, h) to clip (+1, -1). The Y
2088    // flip is the `-2.0 / h` term in `ortho_matrix` - without it,
2089    // increasing screen-y would move the vertex *up* in clip space.
2090    #[test]
2091    fn ortho_matrix_maps_origin() {
2092        let m = ortho_matrix(800.0, 600.0);
2093        let x = m[0][0] * 0.0 + m[3][0];
2094        let y = m[1][1] * 0.0 + m[3][1];
2095        assert!((x - (-1.0)).abs() < 1e-6);
2096        assert!((y - 1.0).abs() < 1e-6);
2097    }
2098
2099    #[test]
2100    fn ortho_matrix_maps_bottom_right() {
2101        let m = ortho_matrix(800.0, 600.0);
2102        let x = m[0][0] * 800.0 + m[3][0];
2103        let y = m[1][1] * 600.0 + m[3][1];
2104        assert!((x - 1.0).abs() < 1e-6);
2105        assert!((y - (-1.0)).abs() < 1e-6);
2106    }
2107
2108    #[test]
2109    fn glyph_atlas_shelf_packing() {
2110        let font = fontdue::Font::from_bytes(
2111            truce_gui::font::JETBRAINS_MONO,
2112            fontdue::FontSettings::default(),
2113        )
2114        .unwrap();
2115        let mut atlas = GlyphAtlas::new();
2116
2117        // Pack a few glyphs
2118        atlas.ensure_glyph(&font, 'A', 14.0);
2119        atlas.ensure_glyph(&font, 'B', 14.0);
2120        atlas.ensure_glyph(&font, 'C', 14.0);
2121
2122        assert_eq!(atlas.glyphs.len(), 3);
2123        assert!(!atlas.pending.is_empty());
2124
2125        // Same glyph at same size should not create a new entry
2126        atlas.ensure_glyph(&font, 'A', 14.0);
2127        assert_eq!(atlas.glyphs.len(), 3);
2128    }
2129
2130    #[test]
2131    fn lyon_fill_circle_produces_triangles() {
2132        let mut builder = Path::builder();
2133        builder.add_circle(
2134            point(50.0, 50.0),
2135            10.0,
2136            lyon_tessellation::path::Winding::Positive,
2137        );
2138        let path = builder.build();
2139        let mut buffers: VertexBuffers<[f32; 2], u32> = VertexBuffers::new();
2140        let mut tess = FillTessellator::new();
2141        tess.tessellate_path(
2142            &path,
2143            &FillOptions::tolerance(0.5),
2144            &mut BuffersBuilder::new(&mut buffers, |v: FillVertex| {
2145                let p = v.position();
2146                [p.x, p.y]
2147            }),
2148        )
2149        .unwrap();
2150        assert!(buffers.vertices.len() >= 3);
2151        assert!(buffers.indices.len() >= 3);
2152    }
2153
2154    /// End-to-end smoke test for the standalone `new` / `begin_frame` /
2155    /// `finish` path: build a backend against a caller-owned device,
2156    /// record some primitives + text, render into an offscreen texture,
2157    /// and verify we wrote non-background pixels.
2158    #[test]
2159    #[allow(clippy::too_many_lines, clippy::many_single_char_names)]
2160    fn standalone_pipeline_renders() {
2161        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
2162        desc.backends = wgpu::Backends::PRIMARY;
2163        let instance = wgpu::Instance::new(desc);
2164        let Ok(adapter) =
2165            pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
2166                power_preference: wgpu::PowerPreference::HighPerformance,
2167                compatible_surface: None,
2168                force_fallback_adapter: false,
2169            }))
2170        else {
2171            return; // no GPU in this environment
2172        };
2173        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
2174            label: Some("standalone-test"),
2175            required_features: wgpu::Features::empty(),
2176            required_limits: wgpu::Limits::downlevel_defaults(),
2177            experimental_features: wgpu::ExperimentalFeatures::default(),
2178            memory_hints: wgpu::MemoryHints::Performance,
2179            trace: wgpu::Trace::Off,
2180        }))
2181        .expect("request_device");
2182        let device = Arc::new(device);
2183        let queue = Arc::new(queue);
2184
2185        let w = 64u32;
2186        let h = 48u32;
2187        let format = wgpu::TextureFormat::Rgba8Unorm;
2188        let mut backend =
2189            WgpuBackend::new(Arc::clone(&device), Arc::clone(&queue), format, w, h, 1.0)
2190                .expect("backend new");
2191
2192        // Pre-fill the offscreen target with red so we can tell apart
2193        // "finish drew something" from "finish cleared to background".
2194        let target = device.create_texture(&wgpu::TextureDescriptor {
2195            label: Some("standalone-target"),
2196            size: wgpu::Extent3d {
2197                width: w,
2198                height: h,
2199                depth_or_array_layers: 1,
2200            },
2201            mip_level_count: 1,
2202            sample_count: 1,
2203            dimension: wgpu::TextureDimension::D2,
2204            format,
2205            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
2206            view_formats: &[],
2207        });
2208        let view = target.create_view(&wgpu::TextureViewDescriptor::default());
2209
2210        backend.begin_frame(w, h);
2211        backend.clear(Color::rgb(0.0, 0.0, 0.0));
2212        backend.fill_rect(8.0, 8.0, 16.0, 16.0, Color::rgb(0.0, 1.0, 0.0));
2213        backend.draw_text("x", 20.0, 20.0, 14.0, Color::rgb(1.0, 1.0, 1.0));
2214
2215        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2216            label: Some("standalone-enc"),
2217        });
2218        backend.finish(&mut encoder, &view);
2219
2220        // Copy target to a readback buffer and inspect.
2221        let bytes_per_row = (w * 4 + 255) & !255;
2222        let readback = device.create_buffer(&wgpu::BufferDescriptor {
2223            label: Some("readback"),
2224            size: u64::from(bytes_per_row * h),
2225            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2226            mapped_at_creation: false,
2227        });
2228        encoder.copy_texture_to_buffer(
2229            wgpu::TexelCopyTextureInfo {
2230                texture: &target,
2231                mip_level: 0,
2232                origin: wgpu::Origin3d::ZERO,
2233                aspect: wgpu::TextureAspect::All,
2234            },
2235            wgpu::TexelCopyBufferInfo {
2236                buffer: &readback,
2237                layout: wgpu::TexelCopyBufferLayout {
2238                    offset: 0,
2239                    bytes_per_row: Some(bytes_per_row),
2240                    rows_per_image: None,
2241                },
2242            },
2243            wgpu::Extent3d {
2244                width: w,
2245                height: h,
2246                depth_or_array_layers: 1,
2247            },
2248        );
2249        queue.submit(std::iter::once(encoder.finish()));
2250
2251        let slice = readback.slice(..);
2252        let (tx, rx) = std::sync::mpsc::channel();
2253        slice.map_async(wgpu::MapMode::Read, move |r| {
2254            tx.send(r).unwrap();
2255        });
2256        let _ = device.poll(wgpu::PollType::Wait {
2257            submission_index: None,
2258            timeout: None,
2259        });
2260        rx.recv().unwrap().unwrap();
2261        let mapped = slice.get_mapped_range();
2262
2263        // Probe the green rect center (16, 16) - should be ~green.
2264        let row_off = 16usize * bytes_per_row as usize;
2265        let px_off = row_off + 16 * 4;
2266        let r = mapped[px_off];
2267        let g = mapped[px_off + 1];
2268        let b = mapped[px_off + 2];
2269        assert!(g > 200, "green rect not rendered: got rgb=({r},{g},{b})");
2270        assert!(
2271            r < 50 && b < 50,
2272            "green rect leaked other channels: rgb=({r},{g},{b})"
2273        );
2274    }
2275}