Skip to main content

waterui_cli/preview/
hydrolysis.rs

1use std::path::{Path, PathBuf};
2
3use askama::Template;
4use eyre::{Context as _, Result, bail};
5
6use crate::backend::reinit_backend;
7use crate::build::{BuildOptions, BuildProfile, BuildProgress};
8use crate::hydrolysis::backend::HydrolysisBackend;
9use crate::hydrolysis::platform::{
10    build_hydrolysis_with_envs_and_features, stage_hydrolysis_shared_runtime,
11};
12use crate::platform::{TargetBackend, TargetPlatform};
13use crate::project::{ManagedBackends, Project};
14use crate::project_model::assets;
15use crate::utils::command;
16
17const HYDROLYSIS_PREVIEW_FEATURE: &str = "waterui-preview-mode";
18const HYDROLYSIS_PREVIEW_TEST_FEATURE: &str = "waterui-preview-test-mode";
19
20use waterui_preview_protocol::hydrolysis::{
21    PREVIEW_RUN_CONFIG_ENV, PreviewRunConfig, PreviewRunMode,
22};
23pub use waterui_preview_protocol::hydrolysis::{
24    ScenarioEvent as HydrolysisPreviewScenarioEvent,
25    ScenarioEventKind as HydrolysisPreviewEventKind,
26    ScenarioPointerButton as HydrolysisPreviewPointerButton,
27};
28
29/// Theme package selected for Hydrolysis preview rendering.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum HydrolysisPreviewTheme {
32    /// Material Design 3 package.
33    Material3,
34}
35
36impl HydrolysisPreviewTheme {
37    /// The Rust expression producing the `hydrolysis::Style` value the
38    /// preview runtimes are constructed with.
39    const fn style(self) -> &'static str {
40        match self {
41            Self::Material3 => "hydrolysis_m3::Material3::defaults()",
42        }
43    }
44
45    fn font_declarations(self) -> Vec<assets::FontDeclaration> {
46        match self {
47            Self::Material3 => vec![assets::FontDeclaration {
48                name: "Roboto".to_string(),
49                source: assets::FontSource::BuiltIn,
50                crate_name: "hydrolysis-m3".to_string(),
51            }],
52        }
53    }
54}
55
56/// Source used to produce a Hydrolysis preview view.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum HydrolysisPreviewSource<'a> {
59    /// Existing `#[preview]` export symbol.
60    Symbol(&'a str),
61    /// Inline Rust expression returning `impl View`.
62    Expression(&'a str),
63}
64
65/// Interactive capture scenario for Hydrolysis preview.
66#[derive(Debug, Clone, PartialEq)]
67pub struct HydrolysisPreviewScenario {
68    /// Capture timestamps in milliseconds from scenario start.
69    pub captures_ms: Vec<u64>,
70    /// Input events sorted by timestamp.
71    pub events: Vec<HydrolysisPreviewScenarioEvent>,
72    /// Directory where captured frames are written.
73    pub output_dir: PathBuf,
74}
75
76#[derive(Template)]
77#[template(
78    path = "src/preview/hydrolysis_preview_bindings.rs.tpl",
79    escape = "none"
80)]
81struct HydrolysisPreviewBindingsTemplate<'a> {
82    expression_mode: bool,
83    preview_symbol: &'a str,
84    preview_expression: &'a str,
85    crate_name_ident: &'a str,
86    preview_theme_style: &'a str,
87    include_automation: bool,
88    semantic_automation_body: &'a str,
89}
90
91/// Common inputs for driving the managed Hydrolysis preview backend.
92#[derive(Debug, Clone)]
93pub struct HydrolysisPreviewRequest<'a> {
94    /// `WaterUI` project directory.
95    pub project_path: &'a Path,
96    /// Preview view source.
97    pub source: HydrolysisPreviewSource<'a>,
98    /// Theme package the preview runtimes are constructed with.
99    pub theme: HydrolysisPreviewTheme,
100    /// Viewport width in logical units.
101    pub width: f32,
102    /// Viewport height in logical units.
103    pub height: f32,
104    /// `sccache` binary used for compilation caching, when available.
105    pub sccache_path: Option<PathBuf>,
106    /// Sink compile progress is reported to while the preview build runs cargo.
107    pub progress: Option<BuildProgress>,
108}
109
110/// Render a preview via the managed Hydrolysis backend binary.
111///
112/// # Errors
113/// Returns an error if the managed backend cannot be prepared, built, or executed.
114pub async fn render_preview_with_hydrolysis(
115    request: HydrolysisPreviewRequest<'_>,
116    output_path: &Path,
117    scenario: Option<&HydrolysisPreviewScenario>,
118) -> Result<()> {
119    let HydrolysisPreviewRequest {
120        project_path,
121        source,
122        theme,
123        width,
124        height,
125        sccache_path,
126        progress,
127    } = request;
128    let project = ensure_hydrolysis_backend_ready(project_path).await?;
129    write_preview_bindings(&project, source, theme, None).await?;
130    stage_hydrolysis_resources(&project, theme, sccache_path.as_deref(), progress.as_ref()).await?;
131
132    let mut build_options = BuildOptions::development(BuildProfile::Debug);
133    if let Some(sccache_path) = sccache_path {
134        build_options = build_options.with_sccache(sccache_path);
135    }
136    if let Some(progress) = progress {
137        build_options = build_options.with_progress(progress);
138    }
139    let built = build_hydrolysis_with_envs_and_features(
140        &project,
141        TargetPlatform::MacOS,
142        build_options,
143        &[],
144        &[HYDROLYSIS_PREVIEW_FEATURE],
145    )
146    .await?;
147    stage_hydrolysis_shared_runtime(&project, &built, TargetPlatform::MacOS).await?;
148    run_preview_binary(
149        &project,
150        &built.artifact,
151        width,
152        height,
153        output_path,
154        scenario,
155    )
156    .await
157}
158
159/// Run a semantic preview test session via the managed Hydrolysis backend binary.
160///
161/// # Errors
162/// Returns an error if the managed backend cannot be prepared, built, or executed.
163pub async fn test_preview_with_hydrolysis(
164    request: HydrolysisPreviewRequest<'_>,
165    automation_body: &str,
166) -> Result<String> {
167    let HydrolysisPreviewRequest {
168        project_path,
169        source,
170        theme,
171        width,
172        height,
173        sccache_path,
174        progress,
175    } = request;
176    let project = ensure_hydrolysis_backend_ready(project_path).await?;
177    write_preview_bindings(&project, source, theme, Some(automation_body)).await?;
178    stage_hydrolysis_resources(&project, theme, sccache_path.as_deref(), progress.as_ref()).await?;
179
180    let mut build_options = BuildOptions::development(BuildProfile::Debug);
181    if let Some(sccache_path) = sccache_path {
182        build_options = build_options.with_sccache(sccache_path);
183    }
184    if let Some(progress) = progress {
185        build_options = build_options.with_progress(progress);
186    }
187    let built = build_hydrolysis_with_envs_and_features(
188        &project,
189        TargetPlatform::MacOS,
190        build_options,
191        &[],
192        &[HYDROLYSIS_PREVIEW_TEST_FEATURE],
193    )
194    .await?;
195    stage_hydrolysis_shared_runtime(&project, &built, TargetPlatform::MacOS).await?;
196    run_preview_test_binary(&project, &built.artifact, width, height).await
197}
198
199/// Stages the project's assets and the selected theme's fonts into the
200/// generated backend's `resources/` directory. Shared by the preview and MCP
201/// runtime modes.
202pub async fn stage_hydrolysis_resources(
203    project: &Project,
204    theme: HydrolysisPreviewTheme,
205    sccache_path: Option<&Path>,
206    progress: Option<&BuildProgress>,
207) -> Result<()> {
208    let resources_dir = project
209        .backend_path::<HydrolysisBackend>()
210        .join("resources");
211    let manifest = assets::stage_project_assets_for_gtk(
212        project,
213        &resources_dir,
214        sccache_path,
215        false,
216        progress,
217    )
218    .await?;
219
220    let backend_path = project.backend_path::<HydrolysisBackend>();
221    let mut font_declarations =
222        assets::scan_fonts(project, &backend_path.join("Cargo.toml")).await?;
223    font_declarations.extend(theme.font_declarations());
224    let mut resolved_fonts = assets::resolve_fonts(font_declarations).await?;
225    resolved_fonts.extend(assets::scan_project_font_assets(&manifest)?);
226    if resolved_fonts.is_empty() {
227        return Ok(());
228    }
229
230    let fonts_dest = resources_dir.join("fonts");
231    assets::copy_fonts(&resolved_fonts, &fonts_dest).await?;
232    Ok(())
233}
234
235/// Opens the project and makes sure its managed Hydrolysis backend exists and
236/// matches the current templates. Shared by the preview and MCP flows.
237pub async fn ensure_hydrolysis_backend_ready(project_path: &Path) -> Result<Project> {
238    let project = Project::open(
239        project_path,
240        ManagedBackends::for_backend(TargetBackend::Hydrolysis),
241    )
242    .await?;
243    if project.hydrolysis_backend().is_none() && !project.is_playground() {
244        bail!("Hydrolysis backend is not configured. Run `water backend add hydrolysis`.");
245    }
246
247    if HydrolysisBackend::requires_regeneration(&project).await? {
248        reinit_backend::<HydrolysisBackend>(&project).await?;
249    }
250
251    Ok(project)
252}
253
254async fn write_preview_bindings(
255    project: &Project,
256    source: HydrolysisPreviewSource<'_>,
257    theme: HydrolysisPreviewTheme,
258    automation_body: Option<&str>,
259) -> Result<()> {
260    let file_name = if automation_body.is_some() {
261        "preview_test.rs"
262    } else {
263        "preview_symbol.rs"
264    };
265    let module_path = project
266        .backend_path::<HydrolysisBackend>()
267        .join("src")
268        .join(file_name);
269    let crate_name_ident = project.crate_name().rust_ident();
270    let (expression_mode, preview_symbol, preview_expression) = match source {
271        HydrolysisPreviewSource::Symbol(symbol) => (false, symbol, ""),
272        HydrolysisPreviewSource::Expression(expression) => (true, "", expression),
273    };
274    let rendered = HydrolysisPreviewBindingsTemplate {
275        expression_mode,
276        preview_symbol,
277        preview_expression,
278        crate_name_ident: crate_name_ident.as_str(),
279        preview_theme_style: theme.style(),
280        include_automation: automation_body.is_some(),
281        semantic_automation_body: automation_body.unwrap_or(""),
282    }
283    .render()
284    .wrap_err("Failed to render hydrolysis preview bindings template")?;
285    smol::fs::write(&module_path, rendered)
286        .await
287        .wrap_err_with(|| format!("Failed to write {}", module_path.display()))?;
288    Ok(())
289}
290
291/// Writes the run config JSON next to the backend sources and returns its
292/// path; the file is overwritten per invocation.
293async fn write_run_config(project: &Project, config: &PreviewRunConfig) -> Result<PathBuf> {
294    let path = project
295        .backend_path::<HydrolysisBackend>()
296        .join("preview-run.json");
297    let json = serde_json::to_vec_pretty(config)
298        .wrap_err("Failed to serialize hydrolysis preview run config")?;
299    smol::fs::write(&path, json)
300        .await
301        .wrap_err_with(|| format!("Failed to write {}", path.display()))?;
302    Ok(path)
303}
304
305async fn run_preview_binary(
306    project: &Project,
307    binary_path: &Path,
308    width: f32,
309    height: f32,
310    output_path: &Path,
311    scenario: Option<&HydrolysisPreviewScenario>,
312) -> Result<()> {
313    let mode = match scenario {
314        Some(scenario) => PreviewRunMode::Scenario {
315            output_dir: absolute_output_path(&scenario.output_dir)?,
316            captures_ms: scenario.captures_ms.clone(),
317            events: scenario.events.clone(),
318        },
319        None => PreviewRunMode::Image {
320            output: absolute_output_path(output_path)?,
321        },
322    };
323    let config = PreviewRunConfig {
324        width,
325        height,
326        mode,
327    };
328    let config_path = write_run_config(project, &config).await?;
329    let backend_path = project.backend_path::<HydrolysisBackend>();
330
331    let mut child = smol::process::Command::new(binary_path);
332    let child = command(&mut child);
333    child.current_dir(&backend_path);
334    child.env(PREVIEW_RUN_CONFIG_ENV, &config_path);
335
336    let output = child.output().await.wrap_err_with(|| {
337        format!(
338            "Failed to run hydrolysis preview binary {}",
339            binary_path.display()
340        )
341    })?;
342
343    if !output.status.success() {
344        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
345        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
346        let details = if !stderr.is_empty() {
347            stderr
348        } else if !stdout.is_empty() {
349            stdout
350        } else {
351            format!("exit status {}", output.status)
352        };
353        bail!("Hydrolysis preview binary failed: {details}");
354    }
355
356    match config.mode {
357        PreviewRunMode::Scenario {
358            ref output_dir,
359            ref captures_ms,
360            ..
361        } => {
362            for capture_ms in captures_ms {
363                let frame_path = scenario_frame_path(output_dir, *capture_ms);
364                expect_nonempty_output(&frame_path, "scenario frame").await?;
365            }
366        }
367        PreviewRunMode::Image { ref output } => {
368            expect_nonempty_output(output, "output").await?;
369        }
370        PreviewRunMode::Semantic => {
371            unreachable!("render runs only produce images or scenarios")
372        }
373    }
374
375    Ok(())
376}
377
378async fn run_preview_test_binary(
379    project: &Project,
380    binary_path: &Path,
381    width: f32,
382    height: f32,
383) -> Result<String> {
384    let config = PreviewRunConfig {
385        width,
386        height,
387        mode: PreviewRunMode::Semantic,
388    };
389    let config_path = write_run_config(project, &config).await?;
390    let backend_path = project.backend_path::<HydrolysisBackend>();
391
392    let mut child = smol::process::Command::new(binary_path);
393    let child = command(&mut child);
394    child.current_dir(&backend_path);
395    child.env(PREVIEW_RUN_CONFIG_ENV, &config_path);
396
397    let output = child.output().await.wrap_err_with(|| {
398        format!(
399            "Failed to run hydrolysis preview test binary {}",
400            binary_path.display()
401        )
402    })?;
403
404    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
405    if !output.status.success() {
406        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
407        let details = if !stderr.is_empty() {
408            stderr
409        } else if !stdout.is_empty() {
410            stdout
411        } else {
412            format!("exit status {}", output.status)
413        };
414        bail!("Hydrolysis preview test binary failed: {details}");
415    }
416
417    Ok(stdout)
418}
419
420async fn expect_nonempty_output(path: &Path, what: &str) -> Result<()> {
421    let metadata = smol::fs::metadata(path).await.wrap_err_with(|| {
422        format!(
423            "Hydrolysis preview did not produce {what} {}",
424            path.display()
425        )
426    })?;
427    if metadata.len() == 0 {
428        bail!(
429            "Hydrolysis preview wrote empty {what} to {}",
430            path.display()
431        );
432    }
433    Ok(())
434}
435
436fn scenario_frame_path(output_dir: &Path, capture_ms: u64) -> PathBuf {
437    output_dir.join(format!("frame-{capture_ms:04}ms.png"))
438}
439
440fn absolute_output_path(path: &Path) -> Result<PathBuf> {
441    if path.is_absolute() {
442        return Ok(path.to_path_buf());
443    }
444    Ok(std::env::current_dir()?.join(path))
445}