Skip to main content

aetna_core/bundle/
artifact.rs

1//! Render bundle — one call produces every artifact the agent loop needs.
2//!
3//! A [`Bundle`] is the textual + visual representation of a rendered tree:
4//!
5//! - `svg` — visual fixture (also convertible to PNG via `tools/svg_to_png.sh`).
6//! - `tree_dump` — semantic walk of the laid-out tree with rects and source.
7//! - `draw_ops` — flat draw-op IR (the same one a wgpu backend consumes).
8//! - `shader_manifest` — every shader used by this tree, with uniform values.
9//! - `lint` — findings: raw values in user code, overflows, duplicate IDs.
10//!
11//! [`render_bundle`] runs layout + draw-op resolution + dump + lint in one
12//! call so a single `cargo run --example X` produces everything needed
13//! to verify intent without further round-trips.
14//!
15//! The SVG output is approximate (stock shaders rendered best-effort,
16//! custom shaders as placeholder rects). The wgpu renderer is the source
17//! of truth for visual fidelity; SVG stays as a layout/structure
18//! debugging artifact.
19//!
20//! # Wiring this into your app
21//!
22//! The bundle pipeline is also the cheapest layout-review path *during
23//! app development*. It runs CPU-only, exercises the same layout +
24//! draw-op stack the GPU does, and produces a diffable tree dump that
25//! catches regressions long before they hit a window. The shape every
26//! aetna app converges on:
27//!
28//! ```ignore
29//! // crates/your-app/src/bin/dump_bundles.rs
30//! use aetna_core::prelude::*;
31//! use std::path::PathBuf;
32//!
33//! struct MockBackend { state: AppState }
34//! impl UiBackend for MockBackend { /* return canned `state` */ }
35//!
36//! enum Scene { Empty, Loaded, ErrorDialog /* ... */ }
37//!
38//! fn main() -> std::io::Result<()> {
39//!     let viewport = Rect::new(0.0, 0.0, 1280.0, 800.0);
40//!     let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("out");
41//!
42//!     for scene in Scene::ALL {
43//!         let mut app = MyApp::new(MockBackend { state: scene.canned_state() });
44//!         // Local UI flags? Drive them through the real on_event path:
45//!         scene.drive_setup(&mut app);
46//!
47//!         let theme = app.theme();
48//!         let mut tree = app.build(&BuildCx::new(&theme));
49//!         let bundle = render_bundle(&mut tree, viewport);
50//!         write_bundle(&bundle, &out_dir, &scene.slug())?;
51//!         if !bundle.lint.findings.is_empty() {
52//!             eprint!("{}", bundle.lint.text());
53//!         }
54//!     }
55//!     Ok(())
56//! }
57//! ```
58//!
59//! Three to six scenes is plenty for a typical app chrome. Output goes
60//! to `crates/<app>/out/` (gitignore the directory). Worked examples in
61//! the workspace: `tools/src/bin/dump_showcase_bundles.rs` (aetna's own
62//! showcase), and the `render_artifacts` / `dump_bundles` bins in the
63//! external `aetna-volume` and `rumble-aetna` apps.
64//!
65//! Driving local UI state via [`crate::event::UiEvent::synthetic_click`]
66//! is preferred over fixture-only setters: the dumped scene is exactly
67//! what the user sees after performing the same interaction, so the
68//! fixture and production code can't drift.
69
70use std::path::Path;
71
72use super::inspect;
73use super::lint::{LintReport, lint};
74use super::manifest;
75use super::svg::svg_from_ops;
76use crate::draw_ops;
77use crate::ir::DrawOp;
78use crate::layout;
79use crate::state::UiState;
80use crate::theme::Theme;
81use crate::tokens;
82use crate::tree::{El, Rect};
83
84/// Everything an agent loop wants from a single render.
85#[derive(Clone, Debug)]
86#[non_exhaustive]
87pub struct Bundle {
88    /// SVG source (approximate — see crate-level docs).
89    pub svg: String,
90    /// Semantic tree dump — grep-able, source-mapped.
91    pub tree_dump: String,
92    /// Flat draw-op list — the same IR a wgpu backend would consume.
93    pub draw_ops: Vec<DrawOp>,
94    /// Shader manifest — usage + resolved uniforms per draw.
95    pub shader_manifest: String,
96    /// Findings from the lint pass.
97    pub lint: LintReport,
98}
99
100/// Lay out, resolve to draw ops, dump, lint.
101///
102/// Constructs a fresh [`UiState`] internally — bundle artifacts are a
103/// snapshot of the tree at rest, with no hover/press/focus state. For
104/// fixtures that need to demonstrate non-trivial state (a scroll
105/// position, a hovered button), see [`render_bundle_with`].
106pub fn render_bundle(root: &mut El, viewport: Rect) -> Bundle {
107    render_bundle_with(root, &mut UiState::new(), viewport)
108}
109
110/// Same as [`render_bundle`], but resolves implicit surfaces through a
111/// caller-supplied [`Theme`].
112pub fn render_bundle_themed(root: &mut El, viewport: Rect, theme: &Theme) -> Bundle {
113    render_bundle_with_theme(root, &mut UiState::new(), viewport, theme)
114}
115
116/// Same as [`render_bundle`], but threads a caller-built [`UiState`]
117/// through the pipeline. Use this when the fixture wants to seed
118/// runtime state (scroll offsets, hovered/focused trackers) before
119/// snapshotting — the layout pass reads it, and the resulting bundle
120/// reflects the seeded state.
121///
122/// Seed scroll offsets by calling [`crate::layout::assign_ids`] first
123/// to populate `computed_id`, then calling [`UiState::set_scroll_offset`].
124pub fn render_bundle_with(root: &mut El, ui_state: &mut UiState, viewport: Rect) -> Bundle {
125    render_bundle_with_theme(root, ui_state, viewport, &Theme::default())
126}
127
128/// Same as [`render_bundle_with`], but resolves implicit surfaces through
129/// a caller-supplied [`Theme`].
130pub fn render_bundle_with_theme(
131    root: &mut El,
132    ui_state: &mut UiState,
133    viewport: Rect,
134    theme: &Theme,
135) -> Bundle {
136    theme.apply_metrics(root);
137    layout::layout(root, ui_state, viewport);
138    let draw_ops = draw_ops::draw_ops_with_theme(root, ui_state, theme);
139    let svg = svg_from_ops(viewport.w, viewport.h, &draw_ops, tokens::BACKGROUND);
140    let tree_dump = inspect::dump_tree(root, ui_state);
141    let shader_manifest = manifest::shader_manifest(&draw_ops);
142    let lint = lint(root, ui_state);
143    Bundle {
144        svg,
145        tree_dump,
146        draw_ops,
147        shader_manifest,
148        lint,
149    }
150}
151
152/// Write a bundle to disk under `dir`, naming files `{name}.{ext}`.
153///
154/// Files written:
155/// - `{name}.svg`
156/// - `{name}.tree.txt`
157/// - `{name}.draw_ops.txt`
158/// - `{name}.shader_manifest.txt`
159/// - `{name}.lint.txt`
160pub fn write_bundle(
161    bundle: &Bundle,
162    dir: &Path,
163    name: &str,
164) -> std::io::Result<Vec<std::path::PathBuf>> {
165    std::fs::create_dir_all(dir)?;
166    let mut written = Vec::new();
167
168    let svg = dir.join(format!("{name}.svg"));
169    std::fs::write(&svg, &bundle.svg)?;
170    written.push(svg);
171
172    let tree = dir.join(format!("{name}.tree.txt"));
173    std::fs::write(&tree, &bundle.tree_dump)?;
174    written.push(tree);
175
176    let draw_ops_path = dir.join(format!("{name}.draw_ops.txt"));
177    std::fs::write(&draw_ops_path, manifest::draw_ops_text(&bundle.draw_ops))?;
178    written.push(draw_ops_path);
179
180    let manifest_path = dir.join(format!("{name}.shader_manifest.txt"));
181    std::fs::write(&manifest_path, &bundle.shader_manifest)?;
182    written.push(manifest_path);
183
184    let lint = dir.join(format!("{name}.lint.txt"));
185    std::fs::write(&lint, bundle.lint.text())?;
186    written.push(lint);
187
188    Ok(written)
189}