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