Skip to main content

aetna_wgpu/
lib.rs

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