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}