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}