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