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 mut tree = app.build();
32//! runner.set_hotkeys(app.hotkeys());
33//! runner.set_theme(app.theme());
34//! runner.prepare(&device, &queue, &mut tree, viewport, scale_factor);
35//! runner.render(&device, &mut encoder, target_texture, target_view, None, load_op);
36//! ```
37//!
38//! `prepare` is split from `render`/`draw` so all `queue.write_buffer`
39//! calls and atlas uploads happen before render-pass recording, matching
40//! `wgpu`'s expected order. Coordinates passed to pointer methods are
41//! logical pixels; render targets are physical pixels, so pass the host
42//! scale factor to [`Runner::prepare`].
43//!
44//! Use [`Runner::render`] when Aetna should own pass boundaries. This is
45//! required for backdrop-sampling custom shaders. Use [`Runner::draw`]
46//! only when you are already inside a host-owned pass and do not need
47//! backdrop sampling.
48//!
49//! # Custom shaders
50//!
51//! Call [`Runner::register_shader`] with a name and WGSL source. The
52//! shader's vertex/fragment must use the shared instance layout — see
53//! `shaders/rounded_rect.wgsl` (in aetna-core) for the canonical
54//! example. Bind the shader at a node via
55//! `El::shader(ShaderBinding::custom(name).with(...))`. Per-instance
56//! uniforms map to three generic `vec4` slots:
57//!
58//! | Uniform key | Slot (`@location`) | Accepted types |
59//! |---|---|---|
60//! | `vec_a` | 2 | `Color` (rgba 0..1) or `Vec4` |
61//! | `vec_b` | 3 | `Color` or `Vec4` |
62//! | `vec_c` | 4 | `Vec4` (or fall back to scalar `f32` packed in `.x`) |
63//!
64//! Stock `rounded_rect` reuses the same layout but reads its own named
65//! uniforms (`fill`, `stroke`, `stroke_width`, `radius`, `shadow`).
66
67mod icon;
68mod instance;
69mod msaa;
70mod pipeline;
71mod text;
72
73pub use crate::msaa::MsaaTarget;
74
75use std::collections::{HashMap, HashSet};
76// `web_time::Instant` is API-identical to `std::time::Instant` on
77// native and uses `performance.now()` on wasm32 — std's `Instant::now()`
78// panics in the browser because there is no monotonic clock there.
79use web_time::Instant;
80
81use wgpu::util::DeviceExt;
82
83use aetna_core::event::{KeyChord, KeyModifiers, PointerButton, UiEvent, UiKey};
84use aetna_core::ir::TextAnchor;
85use aetna_core::paint::{IconRunKind, PhysicalScissor, QuadInstance};
86use aetna_core::runtime::{RecordedPaint, RunnerCore, TextRecorder};
87use aetna_core::shader::{ShaderHandle, StockShader, stock_wgsl};
88use aetna_core::state::{AnimationMode, UiState};
89use aetna_core::text::atlas::RunStyle;
90use aetna_core::theme::Theme;
91use aetna_core::tree::{Color, El, FontWeight, IconName, Rect, TextWrap};
92use aetna_core::vector::IconMaterial;
93
94pub use aetna_core::paint::PaintItem;
95pub use aetna_core::runtime::{PrepareResult, PrepareTimings};
96
97use crate::icon::IconPaint;
98use crate::instance::set_scissor;
99use crate::pipeline::{FrameUniforms, build_quad_pipeline};
100use crate::text::TextPaint;
101
102/// Initial size for the dynamic instance buffer (grows as needed).
103const INITIAL_INSTANCE_CAPACITY: usize = 256;
104
105/// Wgpu runtime owned by the host. One instance per surface/format.
106///
107/// All backend-agnostic state — interaction state, paint-stream scratch,
108/// per-stage layout/animation hooks — lives in `core: RunnerCore` and
109/// is shared with the vulkano backend. The fields below are wgpu-specific
110/// resources only.
111pub struct Runner {
112    target_format: wgpu::TextureFormat,
113    sample_count: u32,
114
115    // Shared resources.
116    pipeline_layout: wgpu::PipelineLayout,
117    /// Pipeline layout for `samples_backdrop` custom shaders — adds
118    /// `@group(1)` for the snapshot texture + sampler.
119    backdrop_pipeline_layout: wgpu::PipelineLayout,
120    quad_bind_group: wgpu::BindGroup,
121    backdrop_bind_layout: wgpu::BindGroupLayout,
122    backdrop_sampler: wgpu::Sampler,
123    frame_buf: wgpu::Buffer,
124    quad_vbo: wgpu::Buffer,
125    instance_buf: wgpu::Buffer,
126    instance_capacity: usize,
127
128    // One pipeline per registered shader (stock + custom).
129    pipelines: HashMap<ShaderHandle, wgpu::RenderPipeline>,
130    // Custom shader names registered with `samples_backdrop=true`. The
131    // paint scheduler queries this to insert pass boundaries before the
132    // first backdrop-sampling draw.
133    backdrop_shaders: HashSet<&'static str>,
134
135    // stock::text resources — atlas, page textures, glyph instances.
136    text_paint: TextPaint,
137    // stock::icon_line resources — vector icon stroke instances.
138    icon_paint: IconPaint,
139
140    /// Lazily-allocated snapshot of the color target, sized to match
141    /// the current target on each `render()`. Backdrop-sampling
142    /// shaders read this via `@group(1)` after Pass A.
143    snapshot: Option<SnapshotTexture>,
144    /// Bind group binding the snapshot view + sampler. Rebuilt each
145    /// time the snapshot texture is reallocated.
146    backdrop_bind_group: Option<wgpu::BindGroup>,
147
148    /// Wall-clock origin for the `time` field in `FrameUniforms`.
149    /// `prepare()` writes `(now - start_time).as_secs_f32()`.
150    start_time: Instant,
151
152    // Backend-agnostic state shared with aetna-vulkano: interaction
153    // state, paint-stream scratch (quad_scratch / runs / paint_items),
154    // viewport_px, last_tree, the 13 input plumbing methods.
155    core: RunnerCore,
156}
157
158struct SnapshotTexture {
159    texture: wgpu::Texture,
160    extent: (u32, u32),
161}
162
163struct PaintRecorder<'a> {
164    text: &'a mut TextPaint,
165    icons: &'a mut IconPaint,
166}
167
168impl TextRecorder for PaintRecorder<'_> {
169    fn record(
170        &mut self,
171        rect: Rect,
172        scissor: Option<PhysicalScissor>,
173        color: Color,
174        text: &str,
175        size: f32,
176        weight: FontWeight,
177        wrap: TextWrap,
178        anchor: TextAnchor,
179        scale_factor: f32,
180    ) -> std::ops::Range<usize> {
181        self.text.record(
182            rect,
183            scissor,
184            color,
185            text,
186            size,
187            weight,
188            wrap,
189            anchor,
190            scale_factor,
191        )
192    }
193
194    fn record_runs(
195        &mut self,
196        rect: Rect,
197        scissor: Option<PhysicalScissor>,
198        runs: &[(String, RunStyle)],
199        size: f32,
200        wrap: TextWrap,
201        anchor: TextAnchor,
202        scale_factor: f32,
203    ) -> std::ops::Range<usize> {
204        self.text
205            .record_runs(rect, scissor, runs, size, wrap, anchor, scale_factor)
206    }
207
208    fn record_icon(
209        &mut self,
210        rect: Rect,
211        scissor: Option<PhysicalScissor>,
212        name: IconName,
213        color: Color,
214        _size: f32,
215        stroke_width: f32,
216        _scale_factor: f32,
217    ) -> RecordedPaint {
218        RecordedPaint::Icon(self.icons.record(rect, scissor, name, color, stroke_width))
219    }
220}
221
222impl Runner {
223    /// Create a runner for the given target color format. The host
224    /// passes its swapchain/render-target format here so pipelines and
225    /// the glyph atlas are built compatible.
226    pub fn new(
227        device: &wgpu::Device,
228        queue: &wgpu::Queue,
229        target_format: wgpu::TextureFormat,
230    ) -> Self {
231        Self::with_sample_count(device, queue, target_format, 1)
232    }
233
234    /// Like [`Self::new`], but builds all pipelines with `sample_count`
235    /// MSAA samples. The host must provide a matching multisampled
236    /// render target and a single-sample resolve target. `sample_count`
237    /// of 1 is the non-MSAA default.
238    pub fn with_sample_count(
239        device: &wgpu::Device,
240        _queue: &wgpu::Queue,
241        target_format: wgpu::TextureFormat,
242        sample_count: u32,
243    ) -> Self {
244        // ---- Shared resources ----
245        let frame_buf = device.create_buffer(&wgpu::BufferDescriptor {
246            label: Some("aetna_wgpu::frame_uniforms"),
247            size: std::mem::size_of::<FrameUniforms>() as u64,
248            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
249            mapped_at_creation: false,
250        });
251
252        let frame_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
253            label: Some("aetna_wgpu::frame_bind_layout"),
254            entries: &[wgpu::BindGroupLayoutEntry {
255                binding: 0,
256                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
257                ty: wgpu::BindingType::Buffer {
258                    ty: wgpu::BufferBindingType::Uniform,
259                    has_dynamic_offset: false,
260                    min_binding_size: None,
261                },
262                count: None,
263            }],
264        });
265
266        let quad_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
267            label: Some("aetna_wgpu::frame_bind_group"),
268            layout: &frame_bind_layout,
269            entries: &[wgpu::BindGroupEntry {
270                binding: 0,
271                resource: frame_buf.as_entire_binding(),
272            }],
273        });
274
275        let quad_vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
276            label: Some("aetna_wgpu::quad_vbo"),
277            // Triangle strip: 4 corners, uv 0..1.
278            contents: bytemuck::cast_slice::<f32, u8>(&[0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0]),
279            usage: wgpu::BufferUsages::VERTEX,
280        });
281
282        let instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
283            label: Some("aetna_wgpu::instance_buf"),
284            size: (INITIAL_INSTANCE_CAPACITY * std::mem::size_of::<QuadInstance>()) as u64,
285            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
286            mapped_at_creation: false,
287        });
288
289        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
290            label: Some("aetna_wgpu::pipeline_layout"),
291            bind_group_layouts: &[Some(&frame_bind_layout)],
292            immediate_size: 0,
293        });
294
295        // ---- Backdrop sampling resources ----
296        //
297        // Custom shaders that opt into backdrop sampling (registered
298        // via `register_shader_with(..samples_backdrop=true)`) get a
299        // pipeline layout with `@group(1)` for the snapshot texture
300        // and sampler. The bind group is rebuilt whenever the
301        // snapshot is (re)allocated.
302        let backdrop_bind_layout =
303            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
304                label: Some("aetna_wgpu::backdrop_bind_layout"),
305                entries: &[
306                    wgpu::BindGroupLayoutEntry {
307                        binding: 0,
308                        visibility: wgpu::ShaderStages::FRAGMENT,
309                        ty: wgpu::BindingType::Texture {
310                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
311                            view_dimension: wgpu::TextureViewDimension::D2,
312                            multisampled: false,
313                        },
314                        count: None,
315                    },
316                    wgpu::BindGroupLayoutEntry {
317                        binding: 1,
318                        visibility: wgpu::ShaderStages::FRAGMENT,
319                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
320                        count: None,
321                    },
322                ],
323            });
324        let backdrop_pipeline_layout =
325            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
326                label: Some("aetna_wgpu::backdrop_pipeline_layout"),
327                bind_group_layouts: &[Some(&frame_bind_layout), Some(&backdrop_bind_layout)],
328                immediate_size: 0,
329            });
330        let backdrop_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
331            label: Some("aetna_wgpu::backdrop_sampler"),
332            address_mode_u: wgpu::AddressMode::ClampToEdge,
333            address_mode_v: wgpu::AddressMode::ClampToEdge,
334            address_mode_w: wgpu::AddressMode::ClampToEdge,
335            mag_filter: wgpu::FilterMode::Linear,
336            min_filter: wgpu::FilterMode::Linear,
337            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
338            ..Default::default()
339        });
340
341        // Build stock rect-shaped pipelines up-front; custom shaders are
342        // added on demand by the host.
343        let mut pipelines = HashMap::new();
344        let rr_pipeline = build_quad_pipeline(
345            device,
346            &pipeline_layout,
347            target_format,
348            sample_count,
349            "stock::rounded_rect",
350            stock_wgsl::ROUNDED_RECT,
351        );
352        pipelines.insert(ShaderHandle::Stock(StockShader::RoundedRect), rr_pipeline);
353
354        // Text pipeline + atlas (replaces glyphon).
355        let text_paint = TextPaint::new(device, target_format, sample_count, &frame_bind_layout);
356        let icon_paint = IconPaint::new(device, target_format, sample_count, &frame_bind_layout);
357
358        let mut core = RunnerCore::new();
359        core.quad_scratch = Vec::with_capacity(INITIAL_INSTANCE_CAPACITY);
360
361        Self {
362            target_format,
363            sample_count,
364            pipeline_layout,
365            backdrop_pipeline_layout,
366            quad_bind_group,
367            backdrop_bind_layout,
368            backdrop_sampler,
369            frame_buf,
370            quad_vbo,
371            instance_buf,
372            instance_capacity: INITIAL_INSTANCE_CAPACITY,
373            pipelines,
374            backdrop_shaders: HashSet::new(),
375            text_paint,
376            icon_paint,
377            snapshot: None,
378            backdrop_bind_group: None,
379            start_time: Instant::now(),
380            core,
381        }
382    }
383
384    /// Tell the runner the swapchain texture size in physical pixels.
385    /// Call this once after `surface.configure(...)` and again on every
386    /// `WindowEvent::Resized`. The runner uses this as the canonical
387    /// `viewport_px` for scissor math; without it, the value is derived
388    /// from `viewport.w * scale_factor`, which can drift by one pixel
389    /// when `scale_factor` is fractional and trip wgpu's
390    /// `set_scissor_rect` validation.
391    pub fn set_surface_size(&mut self, width: u32, height: u32) {
392        self.core.set_surface_size(width, height);
393    }
394
395    /// Set the theme used to resolve implicit widget surfaces to shaders.
396    pub fn set_theme(&mut self, theme: Theme) {
397        self.icon_paint.set_material(theme.icon_material());
398        self.core.set_theme(theme);
399    }
400
401    pub fn theme(&self) -> &Theme {
402        self.core.theme()
403    }
404
405    /// Select the stock material used by the vector-icon painter.
406    /// Prefer [`Theme::with_icon_material`] for app-level routing; this
407    /// remains useful for low-level render fixtures.
408    pub fn set_icon_material(&mut self, material: IconMaterial) {
409        self.icon_paint.set_material(material);
410    }
411
412    pub fn icon_material(&self) -> IconMaterial {
413        self.icon_paint.material()
414    }
415
416    /// Register a custom shader. `name` is the same string passed to
417    /// `aetna_core::shader::ShaderBinding::custom`; nodes bound to it
418    /// via [`El::shader`](aetna_core::tree::El) paint through this
419    /// pipeline.
420    ///
421    /// The WGSL source must use the shared `(rect, vec_a, vec_b, vec_c)`
422    /// instance layout and the `FrameUniforms` bind group described in
423    /// the module docs. Compilation happens at register time — invalid
424    /// WGSL panics here, not mid-frame.
425    ///
426    /// Re-registering the same name replaces the previous pipeline
427    /// (useful for hot-reload during development).
428    pub fn register_shader(&mut self, device: &wgpu::Device, name: &'static str, wgsl: &str) {
429        self.register_shader_with(device, name, wgsl, false);
430    }
431
432    /// Register a custom shader, with an opt-in flag for backdrop
433    /// sampling. When `samples_backdrop` is true, the renderer schedules
434    /// the shader's draws into Pass B (after a snapshot of Pass A's
435    /// rendered content) and binds the snapshot texture as
436    /// `@group(2) binding=0` (`backdrop_tex`) plus a sampler at
437    /// `binding=1` (`backdrop_smp`). See `docs/SHADER_VISION.md`
438    /// §"Backdrop sampling architecture".
439    ///
440    /// Backdrop depth is capped at 1: glass-on-glass shows the same
441    /// underlying content, not a second snapshot of the first glass
442    /// composited.
443    pub fn register_shader_with(
444        &mut self,
445        device: &wgpu::Device,
446        name: &'static str,
447        wgsl: &str,
448        samples_backdrop: bool,
449    ) {
450        let label = format!("custom::{name}");
451        let layout = if samples_backdrop {
452            &self.backdrop_pipeline_layout
453        } else {
454            &self.pipeline_layout
455        };
456        let pipeline = build_quad_pipeline(
457            device,
458            layout,
459            self.target_format,
460            self.sample_count,
461            &label,
462            wgsl,
463        );
464        self.pipelines.insert(ShaderHandle::Custom(name), pipeline);
465        if samples_backdrop {
466            self.backdrop_shaders.insert(name);
467        } else {
468            self.backdrop_shaders.remove(name);
469        }
470    }
471
472    /// Borrow the internal [`UiState`] — primarily for headless fixtures
473    /// that want to look up a node's rect after `prepare` (e.g., to
474    /// simulate a pointer at a specific button's center).
475    pub fn ui_state(&self) -> &UiState {
476        self.core.ui_state()
477    }
478
479    /// One-line diagnostic snapshot of interactive state — passes through
480    /// to [`UiState::debug_summary`]. Intended for per-frame logging
481    /// (e.g., `console.log` from the wasm host while debugging hover /
482    /// animation glitches).
483    pub fn debug_summary(&self) -> String {
484        self.core.debug_summary()
485    }
486
487    /// Return the most recently laid-out rectangle for a keyed node.
488    ///
489    /// Call after [`Self::prepare`]. This is the host-composition hook:
490    /// reserve a keyed Aetna element in the UI tree, ask for its rect
491    /// here, then record host-owned rendering into that region using the
492    /// same encoder / render flow that surrounds Aetna's pass.
493    pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
494        self.core.rect_of_key(key)
495    }
496
497    /// Lay out the tree, resolve to draw ops, and upload per-frame
498    /// buffers (quad instances + glyph atlas). Must be called before
499    /// [`Self::draw`] and outside of any render pass.
500    ///
501    /// `viewport` is in **logical** pixels — the units the layout pass
502    /// works in. `scale_factor` is the HiDPI multiplier (1.0 on a
503    /// regular display, 2.0 on most modern HiDPI, can be fractional).
504    /// The host's render-pass target should be sized at physical pixels
505    /// (`viewport × scale_factor`); the runner maps logical → physical
506    /// internally so layout, fonts, and SDF math stay device-independent.
507    pub fn prepare(
508        &mut self,
509        device: &wgpu::Device,
510        queue: &wgpu::Queue,
511        root: &mut El,
512        viewport: Rect,
513        scale_factor: f32,
514    ) -> PrepareResult {
515        let mut timings = PrepareTimings::default();
516
517        // Layout + state apply + animation tick + draw_ops resolution.
518        // Writes timings.layout + timings.draw_ops.
519        let (ops, needs_redraw) =
520            self.core
521                .prepare_layout(root, viewport, scale_factor, &mut timings);
522
523        // Paint stream: pack quads, record text, preserve z-order. The
524        // closure is the wgpu-specific "is this shader registered?"
525        // query (different pipeline types per backend prevent moving the
526        // check itself into core).
527        self.text_paint.frame_begin();
528        self.icon_paint.frame_begin();
529        let pipelines = &self.pipelines;
530        let backdrop_shaders = &self.backdrop_shaders;
531        let mut recorder = PaintRecorder {
532            text: &mut self.text_paint,
533            icons: &mut self.icon_paint,
534        };
535        self.core.prepare_paint(
536            &ops,
537            |shader| pipelines.contains_key(shader),
538            |shader| match shader {
539                ShaderHandle::Custom(name) => backdrop_shaders.contains(name),
540                ShaderHandle::Stock(_) => false,
541            },
542            &mut recorder,
543            scale_factor,
544            &mut timings,
545        );
546
547        // GPU upload — wgpu-specific. Resize the instance buffer if
548        // needed, then write quad_scratch + frame uniforms + flush text
549        // atlas dirty regions.
550        let t_paint_end = Instant::now();
551        if self.core.quad_scratch.len() > self.instance_capacity {
552            let new_cap = self.core.quad_scratch.len().next_power_of_two();
553            self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
554                label: Some("aetna_wgpu::instance_buf (resized)"),
555                size: (new_cap * std::mem::size_of::<QuadInstance>()) as u64,
556                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
557                mapped_at_creation: false,
558            });
559            self.instance_capacity = new_cap;
560        }
561        if !self.core.quad_scratch.is_empty() {
562            queue.write_buffer(
563                &self.instance_buf,
564                0,
565                bytemuck::cast_slice(&self.core.quad_scratch),
566            );
567        }
568        self.text_paint.flush(device, queue);
569        self.icon_paint.flush(device, queue);
570        let time = (Instant::now() - self.start_time).as_secs_f32();
571        let frame = FrameUniforms {
572            viewport: [viewport.w, viewport.h],
573            time,
574            scale_factor,
575        };
576        queue.write_buffer(&self.frame_buf, 0, bytemuck::bytes_of(&frame));
577        timings.gpu_upload = Instant::now() - t_paint_end;
578
579        // Snapshot the laid-out tree for next-frame hit-testing.
580        self.core.snapshot(root, &mut timings);
581
582        PrepareResult {
583            needs_redraw,
584            timings,
585        }
586    }
587
588    // ---- Input plumbing ----
589    //
590    // The host (winit-side) calls these from its event loop.
591    // Coordinates are **logical pixels** — divide winit's physical
592    // PhysicalPosition by the window scale factor before handing them in.
593
594    /// Update pointer position and recompute the hovered key.
595    /// Returns the new hovered key, if any (host can use it for cursor
596    /// styling or to decide whether to call `request_redraw`).
597    /// Pointer moved to `(x, y)` (logical px). Returns a `Drag` event
598    /// when the primary button is held; the host should dispatch it
599    /// via `App::on_event`. The hovered node is updated on
600    /// `ui_state().hovered` regardless.
601    pub fn pointer_moved(&mut self, x: f32, y: f32) -> Option<UiEvent> {
602        self.core.pointer_moved(x, y)
603    }
604
605    /// Pointer left the window — clear hover/press.
606    pub fn pointer_left(&mut self) {
607        self.core.pointer_left();
608    }
609
610    /// Mouse button down at `(x, y)` (logical px) for the given
611    /// `button`. For `Primary`, records the pressed key for press-
612    /// visual feedback, updates focus, and returns a `PointerDown`
613    /// event so widgets that need to react at down-time (text input
614    /// selection anchor, draggable handles) can do so. For
615    /// `Secondary` / `Middle`, records on a side channel and returns
616    /// `None`. The actual click event fires on `pointer_up`.
617    pub fn pointer_down(&mut self, x: f32, y: f32, button: PointerButton) -> Option<UiEvent> {
618        self.core.pointer_down(x, y, button)
619    }
620
621    /// Replace the tracked modifier mask. Hosts call this from their
622    /// platform's "modifiers changed" hook so subsequent pointer
623    /// events (PointerDown, Drag, Click, …) stamp the current mask
624    /// into `UiEvent.modifiers`.
625    pub fn set_modifiers(&mut self, modifiers: KeyModifiers) {
626        self.core.ui_state.set_modifiers(modifiers);
627    }
628
629    /// Mouse button up at `(x, y)` for the given `button`. Returns
630    /// the events the host should dispatch in order: for `Primary`,
631    /// always a `PointerUp` (when there was a corresponding down)
632    /// followed by an optional `Click` (when the up landed on the
633    /// down's node). For `Secondary` / `Middle`, an optional
634    /// `SecondaryClick` / `MiddleClick` on the same-node match.
635    pub fn pointer_up(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
636        self.core.pointer_up(x, y, button)
637    }
638
639    pub fn key_down(
640        &mut self,
641        key: UiKey,
642        modifiers: KeyModifiers,
643        repeat: bool,
644    ) -> Option<UiEvent> {
645        self.core.key_down(key, modifiers, repeat)
646    }
647
648    /// Forward an OS-composed text-input string (winit's keyboard event
649    /// `.text` field, or an `Ime::Commit`) to the focused element as a
650    /// `TextInput` event.
651    pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
652        self.core.text_input(text)
653    }
654
655    /// Replace the hotkey registry. Call once per frame, after `app.build()`,
656    /// passing `app.hotkeys()` so chords stay in sync with state.
657    pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
658        self.core.set_hotkeys(hotkeys);
659    }
660
661    /// Switch animation pacing. Default is [`AnimationMode::Live`].
662    /// Headless render binaries should call this with
663    /// [`AnimationMode::Settled`] so a single-frame snapshot reflects
664    /// the post-animation visual without depending on integrator timing.
665    pub fn set_animation_mode(&mut self, mode: AnimationMode) {
666        self.core.set_animation_mode(mode);
667    }
668
669    /// Apply a wheel delta in **logical** pixels at `(x, y)`. Routes to
670    /// the deepest scrollable container under the cursor in the last
671    /// laid-out tree. Returns `true` if the event landed on a scrollable
672    /// (host should `request_redraw` so the next frame applies the new
673    /// offset).
674    pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
675        self.core.pointer_wheel(x, y, dy)
676    }
677
678    /// Record draws into the host-managed render pass. Call after
679    /// [`Self::prepare`]. Paint order follows the draw-op stream.
680    ///
681    /// **No backdrop sampling.** This entry point cannot honor pass
682    /// boundaries (the host owns the pass lifetime), so any
683    /// `BackdropSnapshot` items in the paint stream are no-ops and any
684    /// shader bound with `samples_backdrop=true` reads an undefined
685    /// backdrop binding. Use [`Self::render`] for backdrop-aware
686    /// rendering.
687    pub fn draw<'pass>(&'pass self, pass: &mut wgpu::RenderPass<'pass>) {
688        self.draw_items(pass, &self.core.paint_items);
689    }
690
691    /// Record draws into a host-supplied encoder, owning pass
692    /// lifetimes ourselves so backdrop-sampling shaders can sample a
693    /// snapshot of Pass A's content.
694    ///
695    /// The host hands us:
696    /// - the encoder (we record into it),
697    /// - the color target's `wgpu::Texture` (used as `copy_src` when
698    ///   we snapshot it; must include `COPY_SRC` in its usage flags),
699    /// - the corresponding `wgpu::TextureView` (we attach it to every
700    ///   render pass we begin), and
701    /// - the `LoadOp` to use on the *first* pass — `Clear(color)` to
702    ///   clear behind us, `Load` to composite onto whatever was
703    ///   already in the target.
704    ///
705    /// Multi-pass schedule when the paint stream contains a
706    /// `BackdropSnapshot`:
707    ///
708    /// 1. Pass A — every paint item before the snapshot, with the
709    ///    caller-supplied `LoadOp`.
710    /// 2. `copy_texture_to_texture` — target → snapshot.
711    /// 3. Pass B — paint items from the snapshot onward, with
712    ///    `LoadOp::Load` so Pass A's pixels remain underneath.
713    ///
714    /// Without a snapshot, this collapses to a single pass and is
715    /// equivalent to [`Self::draw`] called inside a host-managed
716    /// pass with the same `LoadOp`.
717    pub fn render(
718        &mut self,
719        device: &wgpu::Device,
720        encoder: &mut wgpu::CommandEncoder,
721        target_tex: &wgpu::Texture,
722        target_view: &wgpu::TextureView,
723        msaa_view: Option<&wgpu::TextureView>,
724        load_op: wgpu::LoadOp<wgpu::Color>,
725    ) {
726        // When MSAA is in use, the actual color attachment is the
727        // multisampled view and `target_view` becomes its resolve
728        // target. `target_tex` is always the resolved (single-sample)
729        // texture, so the snapshot copy below works whether MSAA is on
730        // or not — the resolve happens at end-of-Pass-A.
731        let attachment_view = msaa_view.unwrap_or(target_view);
732        let resolve_target = msaa_view.map(|_| target_view);
733
734        // Locate the (at most one) snapshot boundary.
735        let split_at = self
736            .core
737            .paint_items
738            .iter()
739            .position(|p| matches!(p, PaintItem::BackdropSnapshot));
740
741        if let Some(idx) = split_at {
742            self.ensure_snapshot(device, target_tex);
743            // Pass A
744            {
745                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
746                    label: Some("aetna_wgpu::pass_a"),
747                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
748                        view: attachment_view,
749                        resolve_target,
750                        depth_slice: None,
751                        ops: wgpu::Operations {
752                            load: load_op,
753                            store: wgpu::StoreOp::Store,
754                        },
755                    })],
756                    depth_stencil_attachment: None,
757                    timestamp_writes: None,
758                    occlusion_query_set: None,
759                    multiview_mask: None,
760                });
761                self.draw_items(&mut pass, &self.core.paint_items[..idx]);
762            }
763            // Snapshot copy. Target must support COPY_SRC; snapshot
764            // texture (created in `ensure_snapshot`) supports COPY_DST
765            // + TEXTURE_BINDING.
766            let snapshot = self.snapshot.as_ref().expect("snapshot ensured");
767            encoder.copy_texture_to_texture(
768                wgpu::TexelCopyTextureInfo {
769                    texture: target_tex,
770                    mip_level: 0,
771                    origin: wgpu::Origin3d::ZERO,
772                    aspect: wgpu::TextureAspect::All,
773                },
774                wgpu::TexelCopyTextureInfo {
775                    texture: &snapshot.texture,
776                    mip_level: 0,
777                    origin: wgpu::Origin3d::ZERO,
778                    aspect: wgpu::TextureAspect::All,
779                },
780                wgpu::Extent3d {
781                    width: snapshot.extent.0,
782                    height: snapshot.extent.1,
783                    depth_or_array_layers: 1,
784                },
785            );
786            // Pass B
787            {
788                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
789                    label: Some("aetna_wgpu::pass_b"),
790                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
791                        view: attachment_view,
792                        resolve_target,
793                        depth_slice: None,
794                        ops: wgpu::Operations {
795                            load: wgpu::LoadOp::Load,
796                            store: wgpu::StoreOp::Store,
797                        },
798                    })],
799                    depth_stencil_attachment: None,
800                    timestamp_writes: None,
801                    occlusion_query_set: None,
802                    multiview_mask: None,
803                });
804                // Skip the snapshot item itself; it's a marker, not a draw.
805                self.draw_items(&mut pass, &self.core.paint_items[idx + 1..]);
806            }
807        } else {
808            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
809                label: Some("aetna_wgpu::pass"),
810                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
811                    view: attachment_view,
812                    resolve_target,
813                    depth_slice: None,
814                    ops: wgpu::Operations {
815                        load: load_op,
816                        store: wgpu::StoreOp::Store,
817                    },
818                })],
819                depth_stencil_attachment: None,
820                timestamp_writes: None,
821                occlusion_query_set: None,
822                multiview_mask: None,
823            });
824            self.draw_items(&mut pass, &self.core.paint_items);
825        }
826    }
827
828    /// (Re)allocate the snapshot texture to match `target_tex`'s
829    /// extent + format. Idempotent when the size matches; rebuilds the
830    /// `backdrop_bind_group` whenever the snapshot is recreated.
831    fn ensure_snapshot(&mut self, device: &wgpu::Device, target_tex: &wgpu::Texture) {
832        let extent = target_tex.size();
833        let want = (extent.width, extent.height);
834        if let Some(s) = &self.snapshot
835            && s.extent == want
836        {
837            return;
838        }
839        let texture = device.create_texture(&wgpu::TextureDescriptor {
840            label: Some("aetna_wgpu::backdrop_snapshot"),
841            size: wgpu::Extent3d {
842                width: want.0,
843                height: want.1,
844                depth_or_array_layers: 1,
845            },
846            mip_level_count: 1,
847            sample_count: 1,
848            dimension: wgpu::TextureDimension::D2,
849            format: self.target_format,
850            usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
851            view_formats: &[],
852        });
853        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
854        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
855            label: Some("aetna_wgpu::backdrop_bind_group"),
856            layout: &self.backdrop_bind_layout,
857            entries: &[
858                wgpu::BindGroupEntry {
859                    binding: 0,
860                    resource: wgpu::BindingResource::TextureView(&view),
861                },
862                wgpu::BindGroupEntry {
863                    binding: 1,
864                    resource: wgpu::BindingResource::Sampler(&self.backdrop_sampler),
865                },
866            ],
867        });
868        self.snapshot = Some(SnapshotTexture {
869            texture,
870            extent: want,
871        });
872        self.backdrop_bind_group = Some(bind_group);
873    }
874
875    /// Walk a slice of `PaintItem`s into the given pass. Helper shared
876    /// by [`Self::draw`] and [`Self::render`]. `BackdropSnapshot`
877    /// items are no-ops here; `render()` handles them by splitting
878    /// the slice before passing to this helper.
879    fn draw_items<'pass>(
880        &'pass self,
881        pass: &mut wgpu::RenderPass<'pass>,
882        items: &'pass [PaintItem],
883    ) {
884        let full = PhysicalScissor {
885            x: 0,
886            y: 0,
887            w: self.core.viewport_px.0,
888            h: self.core.viewport_px.1,
889        };
890        for item in items {
891            match *item {
892                PaintItem::QuadRun(index) => {
893                    let run = &self.core.runs[index];
894                    set_scissor(pass, run.scissor, full);
895                    pass.set_bind_group(0, &self.quad_bind_group, &[]);
896                    let is_backdrop_shader = matches!(
897                        run.handle,
898                        ShaderHandle::Custom(name) if self.backdrop_shaders.contains(name)
899                    );
900                    if is_backdrop_shader && let Some(bg) = &self.backdrop_bind_group {
901                        pass.set_bind_group(1, bg, &[]);
902                    }
903                    pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
904                    pass.set_vertex_buffer(1, self.instance_buf.slice(..));
905                    let pipeline = self
906                        .pipelines
907                        .get(&run.handle)
908                        .expect("run handle has no pipeline (bug in prepare)");
909                    pass.set_pipeline(pipeline);
910                    pass.draw(0..4, run.first..run.first + run.count);
911                }
912                PaintItem::Text(index) => {
913                    let run = self.text_paint.run(index);
914                    set_scissor(pass, run.scissor, full);
915                    pass.set_pipeline(self.text_paint.pipeline_for(run.kind));
916                    pass.set_bind_group(0, &self.quad_bind_group, &[]);
917                    pass.set_bind_group(
918                        1,
919                        self.text_paint.page_bind_group(run.kind, run.page),
920                        &[],
921                    );
922                    pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
923                    pass.set_vertex_buffer(1, self.text_paint.instance_buf_for(run.kind).slice(..));
924                    pass.draw(0..4, run.first..run.first + run.count);
925                }
926                PaintItem::IconRun(index) => {
927                    let run = self.icon_paint.run(index);
928                    set_scissor(pass, run.scissor, full);
929                    match run.kind {
930                        IconRunKind::Tess => {
931                            pass.set_pipeline(self.icon_paint.tess_pipeline(run.material));
932                            pass.set_bind_group(0, &self.quad_bind_group, &[]);
933                            pass.set_vertex_buffer(0, self.icon_paint.tess_vertex_buf().slice(..));
934                            pass.draw(run.first..run.first + run.count, 0..1);
935                        }
936                        IconRunKind::Msdf => {
937                            pass.set_pipeline(self.icon_paint.msdf_pipeline());
938                            pass.set_bind_group(0, &self.quad_bind_group, &[]);
939                            pass.set_bind_group(
940                                1,
941                                self.icon_paint.msdf_page_bind_group(run.page),
942                                &[],
943                            );
944                            pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
945                            pass.set_vertex_buffer(
946                                1,
947                                self.icon_paint.msdf_instance_buf().slice(..),
948                            );
949                            pass.draw(0..4, run.first..run.first + run.count);
950                        }
951                    }
952                }
953                PaintItem::BackdropSnapshot => {
954                    // Marker only — `render()` splits the slice on
955                    // these and never includes one in a draw range.
956                }
957            }
958        }
959    }
960}