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