Skip to main content

damascene_wgpu/
lib.rs

1//! `wgpu` backend for custom Damascene hosts.
2//!
3//! Most applications should implement `damascene_core::App` and run it
4//! through `damascene-winit-wgpu`. Use this crate directly when you are
5//! writing your own host, embedding Damascene into an existing `wgpu`
6//! renderer, or producing headless render artifacts.
7//!
8//! The public entry point is [`Runner`]. It owns:
9//!
10//! - GPU resources: pipelines, buffers, text atlas, and icon atlas.
11//! - Backend-agnostic interaction state shared through
12//!   `damascene_core::runtime::RunnerCore`.
13//! - A snapshot of the last laid-out tree so input arriving between
14//!   frames hit-tests against the geometry the user can see.
15//!
16//! # Custom host loop
17//!
18//! The runner does not own the device, queue, swapchain, window, or
19//! event loop. A host creates those resources, forwards input into the
20//! runner, builds a fresh `El` tree, prepares GPU buffers, and renders:
21//!
22//! ```ignore
23//! use damascene_core::prelude::*;
24//! use damascene_wgpu::Runner;
25//!
26//! let mut runner = Runner::new(&device, &queue, surface_format);
27//! runner.set_surface_size(surface_width, surface_height);
28//!
29//! // Per frame:
30//! app.before_build();
31//! let theme = app.theme();
32//! let mut tree = app.build(&damascene_core::BuildCx::new(&theme));
33//! runner.set_hotkeys(app.hotkeys());
34//! runner.set_theme(theme);
35//! runner.prepare(&device, &queue, &mut tree, viewport, scale_factor);
36//! runner.render(&device, &mut encoder, target_texture, target_view, None, load_op);
37//! ```
38//!
39//! `prepare` is split from `render`/`draw` so all `queue.write_buffer`
40//! calls and atlas uploads happen before render-pass recording, matching
41//! `wgpu`'s expected order. Coordinates passed to pointer methods are
42//! logical pixels; render targets are physical pixels, so pass the host
43//! scale factor to [`Runner::prepare`].
44//!
45//! Use [`Runner::render`] when Damascene should own pass boundaries. This is
46//! required for backdrop-sampling custom shaders. Use [`Runner::draw`]
47//! only when you are already inside a host-owned pass and do not need
48//! backdrop sampling.
49//!
50//! # Custom shaders
51//!
52//! Call [`Runner::register_shader`] with a name and WGSL source. The
53//! shader's vertex/fragment must use the shared instance layout — see
54//! `shaders/rounded_rect.wgsl` (in damascene-core) for the canonical
55//! example. Bind the shader at a node via
56//! `El::shader(ShaderBinding::custom(name).with(...))`. Per-instance
57//! uniforms map to three generic `vec4` slots:
58//!
59//! | Uniform key | Slot (`@location`) | Accepted types |
60//! |---|---|---|
61//! | `vec_a` | 2 | `Color` (rgba 0..1) or `Vec4` |
62//! | `vec_b` | 3 | `Color` or `Vec4` |
63//! | `vec_c` | 4 | `Vec4` (or fall back to scalar `f32` packed in `.x`) |
64//!
65//! Stock `rounded_rect` reuses the same layout but reads its own named
66//! uniforms (`fill`, `stroke`, `stroke_width`, `radius`, `shadow`).
67
68mod icon;
69mod image;
70mod instance;
71mod msaa;
72mod pipeline;
73mod scene;
74mod surface;
75mod text;
76
77pub use crate::msaa::MsaaTarget;
78pub use crate::surface::{WgpuAppTexture, app_texture};
79
80use std::collections::{HashMap, HashSet};
81// `web_time::Instant` is API-identical to `std::time::Instant` on
82// native and uses `performance.now()` on wasm32 — std's `Instant::now()`
83// panics in the browser because there is no monotonic clock there.
84use web_time::Instant;
85
86use wgpu::util::DeviceExt;
87
88use damascene_core::event::{KeyChord, KeyModifiers, Pointer, UiEvent, UiKey};
89use damascene_core::ir::TextAnchor;
90use damascene_core::paint::{IconRunKind, PhysicalScissor, QuadInstance};
91use damascene_core::runtime::{RecordedPaint, RunnerCore, TextRecorder};
92use damascene_core::shader::{ShaderHandle, StockShader, stock_wgsl};
93use damascene_core::state::{AnimationMode, UiState};
94use damascene_core::text::atlas::RunStyle;
95use damascene_core::theme::Theme;
96use damascene_core::tree::{Color, El, Rect, TextWrap};
97use damascene_core::vector::IconMaterial;
98
99pub use damascene_core::paint::PaintItem;
100pub use damascene_core::runtime::{LayoutPrepared, PointerMove, PrepareResult, PrepareTimings};
101
102use crate::icon::IconPaint;
103use crate::image::ImagePaint;
104use crate::instance::set_scissor;
105use crate::pipeline::{FrameUniforms, build_quad_pipeline};
106use crate::scene::Scene3DPaint;
107use crate::surface::SurfacePaint;
108use crate::text::TextPaint;
109
110/// Initial size for the dynamic instance buffer (grows as needed).
111const INITIAL_INSTANCE_CAPACITY: usize = 256;
112
113/// Adapter-derived capabilities the [`Runner`] adapts its pipelines to.
114///
115/// Defaults to everything supported — correct for native Vulkan/Metal/DX
116/// adapters. Hosts that can land on GL or browser adapters should derive
117/// the real values with [`RunnerCaps::from_adapter`] and build the runner
118/// via [`Runner::with_caps`].
119#[derive(Clone, Copy, Debug)]
120pub struct RunnerCaps {
121    /// Whether the adapter supports per-sample MSAA shading
122    /// (`DownlevelFlags::MULTISAMPLED_SHADING`). When `false`, every
123    /// pipeline (stock and later-registered custom) has
124    /// `@interpolate(perspective, sample)` rewritten to
125    /// `@interpolate(perspective)` before WGSL compilation. The shader
126    /// then interpolates at pixel centre instead of per MSAA sample —
127    /// MSAA coverage still works at `sample_count > 1`; only the
128    /// per-sub-sample brightness pass is skipped, slightly thickening
129    /// the AA band on curved SDF edges.
130    pub per_sample_shading: bool,
131    /// Whether the backend can read a scene depth *attachment* back for
132    /// `Scene3D` label occlusion. Must be `false` on GL backends
133    /// (WebGL2): naga's GLSL target can't `textureLoad` depth textures
134    /// (so building the resolve pipeline panics the device), and GLES 3.0
135    /// can't create multisampled depth *textures* at all. When `false`,
136    /// occlusion still works — the capture re-renders the scene's meshes
137    /// with a fragment stage that packs depth into an RGBA8 colour target
138    /// instead of resolving the depth attachment. Costs one extra
139    /// mesh-only pass per camera-pose change on those backends.
140    pub depth_readback: bool,
141}
142
143impl Default for RunnerCaps {
144    fn default() -> Self {
145        Self {
146            per_sample_shading: true,
147            depth_readback: true,
148        }
149    }
150}
151
152impl RunnerCaps {
153    /// Derive the caps from the adapter the host actually got.
154    ///
155    /// GL is treated as unsupported across the board regardless of the
156    /// reported downlevel flags: Chrome's SwiftShader WebGL2 fallback
157    /// reports `MULTISAMPLED_SHADING` through wgpu, but the GLSL ES
158    /// target still rejects the sample interpolation qualifier (and can
159    /// never `textureLoad` a depth texture). WebGPU/native keep trusting
160    /// the adapter flags.
161    pub fn from_adapter(adapter: &wgpu::Adapter) -> Self {
162        let gl = adapter.get_info().backend == wgpu::Backend::Gl;
163        Self {
164            per_sample_shading: !gl
165                && adapter
166                    .get_downlevel_capabilities()
167                    .flags
168                    .contains(wgpu::DownlevelFlags::MULTISAMPLED_SHADING),
169            depth_readback: !gl,
170        }
171    }
172}
173
174/// Wgpu runtime owned by the host. One instance per surface/format.
175///
176/// All backend-agnostic state — interaction state, paint-stream scratch,
177/// per-stage layout/animation hooks — lives in `core: RunnerCore` and
178/// is shared with the vulkano backend. The fields below are wgpu-specific
179/// resources only.
180pub struct Runner {
181    target_format: wgpu::TextureFormat,
182    sample_count: u32,
183    /// [`RunnerCaps::per_sample_shading`], kept past construction because
184    /// later-registered custom shaders go through [`build_quad_pipeline`]
185    /// too. (`depth_readback` lives on in [`Scene3DPaint`].)
186    per_sample_shading: bool,
187
188    // Shared resources.
189    pipeline_layout: wgpu::PipelineLayout,
190    /// Pipeline layout for `samples_backdrop` custom shaders — adds
191    /// `@group(1)` for the snapshot texture + sampler.
192    backdrop_pipeline_layout: wgpu::PipelineLayout,
193    quad_bind_group: wgpu::BindGroup,
194    backdrop_bind_layout: wgpu::BindGroupLayout,
195    backdrop_sampler: wgpu::Sampler,
196    frame_buf: wgpu::Buffer,
197    quad_vbo: wgpu::Buffer,
198    instance_buf: wgpu::Buffer,
199    instance_capacity: usize,
200
201    // One pipeline per registered shader (stock + custom).
202    pipelines: HashMap<ShaderHandle, wgpu::RenderPipeline>,
203    // Custom shader names registered with `samples_backdrop=true`. The
204    // paint scheduler queries this to insert pass boundaries before the
205    // first backdrop-sampling draw.
206    backdrop_shaders: HashSet<&'static str>,
207    // Custom shader names registered with `samples_time=true`. Mirrors
208    // `backdrop_shaders` but feeds `prepare_layout`'s continuous-redraw
209    // scan instead of the paint scheduler.
210    time_shaders: HashSet<&'static str>,
211    // Retained WGSL source per registered custom shader, keyed by name
212    // (re-registering replaces the entry). `register_shader_with` builds
213    // the pipeline *and* stashes the source here so
214    // [`Self::set_target_format`] can rebuild every custom pipeline against
215    // the new swapchain format. The bool is the `samples_backdrop` flag,
216    // which selects the same pipeline layout the original registration used.
217    custom_shaders: HashMap<&'static str, (String, bool)>,
218
219    // stock::text resources — atlas, page textures, glyph instances.
220    text_paint: TextPaint,
221    // stock::icon_line resources — vector icon stroke instances.
222    icon_paint: IconPaint,
223    // stock::image resources — per-image texture cache + instance buf.
224    image_paint: ImagePaint,
225    surface_paint: SurfacePaint,
226    // stock::scene resources — geometry buffer cache, per-node offscreen
227    // targets, scene pipelines. Renders DrawOp::Scene3D offscreen and
228    // composites the resolved texture through the surface path.
229    scene_paint: Scene3DPaint,
230
231    /// Lazily-allocated snapshot of the color target, sized to match
232    /// the current target on each `render()`. Backdrop-sampling
233    /// shaders read this via `@group(1)` after Pass A.
234    snapshot: Option<SnapshotTexture>,
235    /// Bind group binding the snapshot view + sampler. Rebuilt each
236    /// time the snapshot texture is reallocated.
237    backdrop_bind_group: Option<wgpu::BindGroup>,
238
239    /// Wall-clock origin for the `time` field in `FrameUniforms`.
240    /// `prepare()` writes `(now - start_time).as_secs_f32()`.
241    start_time: Instant,
242
243    /// Output white-level scale written into `FrameUniforms.white_scale`.
244    /// 1.0 whenever the surface puts reference white at signal 1.0 —
245    /// 8-bit sRGB, and Wayland's anchored parametric ext-linear float
246    /// swapchain. A host whose surface reads as genuine Windows scRGB
247    /// (signal 1.0 = 80 cd/m² absolute) sets 203/80 so UI white lands
248    /// at the encoding's assumed reference white. See
249    /// [`Self::set_white_scale`] and docs/COLOR_MANAGEMENT.md.
250    white_scale: f32,
251    /// Output luminance headroom (`target_max / reference`, 1.0 on SDR)
252    /// and reference white in cd/m², written into
253    /// `FrameUniforms.headroom/ref_nits` and (headroom) mirrored into
254    /// the image paint for the per-image HDR remaster. See
255    /// [`Self::set_output_luminance`].
256    headroom: f32,
257    ref_nits: f32,
258
259    // Backend-agnostic state shared with damascene-vulkano: interaction
260    // state, paint-stream scratch (quad_scratch / runs / paint_items),
261    // viewport_px, last_tree, the 13 input plumbing methods.
262    core: RunnerCore,
263}
264
265struct SnapshotTexture {
266    texture: wgpu::Texture,
267    extent: (u32, u32),
268}
269
270struct PaintRecorder<'a> {
271    text: &'a mut TextPaint,
272    icons: &'a mut IconPaint,
273    images: &'a mut ImagePaint,
274    surfaces: &'a mut SurfacePaint,
275    scenes: &'a mut Scene3DPaint,
276    device: &'a wgpu::Device,
277    queue: &'a wgpu::Queue,
278}
279
280impl TextRecorder for PaintRecorder<'_> {
281    fn record(
282        &mut self,
283        rect: Rect,
284        scissor: Option<PhysicalScissor>,
285        style: &damascene_core::text::atlas::RunStyle,
286        text: &str,
287        size: f32,
288        line_height: f32,
289        wrap: TextWrap,
290        anchor: TextAnchor,
291        scale_factor: f32,
292    ) -> std::ops::Range<usize> {
293        self.text.record(
294            rect,
295            scissor,
296            style,
297            text,
298            size,
299            line_height,
300            wrap,
301            anchor,
302            scale_factor,
303        )
304    }
305
306    fn record_runs(
307        &mut self,
308        rect: Rect,
309        scissor: Option<PhysicalScissor>,
310        runs: &[(String, RunStyle)],
311        size: f32,
312        line_height: f32,
313        wrap: TextWrap,
314        anchor: TextAnchor,
315        scale_factor: f32,
316    ) -> std::ops::Range<usize> {
317        self.text.record_runs(
318            rect,
319            scissor,
320            runs,
321            size,
322            line_height,
323            wrap,
324            anchor,
325            scale_factor,
326        )
327    }
328
329    fn record_icon(
330        &mut self,
331        rect: Rect,
332        scissor: Option<PhysicalScissor>,
333        source: &damascene_core::icons::svg::IconSource,
334        color: Color,
335        _size: f32,
336        stroke_width: f32,
337        _scale_factor: f32,
338    ) -> RecordedPaint {
339        RecordedPaint::Icon(
340            self.icons
341                .record(rect, scissor, source, color, stroke_width),
342        )
343    }
344
345    fn record_image(
346        &mut self,
347        rect: Rect,
348        scissor: Option<PhysicalScissor>,
349        image: &damascene_core::image::Image,
350        tint: Option<Color>,
351        radius: damascene_core::tree::Corners,
352        _fit: damascene_core::image::ImageFit,
353        range_limit: damascene_core::image::DynamicRangeLimit,
354        _scale_factor: f32,
355    ) -> std::ops::Range<usize> {
356        self.images.record(
357            self.device,
358            self.queue,
359            rect,
360            scissor,
361            image,
362            tint,
363            radius,
364            range_limit,
365        )
366    }
367
368    fn record_app_texture(
369        &mut self,
370        rect: Rect,
371        scissor: Option<PhysicalScissor>,
372        texture: &damascene_core::surface::AppTexture,
373        alpha: damascene_core::surface::SurfaceAlpha,
374        transform: damascene_core::affine::Affine2,
375        _scale_factor: f32,
376    ) -> std::ops::Range<usize> {
377        self.surfaces
378            .record(self.device, rect, scissor, texture, alpha, transform)
379    }
380
381    fn record_vector(
382        &mut self,
383        rect: Rect,
384        scissor: Option<PhysicalScissor>,
385        asset: &damascene_core::vector::VectorAsset,
386        render_mode: damascene_core::vector::VectorRenderMode,
387        _scale_factor: f32,
388    ) -> std::ops::Range<usize> {
389        self.icons.record_vector(rect, scissor, asset, render_mode)
390    }
391
392    fn record_scene3d(
393        &mut self,
394        rect: Rect,
395        scissor: Option<PhysicalScissor>,
396        id: &str,
397        scene: &std::sync::Arc<damascene_core::scene::Scene3DData>,
398        scale_factor: f32,
399    ) -> std::ops::Range<usize> {
400        self.scenes
401            .record(self.device, rect, scissor, id, scene, scale_factor)
402    }
403}
404
405/// Build the four stock rect-shaped quad pipelines (rounded_rect, spinner,
406/// skeleton, progress_indeterminate) into `pipelines`, replacing any
407/// existing entries. Shared by [`Runner::with_caps`] and
408/// [`Runner::set_target_format`] so the catalog stays a single source of
409/// truth — only `target_format` varies across the two call sites.
410fn build_stock_quad_pipelines(
411    pipelines: &mut HashMap<ShaderHandle, wgpu::RenderPipeline>,
412    device: &wgpu::Device,
413    layout: &wgpu::PipelineLayout,
414    target_format: wgpu::TextureFormat,
415    sample_count: u32,
416    per_sample_shading: bool,
417) {
418    for (handle, label, wgsl) in [
419        (
420            StockShader::RoundedRect,
421            "stock::rounded_rect",
422            stock_wgsl::ROUNDED_RECT,
423        ),
424        (StockShader::Spinner, "stock::spinner", stock_wgsl::SPINNER),
425        (
426            StockShader::Skeleton,
427            "stock::skeleton",
428            stock_wgsl::SKELETON,
429        ),
430        (
431            StockShader::ProgressIndeterminate,
432            "stock::progress_indeterminate",
433            stock_wgsl::PROGRESS_INDETERMINATE,
434        ),
435    ] {
436        let pipeline = build_quad_pipeline(
437            device,
438            layout,
439            target_format,
440            sample_count,
441            label,
442            wgsl,
443            per_sample_shading,
444        );
445        pipelines.insert(ShaderHandle::Stock(handle), pipeline);
446    }
447}
448
449impl Runner {
450    /// Create a runner for the given target color format. The host
451    /// passes its swapchain/render-target format here so pipelines and
452    /// the glyph atlas are built compatible.
453    pub fn new(
454        device: &wgpu::Device,
455        queue: &wgpu::Queue,
456        target_format: wgpu::TextureFormat,
457    ) -> Self {
458        Self::with_sample_count(device, queue, target_format, 1)
459    }
460
461    /// Like [`Self::new`], but builds all pipelines with `sample_count`
462    /// MSAA samples. The host must provide a matching multisampled
463    /// render target and a single-sample resolve target. `sample_count`
464    /// of 1 is the non-MSAA default.
465    ///
466    /// Defaults to [`RunnerCaps::default`] (everything supported) —
467    /// appropriate for native adapters. Hosts that can land on GL or
468    /// browser adapters must instead route through [`Self::with_caps`]
469    /// with [`RunnerCaps::from_adapter`], otherwise stock pipelines fail
470    /// naga validation on shader-module creation.
471    pub fn with_sample_count(
472        device: &wgpu::Device,
473        queue: &wgpu::Queue,
474        target_format: wgpu::TextureFormat,
475        sample_count: u32,
476    ) -> Self {
477        Self::with_caps(
478            device,
479            queue,
480            target_format,
481            sample_count,
482            RunnerCaps::default(),
483        )
484    }
485
486    /// Like [`Self::with_sample_count`], but with the adapter caps
487    /// supplied explicitly — see [`RunnerCaps`] for what each cap gates:
488    ///
489    /// ```ignore
490    /// Runner::with_caps(&device, &queue, format, sample_count,
491    ///                   RunnerCaps::from_adapter(&adapter))
492    /// ```
493    pub fn with_caps(
494        device: &wgpu::Device,
495        _queue: &wgpu::Queue,
496        target_format: wgpu::TextureFormat,
497        sample_count: u32,
498        caps: RunnerCaps,
499    ) -> Self {
500        let RunnerCaps {
501            per_sample_shading,
502            depth_readback,
503        } = caps;
504        // ---- Shared resources ----
505        let frame_buf = device.create_buffer(&wgpu::BufferDescriptor {
506            label: Some("damascene_wgpu::frame_uniforms"),
507            size: std::mem::size_of::<FrameUniforms>() as u64,
508            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
509            mapped_at_creation: false,
510        });
511
512        let frame_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
513            label: Some("damascene_wgpu::frame_bind_layout"),
514            entries: &[wgpu::BindGroupLayoutEntry {
515                binding: 0,
516                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
517                ty: wgpu::BindingType::Buffer {
518                    ty: wgpu::BufferBindingType::Uniform,
519                    has_dynamic_offset: false,
520                    min_binding_size: None,
521                },
522                count: None,
523            }],
524        });
525
526        let quad_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
527            label: Some("damascene_wgpu::frame_bind_group"),
528            layout: &frame_bind_layout,
529            entries: &[wgpu::BindGroupEntry {
530                binding: 0,
531                resource: frame_buf.as_entire_binding(),
532            }],
533        });
534
535        let quad_vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
536            label: Some("damascene_wgpu::quad_vbo"),
537            // Triangle strip: 4 corners, uv 0..1.
538            contents: bytemuck::cast_slice::<f32, u8>(&[0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0]),
539            usage: wgpu::BufferUsages::VERTEX,
540        });
541
542        let instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
543            label: Some("damascene_wgpu::instance_buf"),
544            size: (INITIAL_INSTANCE_CAPACITY * std::mem::size_of::<QuadInstance>()) as u64,
545            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
546            mapped_at_creation: false,
547        });
548
549        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
550            label: Some("damascene_wgpu::pipeline_layout"),
551            bind_group_layouts: &[Some(&frame_bind_layout)],
552            immediate_size: 0,
553        });
554
555        // ---- Backdrop sampling resources ----
556        //
557        // Custom shaders that opt into backdrop sampling (registered
558        // via `register_shader_with(..samples_backdrop=true)`) get a
559        // pipeline layout with `@group(1)` for the snapshot texture
560        // and sampler. The bind group is rebuilt whenever the
561        // snapshot is (re)allocated.
562        let backdrop_bind_layout =
563            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
564                label: Some("damascene_wgpu::backdrop_bind_layout"),
565                entries: &[
566                    wgpu::BindGroupLayoutEntry {
567                        binding: 0,
568                        visibility: wgpu::ShaderStages::FRAGMENT,
569                        ty: wgpu::BindingType::Texture {
570                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
571                            view_dimension: wgpu::TextureViewDimension::D2,
572                            multisampled: false,
573                        },
574                        count: None,
575                    },
576                    wgpu::BindGroupLayoutEntry {
577                        binding: 1,
578                        visibility: wgpu::ShaderStages::FRAGMENT,
579                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
580                        count: None,
581                    },
582                ],
583            });
584        let backdrop_pipeline_layout =
585            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
586                label: Some("damascene_wgpu::backdrop_pipeline_layout"),
587                bind_group_layouts: &[Some(&frame_bind_layout), Some(&backdrop_bind_layout)],
588                immediate_size: 0,
589            });
590        let backdrop_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
591            label: Some("damascene_wgpu::backdrop_sampler"),
592            address_mode_u: wgpu::AddressMode::ClampToEdge,
593            address_mode_v: wgpu::AddressMode::ClampToEdge,
594            address_mode_w: wgpu::AddressMode::ClampToEdge,
595            mag_filter: wgpu::FilterMode::Linear,
596            min_filter: wgpu::FilterMode::Linear,
597            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
598            ..Default::default()
599        });
600
601        // Build stock rect-shaped pipelines up-front; custom shaders are
602        // added on demand by the host.
603        let mut pipelines = HashMap::new();
604        build_stock_quad_pipelines(
605            &mut pipelines,
606            device,
607            &pipeline_layout,
608            target_format,
609            sample_count,
610            per_sample_shading,
611        );
612
613        // Text pipeline + atlas (replaces glyphon).
614        let text_paint = TextPaint::new(device, target_format, sample_count, &frame_bind_layout);
615        let icon_paint = IconPaint::new(device, target_format, sample_count, &frame_bind_layout);
616        let image_paint = ImagePaint::new(device, target_format, sample_count, &frame_bind_layout);
617        let surface_paint =
618            SurfacePaint::new(device, target_format, sample_count, &frame_bind_layout);
619        let scene_paint = Scene3DPaint::new(
620            device,
621            target_format,
622            sample_count,
623            &frame_bind_layout,
624            damascene_core::paint::DEFAULT_WORKING_COLOR_SPACE,
625            depth_readback,
626        );
627
628        let mut core = RunnerCore::new();
629        core.quad_scratch = Vec::with_capacity(INITIAL_INSTANCE_CAPACITY);
630
631        Self {
632            target_format,
633            sample_count,
634            per_sample_shading,
635            pipeline_layout,
636            backdrop_pipeline_layout,
637            quad_bind_group,
638            backdrop_bind_layout,
639            backdrop_sampler,
640            frame_buf,
641            quad_vbo,
642            instance_buf,
643            instance_capacity: INITIAL_INSTANCE_CAPACITY,
644            pipelines,
645            backdrop_shaders: HashSet::new(),
646            time_shaders: HashSet::new(),
647            custom_shaders: HashMap::new(),
648            text_paint,
649            icon_paint,
650            image_paint,
651            surface_paint,
652            scene_paint,
653            snapshot: None,
654            backdrop_bind_group: None,
655            start_time: Instant::now(),
656            white_scale: 1.0,
657            headroom: 1.0,
658            ref_nits: damascene_core::color::BT2408_REFERENCE_WHITE_NITS,
659            core,
660        }
661    }
662
663    /// Tell the runner the swapchain texture size in physical pixels.
664    /// Call this once after `surface.configure(...)` and again on every
665    /// `WindowEvent::Resized`. The runner uses this as the canonical
666    /// `viewport_px` for scissor math; without it, the value is derived
667    /// from `viewport.w * scale_factor`, which can drift by one pixel
668    /// when `scale_factor` is fractional and trip wgpu's
669    /// `set_scissor_rect` validation.
670    pub fn set_surface_size(&mut self, width: u32, height: u32) {
671        self.core.set_surface_size(width, height);
672    }
673
674    /// Set the color space the renderer composites in. Hosts call this
675    /// once after negotiating a surface format with the display server
676    /// (see `damascene-winit-wgpu`) and before the first frame. Updates the
677    /// shared quad path (via `RunnerCore`) and this backend's text /
678    /// icon / image color recorders so every color crosses the working-
679    /// space boundary consistently.
680    ///
681    /// The working space must match how the swapchain interprets the
682    /// pixels the renderer writes: `SRGB_LINEAR` for an `*_unorm_srgb`
683    /// surface (the default), `SCRGB_LINEAR` / `DISPLAY_P3_LINEAR` for
684    /// an `Rgba16Float` surface, etc.
685    pub fn set_working_color_space(&mut self, space: damascene_core::color::ColorSpace) {
686        self.core.set_working_color_space(space);
687        self.text_paint.set_working_color_space(space);
688        self.icon_paint.set_working_color_space(space);
689        self.image_paint.set_working_color_space(space);
690        self.scene_paint.set_working_color_space(space);
691    }
692
693    /// The color space the renderer currently composites in.
694    pub fn working_color_space(&self) -> damascene_core::color::ColorSpace {
695        self.core.working_color_space()
696    }
697
698    /// Rebuild every swapchain-format-bound render pipeline for a new
699    /// surface format, in place, preserving all other runner state.
700    ///
701    /// The `damascene-winit-wgpu` host calls this on **live color
702    /// renegotiation** — when the display server hands back a different
703    /// surface format than the one the runner was built with (e.g.
704    /// `Bgra8UnormSrgb` ↔ `Rgba16Float` when HDR turns on or off). The
705    /// swapchain format is baked into every pipeline's `ColorTargetState`,
706    /// so those pipelines must be recreated; everything else can stay.
707    ///
708    /// **What survives:** all interaction state in `RunnerCore` (hover,
709    /// focus, press, selection, scroll, hotkeys, the laid-out tree
710    /// snapshot), the glyph + icon MSDF atlases and their GPU page
711    /// textures, the per-image and app-texture/surface bind-group caches,
712    /// the scene geometry caches and per-node offscreen targets, and every
713    /// instance/uniform/vertex buffer. No atlas re-rasterization, no
714    /// texture re-upload, no layout recompute.
715    ///
716    /// **What's rebuilt:** the four stock quad pipelines (rounded_rect,
717    /// spinner, skeleton, progress_indeterminate), every retained custom
718    /// shader pipeline, and the swapchain-bound pipelines inside each paint
719    /// module (text color/MSDF/highlight, icon flat/relief/glass/MSDF,
720    /// image, surface premul/straight/opaque, and the scene composite —
721    /// the scene's offscreen point/line/mesh + occlusion pipelines render
722    /// to fixed formats and are left alone). The backdrop snapshot texture
723    /// is dropped so it reallocates in the new format on the next
724    /// backdrop-sampling frame.
725    ///
726    /// Early-returns when `format` already matches the current target.
727    /// `sample_count` and `per_sample_shading` are unaffected.
728    pub fn set_target_format(&mut self, device: &wgpu::Device, format: wgpu::TextureFormat) {
729        if format == self.target_format {
730            return;
731        }
732        self.target_format = format;
733
734        // Stock quad pipelines (replaces the four entries in place).
735        build_stock_quad_pipelines(
736            &mut self.pipelines,
737            device,
738            &self.pipeline_layout,
739            format,
740            self.sample_count,
741            self.per_sample_shading,
742        );
743
744        // Retained custom shader pipelines. Same layout selection as
745        // `register_shader_with`: backdrop-sampling shaders bind `@group(1)`.
746        for (name, (wgsl, samples_backdrop)) in &self.custom_shaders {
747            let layout = if *samples_backdrop {
748                &self.backdrop_pipeline_layout
749            } else {
750                &self.pipeline_layout
751            };
752            let pipeline = build_quad_pipeline(
753                device,
754                layout,
755                format,
756                self.sample_count,
757                &format!("custom::{name}"),
758                wgsl,
759                self.per_sample_shading,
760            );
761            self.pipelines.insert(ShaderHandle::Custom(name), pipeline);
762        }
763
764        // Per-paint-module swapchain-bound pipelines.
765        self.text_paint.set_target_format(device, format);
766        self.icon_paint.set_target_format(device, format);
767        self.image_paint.set_target_format(device, format);
768        self.surface_paint.set_target_format(device, format);
769        self.scene_paint.set_target_format(device, format);
770
771        // The backdrop snapshot texture is created in the target format
772        // (see `ensure_snapshot`); drop it so the next backdrop-sampling
773        // frame lazily reallocates it in the new format. The bind group
774        // referencing it goes too — it's rebuilt alongside the texture.
775        self.snapshot = None;
776        self.backdrop_bind_group = None;
777    }
778
779    /// Set the output white-level scale (default 1.0). Leave at 1.0
780    /// whenever the surface puts reference white at signal 1.0: 8-bit
781    /// sRGB by definition, and Wayland float swapchains tagged as
782    /// parametric ext-linear (the WSI default — the compositor anchors
783    /// signal 1.0 to the output reference; scaling on top double-lifts
784    /// ~2.5×). Pass
785    /// [`damascene_core::color::WINDOWS_SCRGB_WHITE_SCALE`] only when
786    /// the surface genuinely reads as Windows scRGB — signal 1.0 =
787    /// 80 cd/m² *absolute*, assumed reference white at 2.5375 (203
788    /// cd/m², BT.2408) — so SDR-referred UI white lands at the
789    /// reference level instead of 80 nits.
790    pub fn set_white_scale(&mut self, scale: f32) {
791        self.white_scale = scale;
792    }
793
794    /// Set the output's luminance frame: `headroom` = usable range in
795    /// multiples of reference white (`target_max / reference`; 1.0 on
796    /// SDR — the default — or `f32::INFINITY` when the output declared
797    /// no maximum) and `reference_nits` = the output's reference white
798    /// in cd/m² (default 203, BT.2408). Feeds
799    /// `FrameUniforms.headroom/ref_nits` and the per-image HDR
800    /// remaster: image draws whose measured content peak exceeds their
801    /// [`damascene_core::image::DynamicRangeLimit`] resolved against
802    /// this headroom are rolled off (BT.2390) to fit. Hosts re-call
803    /// this whenever the output's preferred description changes.
804    pub fn set_output_luminance(&mut self, headroom: f32, reference_nits: f32) {
805        self.headroom = headroom;
806        self.ref_nits = reference_nits;
807        self.image_paint.set_headroom(headroom);
808    }
809
810    /// Set the theme used to resolve implicit widget surfaces to shaders.
811    /// Pre-rasterize printable ASCII for the bundled default faces
812    /// (Inter Variable + JetBrains Mono Variable). Pays the ~40ms
813    /// one-time MSDF-generation cost up-front so the first frame that
814    /// introduces each character doesn't take a 20-30ms paint hit.
815    /// Hosts that interactively render UI text (the showcase, custom
816    /// apps, etc.) should call this once after constructing the
817    /// `Runner` and before the first frame; headless fixtures that
818    /// render only static content can skip it. MSDF keys are
819    /// size-independent so each character is rasterized exactly once
820    /// and reused for every size + weight afterwards.
821    pub fn warm_default_glyphs(&mut self) {
822        self.text_paint.warm_default_glyphs();
823    }
824
825    pub fn set_theme(&mut self, theme: Theme) {
826        self.icon_paint.set_material(theme.icon_material());
827        self.core.set_theme(theme);
828    }
829
830    pub fn theme(&self) -> &Theme {
831        self.core.theme()
832    }
833
834    /// Select the stock material used by the vector-icon painter.
835    /// Prefer [`Theme::with_icon_material`] for app-level routing; this
836    /// remains useful for low-level render fixtures.
837    pub fn set_icon_material(&mut self, material: IconMaterial) {
838        self.icon_paint.set_material(material);
839    }
840
841    pub fn icon_material(&self) -> IconMaterial {
842        self.icon_paint.material()
843    }
844
845    /// Register a custom shader. `name` is the same string passed to
846    /// `damascene_core::shader::ShaderBinding::custom`; nodes bound to it
847    /// via [`El::shader`](damascene_core::tree::El) paint through this
848    /// pipeline.
849    ///
850    /// The WGSL source must use the shared `(rect, vec_a, vec_b, vec_c)`
851    /// instance layout and the `FrameUniforms` bind group described in
852    /// the module docs. Compilation happens at register time — invalid
853    /// WGSL panics here, not mid-frame.
854    ///
855    /// Re-registering the same name replaces the previous pipeline
856    /// (useful for hot-reload during development).
857    pub fn register_shader(&mut self, device: &wgpu::Device, name: &'static str, wgsl: &str) {
858        self.register_shader_with(device, name, wgsl, false, false);
859    }
860
861    /// Register a custom shader, with opt-in flags for backdrop
862    /// sampling and time-driven motion.
863    ///
864    /// `samples_backdrop=true` schedules the shader's draws into
865    /// Pass B (after a snapshot of Pass A's rendered content) and
866    /// binds the snapshot texture as `@group(2) binding=0`
867    /// (`backdrop_tex`) plus a sampler at `binding=1`
868    /// (`backdrop_smp`). See `docs/SHADER_VISION.md` §"Backdrop
869    /// sampling architecture". Backdrop depth is capped at 1.
870    ///
871    /// `samples_time=true` declares that the shader's output depends
872    /// on `frame.time`. The runtime ORs this into
873    /// [`PrepareResult::needs_redraw`] for any frame that has at
874    /// least one node bound to the shader, so the host idle loop
875    /// keeps ticking without a per-El opt-in. Stock shaders self-
876    /// report through [`damascene_core::shader::StockShader::is_continuous`];
877    /// this flag is the same signal for app-registered WGSL.
878    pub fn register_shader_with(
879        &mut self,
880        device: &wgpu::Device,
881        name: &'static str,
882        wgsl: &str,
883        samples_backdrop: bool,
884        samples_time: bool,
885    ) {
886        let label = format!("custom::{name}");
887        let layout = if samples_backdrop {
888            &self.backdrop_pipeline_layout
889        } else {
890            &self.pipeline_layout
891        };
892        let pipeline = build_quad_pipeline(
893            device,
894            layout,
895            self.target_format,
896            self.sample_count,
897            &label,
898            wgsl,
899            self.per_sample_shading,
900        );
901        self.pipelines.insert(ShaderHandle::Custom(name), pipeline);
902        // Retain the source so the pipeline can be rebuilt against a new
903        // swapchain format in `set_target_format`. Re-registering replaces
904        // the prior entry, matching the pipeline-map replacement above.
905        self.custom_shaders
906            .insert(name, (wgsl.to_string(), samples_backdrop));
907        if samples_backdrop {
908            self.backdrop_shaders.insert(name);
909        } else {
910            self.backdrop_shaders.remove(name);
911        }
912        if samples_time {
913            self.time_shaders.insert(name);
914        } else {
915            self.time_shaders.remove(name);
916        }
917    }
918
919    /// Borrow the internal [`UiState`] — primarily for headless fixtures
920    /// that want to look up a node's rect after `prepare` (e.g., to
921    /// simulate a pointer at a specific button's center).
922    pub fn ui_state(&self) -> &UiState {
923        self.core.ui_state()
924    }
925
926    /// One-line diagnostic snapshot of interactive state — passes through
927    /// to [`UiState::debug_summary`]. Intended for per-frame logging
928    /// (e.g., `console.log` from the wasm host while debugging hover /
929    /// animation glitches).
930    pub fn debug_summary(&self) -> String {
931        self.core.debug_summary()
932    }
933
934    /// Return the most recently laid-out rectangle for a keyed node.
935    ///
936    /// Call after [`Self::prepare`]. This is the host-composition hook:
937    /// reserve a keyed Damascene element in the UI tree, ask for its rect
938    /// here, then record host-owned rendering into that region using the
939    /// same encoder / render flow that surrounds Damascene's pass.
940    pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
941        self.core.rect_of_key(key)
942    }
943
944    /// Lay out the tree, resolve to draw ops, and upload per-frame
945    /// buffers (quad instances + glyph atlas). Must be called before
946    /// [`Self::draw`] and outside of any render pass.
947    ///
948    /// `viewport` is in **logical** pixels — the units the layout pass
949    /// works in. `scale_factor` is the HiDPI multiplier (1.0 on a
950    /// regular display, 2.0 on most modern HiDPI, can be fractional).
951    /// The host's render-pass target should be sized at physical pixels
952    /// (`viewport × scale_factor`); the runner maps logical → physical
953    /// internally so layout, fonts, and SDF math stay device-independent.
954    pub fn prepare(
955        &mut self,
956        device: &wgpu::Device,
957        queue: &wgpu::Queue,
958        root: &mut El,
959        viewport: Rect,
960        scale_factor: f32,
961    ) -> PrepareResult {
962        let mut timings = PrepareTimings::default();
963
964        // Install any scene depth maps that finished reading back (a frame
965        // or two late) so this frame's `draw_ops` can occlude scene-anchored
966        // labels behind geometry. Done before `prepare_layout` runs the
967        // draw-op pass. Stale maps for scenes that left the tree are GC'd.
968        let ready_depth = self.scene_paint.collect_depth_maps(device);
969        if !ready_depth.is_empty() {
970            let depth_maps = self.core.ui_state.scene_depth_mut();
971            for (id, map) in ready_depth {
972                depth_maps.insert(id, map);
973            }
974        }
975        self.core
976            .ui_state
977            .scene_depth_mut()
978            .retain(|id, _| self.scene_paint.has_target(id));
979
980        // Layout + state apply + animation tick + draw_ops resolution.
981        // Writes timings.layout + timings.draw_ops. The closure feeds
982        // the runtime's continuous-redraw scan: any node bound to a
983        // shader registered with `samples_time=true` keeps the host
984        // loop ticking even when no animation is settling.
985        let time_shaders = &self.time_shaders;
986        let LayoutPrepared {
987            ops,
988            mut needs_redraw,
989            mut next_layout_redraw_in,
990            next_paint_redraw_in,
991        } = self
992            .core
993            .prepare_layout(
994                root,
995                viewport,
996                scale_factor,
997                &mut timings,
998                |handle| match handle {
999                    ShaderHandle::Custom(name) => time_shaders.contains(name),
1000                    ShaderHandle::Stock(_) => false,
1001                },
1002            );
1003
1004        // Paint stream: pack quads, record text, preserve z-order. The
1005        // closure is the wgpu-specific "is this shader registered?"
1006        // query (different pipeline types per backend prevent moving the
1007        // check itself into core).
1008        self.text_paint.frame_begin();
1009        self.icon_paint.frame_begin();
1010        self.image_paint.frame_begin();
1011        self.surface_paint.frame_begin();
1012        self.scene_paint.frame_begin();
1013        let pipelines = &self.pipelines;
1014        let backdrop_shaders = &self.backdrop_shaders;
1015        let mut recorder = PaintRecorder {
1016            text: &mut self.text_paint,
1017            icons: &mut self.icon_paint,
1018            images: &mut self.image_paint,
1019            surfaces: &mut self.surface_paint,
1020            scenes: &mut self.scene_paint,
1021            device,
1022            queue,
1023        };
1024        self.core.prepare_paint(
1025            &ops,
1026            |shader| pipelines.contains_key(shader),
1027            |shader| match shader {
1028                ShaderHandle::Custom(name) => backdrop_shaders.contains(name),
1029                ShaderHandle::Stock(_) => false,
1030            },
1031            &mut recorder,
1032            scale_factor,
1033            &mut timings,
1034        );
1035
1036        // GPU upload — wgpu-specific. Resize the instance buffer if
1037        // needed, then write quad_scratch + frame uniforms + flush text
1038        // atlas dirty regions. Wrapped in its own scope so the
1039        // `prepare::gpu_upload` span doesn't bleed into the subsequent
1040        // `snapshot` call (which carries its own span).
1041        {
1042            damascene_core::profile_span!("prepare::gpu_upload");
1043            let t_paint_end = Instant::now();
1044            if self.core.quad_scratch.len() > self.instance_capacity {
1045                let new_cap = self.core.quad_scratch.len().next_power_of_two();
1046                self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
1047                    label: Some("damascene_wgpu::instance_buf (resized)"),
1048                    size: (new_cap * std::mem::size_of::<QuadInstance>()) as u64,
1049                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1050                    mapped_at_creation: false,
1051                });
1052                self.instance_capacity = new_cap;
1053            }
1054            if !self.core.quad_scratch.is_empty() {
1055                queue.write_buffer(
1056                    &self.instance_buf,
1057                    0,
1058                    bytemuck::cast_slice(&self.core.quad_scratch),
1059                );
1060            }
1061            self.text_paint.flush(device, queue);
1062            self.icon_paint.flush(device, queue);
1063            self.image_paint.flush(device, queue);
1064            self.surface_paint.flush(device, queue);
1065            self.scene_paint.flush(device, queue);
1066            // Pin time to 0 in Settled mode so headless fixtures rendering
1067            // a time-driven shader (e.g. stock::spinner) stay byte-identical
1068            // run-to-run, the same way `Animation::settle()` makes the
1069            // spring/tween path deterministic for SVG/PNG snapshots.
1070            let time = match self.core.ui_state().animation_mode() {
1071                damascene_core::AnimationMode::Settled => 0.0,
1072                damascene_core::AnimationMode::Live => {
1073                    (Instant::now() - self.start_time).as_secs_f32()
1074                }
1075            };
1076            let frame = FrameUniforms {
1077                viewport: [viewport.w, viewport.h],
1078                time,
1079                scale_factor,
1080                white_scale: self.white_scale,
1081                headroom: self.headroom,
1082                ref_nits: self.ref_nits,
1083                _reserved: 0.0,
1084            };
1085            queue.write_buffer(&self.frame_buf, 0, bytemuck::bytes_of(&frame));
1086            timings.gpu_upload = Instant::now() - t_paint_end;
1087        }
1088
1089        // Snapshot the laid-out tree for next-frame hit-testing.
1090        self.core.snapshot(root, &mut timings);
1091
1092        // Move resolved ops into the core's cache so a subsequent
1093        // paint-only frame can reuse them without re-running layout.
1094        self.core.last_ops = ops;
1095
1096        // Damascene renders lazily, but the label-occlusion depth read-back needs
1097        // a few frames to resolve. Keep frames coming until every labelled
1098        // scene has a depth map matching its current pose — otherwise a
1099        // capture started in `render` would sit unmapped after the camera
1100        // settles and the labels would never appear. Settled + current scenes
1101        // (and label-free ones) report `false`, so lazy idle is preserved.
1102        //
1103        // This must drive `next_layout_redraw_in`, not just `needs_redraw`:
1104        // hosts schedule the next frame off the deadline lanes (the winit
1105        // host ignores `needs_redraw`), and it must be the *layout* lane, not
1106        // the paint lane — the paint-only `repaint` path skips
1107        // `collect_depth_maps`, so only a full `prepare` advances the readback.
1108        if self.scene_paint.occlusion_unsettled() {
1109            needs_redraw = true;
1110            next_layout_redraw_in = Some(std::time::Duration::ZERO);
1111        }
1112
1113        let next_redraw_in = match (next_layout_redraw_in, next_paint_redraw_in) {
1114            (Some(a), Some(b)) => Some(a.min(b)),
1115            (Some(d), None) | (None, Some(d)) => Some(d),
1116            (None, None) => None,
1117        };
1118        PrepareResult {
1119            needs_redraw,
1120            next_redraw_in,
1121            next_layout_redraw_in,
1122            next_paint_redraw_in,
1123            timings,
1124        }
1125    }
1126
1127    /// Paint-only frame: rerun [`RunnerCore::prepare_paint_cached`] +
1128    /// GPU upload + frame-uniform write against the cached ops from
1129    /// the most recent [`Self::prepare`] call. Skips rebuild + layout
1130    /// + draw_ops + snapshot — only `frame.time` advances.
1131    ///
1132    /// Hosts call this when [`PrepareResult::next_paint_redraw_in`]
1133    /// fires (a time-driven shader needs another frame) and no input
1134    /// has been processed since the last full prepare. Input always
1135    /// upgrades to the full `prepare(...)` path.
1136    ///
1137    /// `viewport` and `scale_factor` must match the values passed to
1138    /// the most recent `prepare(...)` — a resize must go through the
1139    /// full layout path. Returns the same shape of [`PrepareResult`]
1140    /// for diagnostic continuity, with both deadlines re-computed
1141    /// from the cached signals: `next_layout_redraw_in` is `None` (we
1142    /// didn't re-evaluate), and `next_paint_redraw_in` is whatever
1143    /// the cached ops still report. The host owns the layout
1144    /// deadline across paint-only frames.
1145    pub fn repaint(
1146        &mut self,
1147        device: &wgpu::Device,
1148        queue: &wgpu::Queue,
1149        viewport: Rect,
1150        scale_factor: f32,
1151    ) -> PrepareResult {
1152        let mut timings = PrepareTimings::default();
1153
1154        self.text_paint.frame_begin();
1155        self.icon_paint.frame_begin();
1156        self.image_paint.frame_begin();
1157        self.surface_paint.frame_begin();
1158        self.scene_paint.frame_begin();
1159        let pipelines = &self.pipelines;
1160        let backdrop_shaders = &self.backdrop_shaders;
1161        let mut recorder = PaintRecorder {
1162            text: &mut self.text_paint,
1163            icons: &mut self.icon_paint,
1164            images: &mut self.image_paint,
1165            surfaces: &mut self.surface_paint,
1166            scenes: &mut self.scene_paint,
1167            device,
1168            queue,
1169        };
1170        self.core.prepare_paint_cached(
1171            |shader| pipelines.contains_key(shader),
1172            |shader| match shader {
1173                ShaderHandle::Custom(name) => backdrop_shaders.contains(name),
1174                ShaderHandle::Stock(_) => false,
1175            },
1176            &mut recorder,
1177            scale_factor,
1178            &mut timings,
1179        );
1180
1181        // Same GPU-upload block as prepare(); time advances even though
1182        // ops are unchanged so time-driven shaders animate.
1183        {
1184            damascene_core::profile_span!("repaint::gpu_upload");
1185            let t_paint_end = Instant::now();
1186            if self.core.quad_scratch.len() > self.instance_capacity {
1187                let new_cap = self.core.quad_scratch.len().next_power_of_two();
1188                self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
1189                    label: Some("damascene_wgpu::instance_buf (resized)"),
1190                    size: (new_cap * std::mem::size_of::<QuadInstance>()) as u64,
1191                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1192                    mapped_at_creation: false,
1193                });
1194                self.instance_capacity = new_cap;
1195            }
1196            if !self.core.quad_scratch.is_empty() {
1197                queue.write_buffer(
1198                    &self.instance_buf,
1199                    0,
1200                    bytemuck::cast_slice(&self.core.quad_scratch),
1201                );
1202            }
1203            self.text_paint.flush(device, queue);
1204            self.icon_paint.flush(device, queue);
1205            self.image_paint.flush(device, queue);
1206            self.surface_paint.flush(device, queue);
1207            self.scene_paint.flush(device, queue);
1208            let time = match self.core.ui_state().animation_mode() {
1209                AnimationMode::Settled => 0.0,
1210                AnimationMode::Live => (Instant::now() - self.start_time).as_secs_f32(),
1211            };
1212            let frame = FrameUniforms {
1213                viewport: [viewport.w, viewport.h],
1214                time,
1215                scale_factor,
1216                white_scale: self.white_scale,
1217                headroom: self.headroom,
1218                ref_nits: self.ref_nits,
1219                _reserved: 0.0,
1220            };
1221            queue.write_buffer(&self.frame_buf, 0, bytemuck::bytes_of(&frame));
1222            timings.gpu_upload = Instant::now() - t_paint_end;
1223        }
1224
1225        // Re-evaluate the paint lane against the cached ops so the host
1226        // can re-arm the deadline. Cheap (one scan over already-resolved
1227        // ops). The layout lane is left as `None`: we didn't re-run
1228        // `prepare_layout`, so we have no fresh signal to report — the
1229        // host's previously-set layout deadline still stands.
1230        let time_shaders = &self.time_shaders;
1231        let next_paint_redraw_in = self.core.scan_continuous_shaders(|handle| match handle {
1232            ShaderHandle::Custom(name) => time_shaders.contains(name),
1233            ShaderHandle::Stock(_) => false,
1234        });
1235        PrepareResult {
1236            needs_redraw: next_paint_redraw_in.is_some(),
1237            next_redraw_in: next_paint_redraw_in,
1238            next_layout_redraw_in: None,
1239            next_paint_redraw_in,
1240            timings,
1241        }
1242    }
1243
1244    // ---- Input plumbing ----
1245    //
1246    // The host (winit-side) calls these from its event loop.
1247    // Coordinates are **logical pixels** — divide winit's physical
1248    // PhysicalPosition by the window scale factor before handing them in.
1249
1250    /// Update pointer position and recompute the hovered key.
1251    /// Returns the new hovered key, if any (host can use it for cursor
1252    /// styling or to decide whether to call `request_redraw`).
1253    /// Pointer moved to `p.x, p.y` (logical px). Returns the events to
1254    /// dispatch via `App::on_event` plus a `needs_redraw` flag — see
1255    /// [`PointerMove`] for why hosts must gate `request_redraw` on
1256    /// the flag. The hovered node is updated on `ui_state().hovered`
1257    /// regardless. Mouse-only hosts can construct `p` via
1258    /// [`Pointer::moving`].
1259    pub fn pointer_moved(&mut self, p: Pointer) -> PointerMove {
1260        self.core.pointer_moved(p)
1261    }
1262
1263    /// Pointer left the window — clear hover/press. Returns a
1264    /// `PointerLeave` event for the previously hovered target (when
1265    /// there was one); hosts should route the events through
1266    /// `App::on_event` like the other pointer entry points.
1267    pub fn pointer_left(&mut self) -> Vec<damascene_core::UiEvent> {
1268        self.core.pointer_left()
1269    }
1270
1271    /// File is being dragged over the window. Hosts call this from
1272    /// `winit::WindowEvent::HoveredFile` (one call per file). Returns
1273    /// the `FileHovered` event routed to the keyed leaf at the cursor
1274    /// (or window-level if outside any keyed surface).
1275    pub fn file_hovered(
1276        &mut self,
1277        path: std::path::PathBuf,
1278        x: f32,
1279        y: f32,
1280    ) -> Vec<damascene_core::UiEvent> {
1281        self.core.file_hovered(path, x, y)
1282    }
1283
1284    /// File hover ended without a drop — hosts call this from
1285    /// `winit::WindowEvent::HoveredFileCancelled`. Window-level event
1286    /// (not routed); apps clear any drop-zone affordance.
1287    pub fn file_hover_cancelled(&mut self) -> Vec<damascene_core::UiEvent> {
1288        self.core.file_hover_cancelled()
1289    }
1290
1291    /// File was dropped on the window. Hosts call this from
1292    /// `winit::WindowEvent::DroppedFile` (one call per file).
1293    pub fn file_dropped(
1294        &mut self,
1295        path: std::path::PathBuf,
1296        x: f32,
1297        y: f32,
1298    ) -> Vec<damascene_core::UiEvent> {
1299        self.core.file_dropped(path, x, y)
1300    }
1301
1302    /// Whether a primary press at `(x, y)` (logical px) would land
1303    /// on a node that opted into `capture_keys` — the marker the
1304    /// library uses for text-input-style widgets. Hosts query this
1305    /// from a DOM pointerdown handler to decide whether to focus
1306    /// a hidden textarea (so the soft keyboard can open in the
1307    /// user-gesture context). See
1308    /// [`RunnerCore::would_press_focus_text_input`] for details.
1309    pub fn would_press_focus_text_input(&self, x: f32, y: f32) -> bool {
1310        self.core.would_press_focus_text_input(x, y)
1311    }
1312
1313    /// Whether the currently focused node is a text-input-style
1314    /// widget (i.e. has `capture_keys` set). Hosts mirror this each
1315    /// frame into platform affordances such as the on-screen
1316    /// keyboard or IME compose-window placement.
1317    pub fn focused_captures_keys(&self) -> bool {
1318        self.core.focused_captures_keys()
1319    }
1320
1321    /// Pointer pressed at `p.x, p.y` (logical px) for `p.button`. For
1322    /// `Primary`, records the pressed key for press-visual feedback,
1323    /// updates focus, and returns a `PointerDown` event so widgets that
1324    /// need to react at down-time (text input selection anchor,
1325    /// draggable handles) can do so. For `Secondary` / `Middle`, records
1326    /// on a side channel and returns `None`. The actual click event
1327    /// fires on `pointer_up`. Mouse-only hosts can construct `p` via
1328    /// [`Pointer::mouse`].
1329    pub fn pointer_down(&mut self, p: Pointer) -> Vec<UiEvent> {
1330        self.core.pointer_down(p)
1331    }
1332
1333    /// Replace the tracked modifier mask. Hosts call this from their
1334    /// platform's "modifiers changed" hook so subsequent pointer
1335    /// events (PointerDown, Drag, Click, …) stamp the current mask
1336    /// into `UiEvent.modifiers`.
1337    pub fn set_modifiers(&mut self, modifiers: KeyModifiers) {
1338        self.core.ui_state.set_modifiers(modifiers);
1339    }
1340
1341    /// Pointer released at `p.x, p.y` for `p.button`. Returns the
1342    /// events the host should dispatch in order: for `Primary`, always
1343    /// a `PointerUp` (when there was a corresponding down) followed
1344    /// by an optional `Click` (when the up landed on the down's
1345    /// node). For `Secondary` / `Middle`, an optional `SecondaryClick`
1346    /// / `MiddleClick` on the same-node match. Mouse-only hosts can
1347    /// construct `p` via [`Pointer::mouse`].
1348    pub fn pointer_up(&mut self, p: Pointer) -> Vec<UiEvent> {
1349        self.core.pointer_up(p)
1350    }
1351
1352    pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
1353        self.core.key_down(key, modifiers, repeat)
1354    }
1355
1356    /// Forward an OS-composed text-input string (winit's keyboard event
1357    /// `.text` field, or an `Ime::Commit`) to the focused element as a
1358    /// `TextInput` event.
1359    pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1360        self.core.text_input(text)
1361    }
1362
1363    /// Replace the hotkey registry. Call once per frame, after `app.build()`,
1364    /// passing `app.hotkeys()` so chords stay in sync with state.
1365    pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1366        self.core.set_hotkeys(hotkeys);
1367    }
1368
1369    /// Push the app's current selection to the runtime so the painter
1370    /// can draw highlight bands. Hosts call this once per frame
1371    /// alongside [`Self::set_hotkeys`].
1372    pub fn set_selection(&mut self, selection: damascene_core::selection::Selection) {
1373        self.core.set_selection(selection);
1374    }
1375
1376    /// Resolve the runtime's current selection to a text payload from
1377    /// the most recently laid-out tree. See
1378    /// [`RunnerCore::selected_text`] — virtual-list rows are realized
1379    /// during layout, so a freshly built app tree would miss them and
1380    /// a `Ctrl+C` lookup that walked it would silently come back empty.
1381    pub fn selected_text(&self) -> Option<String> {
1382        self.core.selected_text()
1383    }
1384
1385    /// Resolve an explicit [`damascene_core::selection::Selection`] against
1386    /// the last laid-out tree. See [`RunnerCore::selected_text_for`].
1387    pub fn selected_text_for(
1388        &self,
1389        selection: &damascene_core::selection::Selection,
1390    ) -> Option<String> {
1391        self.core.selected_text_for(selection)
1392    }
1393
1394    /// Queue toast specs onto the runtime's toast stack. Hosts call
1395    /// this once per frame with `app.drain_toasts()`. Each spec is
1396    /// stamped with a monotonic id and an `expires_at` deadline
1397    /// (`now + ttl`); the next `prepare` call drops expired entries
1398    /// and synthesizes a `toast_stack` floating layer over the rest.
1399    pub fn push_toasts(&mut self, specs: Vec<damascene_core::toast::ToastSpec>) {
1400        self.core.push_toasts(specs);
1401    }
1402
1403    /// Programmatically dismiss a toast by id. Useful for cancelling
1404    /// long-TTL toasts when an external condition resolves (e.g.,
1405    /// "reconnecting…" turning into "connected").
1406    pub fn dismiss_toast(&mut self, id: u64) {
1407        self.core.dismiss_toast(id);
1408    }
1409
1410    /// Queue programmatic focus requests by widget key. Hosts call
1411    /// this once per frame with `app.drain_focus_requests()`. Each
1412    /// key is resolved during the next `prepare` against the rebuilt
1413    /// focus order; unmatched keys drop silently.
1414    pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1415        self.core.push_focus_requests(keys);
1416    }
1417
1418    /// Queue programmatic scroll-to-row requests targeting virtual
1419    /// lists by key. Hosts call this once per frame with
1420    /// `app.drain_scroll_requests()`. Each request is consumed during
1421    /// the next `prepare` by the layout pass for the matching list,
1422    /// where viewport height and row heights are known. Unmatched
1423    /// list keys and out-of-range row indices drop silently.
1424    pub fn push_scroll_requests(&mut self, requests: Vec<damascene_core::scroll::ScrollRequest>) {
1425        self.core.push_scroll_requests(requests);
1426    }
1427
1428    /// Switch animation pacing. Default is [`AnimationMode::Live`].
1429    /// Headless render binaries should call this with
1430    /// [`AnimationMode::Settled`] so a single-frame snapshot reflects
1431    /// the post-animation visual without depending on integrator timing.
1432    pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1433        self.core.set_animation_mode(mode);
1434    }
1435
1436    /// Apply a wheel delta in **logical** pixels at `(x, y)`. Routes to
1437    /// the deepest scrollable container under the cursor in the last
1438    /// laid-out tree. Returns `true` if the event landed on a scrollable
1439    /// (host should `request_redraw` so the next frame applies the new
1440    /// offset).
1441    pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1442        self.core.pointer_wheel(x, y, dy)
1443    }
1444
1445    /// Build a routed wheel event for the keyed target under `(x, y)`.
1446    ///
1447    /// Dispatch this before [`Self::pointer_wheel`]; if the app
1448    /// consumes the event, skip the fallback scroll call.
1449    pub fn pointer_wheel_event(
1450        &mut self,
1451        x: f32,
1452        y: f32,
1453        dx: f32,
1454        dy: f32,
1455    ) -> Option<damascene_core::UiEvent> {
1456        self.core.pointer_wheel_event(x, y, dx, dy)
1457    }
1458
1459    /// Drain time-driven input events whose deadline has passed (touch
1460    /// long-press today; later: hold-to-repeat, etc.). Hosts call this
1461    /// once per frame before dispatching pointer events. `now` is
1462    /// `web_time::Instant` rather than `std::time::Instant` so the
1463    /// signature compiles on wasm32 — `web_time` aliases to std on
1464    /// native, so existing native callers passing `Instant::now()`
1465    /// from std still work. See [`damascene_core::RunnerCore::poll_input`].
1466    pub fn poll_input(&mut self, now: web_time::Instant) -> Vec<damascene_core::UiEvent> {
1467        self.core.poll_input(now)
1468    }
1469
1470    /// Record draws into the host-managed render pass. Call after
1471    /// [`Self::prepare`]. Paint order follows the draw-op stream.
1472    ///
1473    /// **No backdrop sampling.** This entry point cannot honor pass
1474    /// boundaries (the host owns the pass lifetime), so any
1475    /// `BackdropSnapshot` items in the paint stream are no-ops and any
1476    /// shader bound with `samples_backdrop=true` reads an undefined
1477    /// backdrop binding. Use [`Self::render`] for backdrop-aware
1478    /// rendering.
1479    pub fn draw<'pass>(&'pass self, pass: &mut wgpu::RenderPass<'pass>) {
1480        self.draw_items(pass, &self.core.paint_items);
1481    }
1482
1483    /// Record draws into a host-supplied encoder, owning pass
1484    /// lifetimes ourselves so backdrop-sampling shaders can sample a
1485    /// snapshot of Pass A's content.
1486    ///
1487    /// The host hands us:
1488    /// - the encoder (we record into it),
1489    /// - the color target's `wgpu::Texture` (used as `copy_src` when
1490    ///   we snapshot it; must include `COPY_SRC` in its usage flags),
1491    /// - the corresponding `wgpu::TextureView` (we attach it to every
1492    ///   render pass we begin), and
1493    /// - the `LoadOp` to use on the *first* pass — `Clear(color)` to
1494    ///   clear behind us, `Load` to composite onto whatever was
1495    ///   already in the target.
1496    ///
1497    /// Multi-pass schedule when the paint stream contains a
1498    /// `BackdropSnapshot`:
1499    ///
1500    /// 1. Pass A — every paint item before the snapshot, with the
1501    ///    caller-supplied `LoadOp`.
1502    /// 2. `copy_texture_to_texture` — target → snapshot.
1503    /// 3. Pass B — paint items from the snapshot onward, with
1504    ///    `LoadOp::Load` so Pass A's pixels remain underneath.
1505    ///
1506    /// Without a snapshot, this collapses to a single pass and is
1507    /// equivalent to [`Self::draw`] called inside a host-managed
1508    /// pass with the same `LoadOp`.
1509    pub fn render(
1510        &mut self,
1511        device: &wgpu::Device,
1512        encoder: &mut wgpu::CommandEncoder,
1513        target_tex: &wgpu::Texture,
1514        target_view: &wgpu::TextureView,
1515        msaa_view: Option<&wgpu::TextureView>,
1516        load_op: wgpu::LoadOp<wgpu::Color>,
1517    ) {
1518        // When MSAA is in use, the actual color attachment is the
1519        // multisampled view and `target_view` becomes its resolve
1520        // target. `target_tex` is always the resolved (single-sample)
1521        // texture, so the snapshot copy below works whether MSAA is on
1522        // or not — the resolve happens at end-of-Pass-A.
1523        let attachment_view = msaa_view.unwrap_or(target_view);
1524        let resolve_target = msaa_view.map(|_| target_view);
1525
1526        // Phase 1: render every recorded 3D scene into its own offscreen
1527        // target. Passes can't nest, so this is encoded on `encoder` ahead
1528        // of the main composite pass (same discipline as BackdropSnapshot).
1529        // The `PaintItem::Scene3D` arm below then composites the resolved
1530        // textures into the main pass.
1531        if self.scene_paint.has_runs() {
1532            self.scene_paint.encode_offscreen(encoder);
1533            // Capture each label-bearing scene's depth into its read-back
1534            // buffer (the depth is still alive from the pass above). The
1535            // map + CPU read happens next frame in `prepare`.
1536            self.scene_paint.encode_depth_capture(device, encoder);
1537        }
1538
1539        // Locate the (at most one) snapshot boundary.
1540        let split_at = self
1541            .core
1542            .paint_items
1543            .iter()
1544            .position(|p| matches!(p, PaintItem::BackdropSnapshot));
1545
1546        if let Some(idx) = split_at {
1547            self.ensure_snapshot(device, target_tex);
1548            // Pass A
1549            {
1550                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1551                    label: Some("damascene_wgpu::pass_a"),
1552                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1553                        view: attachment_view,
1554                        resolve_target,
1555                        depth_slice: None,
1556                        ops: wgpu::Operations {
1557                            load: load_op,
1558                            store: wgpu::StoreOp::Store,
1559                        },
1560                    })],
1561                    depth_stencil_attachment: None,
1562                    timestamp_writes: None,
1563                    occlusion_query_set: None,
1564                    multiview_mask: None,
1565                });
1566                self.draw_items(&mut pass, &self.core.paint_items[..idx]);
1567            }
1568            // Snapshot copy. Target must support COPY_SRC; snapshot
1569            // texture (created in `ensure_snapshot`) supports COPY_DST
1570            // + TEXTURE_BINDING.
1571            let snapshot = self.snapshot.as_ref().expect("snapshot ensured");
1572            encoder.copy_texture_to_texture(
1573                wgpu::TexelCopyTextureInfo {
1574                    texture: target_tex,
1575                    mip_level: 0,
1576                    origin: wgpu::Origin3d::ZERO,
1577                    aspect: wgpu::TextureAspect::All,
1578                },
1579                wgpu::TexelCopyTextureInfo {
1580                    texture: &snapshot.texture,
1581                    mip_level: 0,
1582                    origin: wgpu::Origin3d::ZERO,
1583                    aspect: wgpu::TextureAspect::All,
1584                },
1585                wgpu::Extent3d {
1586                    width: snapshot.extent.0,
1587                    height: snapshot.extent.1,
1588                    depth_or_array_layers: 1,
1589                },
1590            );
1591            // Pass B
1592            {
1593                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1594                    label: Some("damascene_wgpu::pass_b"),
1595                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1596                        view: attachment_view,
1597                        resolve_target,
1598                        depth_slice: None,
1599                        ops: wgpu::Operations {
1600                            load: wgpu::LoadOp::Load,
1601                            store: wgpu::StoreOp::Store,
1602                        },
1603                    })],
1604                    depth_stencil_attachment: None,
1605                    timestamp_writes: None,
1606                    occlusion_query_set: None,
1607                    multiview_mask: None,
1608                });
1609                // Skip the snapshot item itself; it's a marker, not a draw.
1610                self.draw_items(&mut pass, &self.core.paint_items[idx + 1..]);
1611            }
1612        } else {
1613            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1614                label: Some("damascene_wgpu::pass"),
1615                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1616                    view: attachment_view,
1617                    resolve_target,
1618                    depth_slice: None,
1619                    ops: wgpu::Operations {
1620                        load: load_op,
1621                        store: wgpu::StoreOp::Store,
1622                    },
1623                })],
1624                depth_stencil_attachment: None,
1625                timestamp_writes: None,
1626                occlusion_query_set: None,
1627                multiview_mask: None,
1628            });
1629            self.draw_items(&mut pass, &self.core.paint_items);
1630        }
1631    }
1632
1633    /// (Re)allocate the snapshot texture to match `target_tex`'s
1634    /// extent + format. Idempotent when the size matches; rebuilds the
1635    /// `backdrop_bind_group` whenever the snapshot is recreated.
1636    fn ensure_snapshot(&mut self, device: &wgpu::Device, target_tex: &wgpu::Texture) {
1637        let extent = target_tex.size();
1638        let want = (extent.width, extent.height);
1639        if let Some(s) = &self.snapshot
1640            && s.extent == want
1641        {
1642            return;
1643        }
1644        let texture = device.create_texture(&wgpu::TextureDescriptor {
1645            label: Some("damascene_wgpu::backdrop_snapshot"),
1646            size: wgpu::Extent3d {
1647                width: want.0,
1648                height: want.1,
1649                depth_or_array_layers: 1,
1650            },
1651            mip_level_count: 1,
1652            sample_count: 1,
1653            dimension: wgpu::TextureDimension::D2,
1654            format: self.target_format,
1655            usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
1656            view_formats: &[],
1657        });
1658        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1659        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1660            label: Some("damascene_wgpu::backdrop_bind_group"),
1661            layout: &self.backdrop_bind_layout,
1662            entries: &[
1663                wgpu::BindGroupEntry {
1664                    binding: 0,
1665                    resource: wgpu::BindingResource::TextureView(&view),
1666                },
1667                wgpu::BindGroupEntry {
1668                    binding: 1,
1669                    resource: wgpu::BindingResource::Sampler(&self.backdrop_sampler),
1670                },
1671            ],
1672        });
1673        self.snapshot = Some(SnapshotTexture {
1674            texture,
1675            extent: want,
1676        });
1677        self.backdrop_bind_group = Some(bind_group);
1678    }
1679
1680    /// Walk a slice of `PaintItem`s into the given pass. Helper shared
1681    /// by [`Self::draw`] and [`Self::render`]. `BackdropSnapshot`
1682    /// items are no-ops here; `render()` handles them by splitting
1683    /// the slice before passing to this helper.
1684    fn draw_items<'pass>(
1685        &'pass self,
1686        pass: &mut wgpu::RenderPass<'pass>,
1687        items: &'pass [PaintItem],
1688    ) {
1689        let full = PhysicalScissor {
1690            x: 0,
1691            y: 0,
1692            w: self.core.viewport_px.0,
1693            h: self.core.viewport_px.1,
1694        };
1695        for item in items {
1696            match *item {
1697                PaintItem::QuadRun(index) => {
1698                    let run = &self.core.runs[index];
1699                    set_scissor(pass, run.scissor, full);
1700                    pass.set_bind_group(0, &self.quad_bind_group, &[]);
1701                    let is_backdrop_shader = matches!(
1702                        run.handle,
1703                        ShaderHandle::Custom(name) if self.backdrop_shaders.contains(name)
1704                    );
1705                    if is_backdrop_shader && let Some(bg) = &self.backdrop_bind_group {
1706                        pass.set_bind_group(1, bg, &[]);
1707                    }
1708                    pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1709                    pass.set_vertex_buffer(1, self.instance_buf.slice(..));
1710                    let pipeline = self
1711                        .pipelines
1712                        .get(&run.handle)
1713                        .expect("run handle has no pipeline (bug in prepare)");
1714                    pass.set_pipeline(pipeline);
1715                    pass.draw(0..4, run.first..run.first + run.count);
1716                }
1717                PaintItem::Text(index) => {
1718                    let run = self.text_paint.run(index);
1719                    set_scissor(pass, run.scissor, full);
1720                    pass.set_pipeline(self.text_paint.pipeline_for(run.kind));
1721                    pass.set_bind_group(0, &self.quad_bind_group, &[]);
1722                    // Highlight runs use a frame-uniform-only pipeline.
1723                    // Glyph kinds bind the active atlas page at group 1.
1724                    if !matches!(run.kind, crate::text::TextRunKind::Highlight) {
1725                        pass.set_bind_group(
1726                            1,
1727                            self.text_paint.page_bind_group(run.kind, run.page),
1728                            &[],
1729                        );
1730                    }
1731                    pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1732                    pass.set_vertex_buffer(1, self.text_paint.instance_buf_for(run.kind).slice(..));
1733                    pass.draw(0..4, run.first..run.first + run.count);
1734                }
1735                PaintItem::IconRun(index) | PaintItem::Vector(index) => {
1736                    // `PaintItem::Vector` is structurally identical to
1737                    // `PaintItem::IconRun` — both index into the same
1738                    // `IconPaint::runs` Vec since `record_vector`
1739                    // appends there too. The variant is kept distinct
1740                    // for paint-stream provenance (icon vs app vector)
1741                    // but the dispatch is the same.
1742                    let run = self.icon_paint.run(index);
1743                    set_scissor(pass, run.scissor, full);
1744                    match run.kind {
1745                        IconRunKind::Tess => {
1746                            pass.set_pipeline(self.icon_paint.tess_pipeline(run.material));
1747                            pass.set_bind_group(0, &self.quad_bind_group, &[]);
1748                            pass.set_vertex_buffer(0, self.icon_paint.tess_vertex_buf().slice(..));
1749                            pass.draw(run.first..run.first + run.count, 0..1);
1750                        }
1751                        IconRunKind::Msdf => {
1752                            pass.set_pipeline(self.icon_paint.msdf_pipeline());
1753                            pass.set_bind_group(0, &self.quad_bind_group, &[]);
1754                            pass.set_bind_group(
1755                                1,
1756                                self.icon_paint.msdf_page_bind_group(run.page),
1757                                &[],
1758                            );
1759                            pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1760                            pass.set_vertex_buffer(
1761                                1,
1762                                self.icon_paint.msdf_instance_buf().slice(..),
1763                            );
1764                            pass.draw(0..4, run.first..run.first + run.count);
1765                        }
1766                    }
1767                }
1768                PaintItem::Image(index) => {
1769                    let run = self.image_paint.run(index);
1770                    set_scissor(pass, run.scissor, full);
1771                    pass.set_pipeline(self.image_paint.pipeline());
1772                    pass.set_bind_group(0, &self.quad_bind_group, &[]);
1773                    pass.set_bind_group(1, self.image_paint.bind_group_for_run(run), &[]);
1774                    pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1775                    pass.set_vertex_buffer(1, self.image_paint.instance_buf().slice(..));
1776                    pass.draw(0..4, run.first..run.first + run.count);
1777                }
1778                PaintItem::AppTexture(index) => {
1779                    let run = self.surface_paint.run(index);
1780                    set_scissor(pass, run.scissor, full);
1781                    pass.set_pipeline(self.surface_paint.pipeline_for(run.alpha));
1782                    pass.set_bind_group(0, &self.quad_bind_group, &[]);
1783                    pass.set_bind_group(1, self.surface_paint.bind_group_for_run(run), &[]);
1784                    pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1785                    pass.set_vertex_buffer(1, self.surface_paint.instance_buf().slice(..));
1786                    pass.draw(0..4, run.first..run.first + run.count);
1787                }
1788                PaintItem::Scene3D(index) => {
1789                    // The scene already rendered + resolved offscreen in
1790                    // phase 1; composite that texture over the rect via the
1791                    // stock surface pipeline (premultiplied).
1792                    let run = self.scene_paint.run(index);
1793                    set_scissor(pass, run.scissor, full);
1794                    pass.set_pipeline(self.scene_paint.composite_pipeline());
1795                    pass.set_bind_group(0, &self.quad_bind_group, &[]);
1796                    pass.set_bind_group(1, self.scene_paint.composite_bind_group(run), &[]);
1797                    pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1798                    pass.set_vertex_buffer(1, self.scene_paint.composite_instance_buf().slice(..));
1799                    pass.draw(0..4, run.composite_instance..run.composite_instance + 1);
1800                }
1801                PaintItem::BackdropSnapshot => {
1802                    // Marker only — `render()` splits the slice on
1803                    // these and never includes one in a draw range.
1804                }
1805            }
1806        }
1807    }
1808}