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, 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}
106
107/// Render a preview via the managed Hydrolysis backend binary.
108///
109/// # Errors
110/// Returns an error if the managed backend cannot be prepared, built, or executed.
111pub async fn render_preview_with_hydrolysis(
112    request: HydrolysisPreviewRequest<'_>,
113    output_path: &Path,
114    scenario: Option<&HydrolysisPreviewScenario>,
115) -> Result<()> {
116    let HydrolysisPreviewRequest {
117        project_path,
118        source,
119        theme,
120        width,
121        height,
122        sccache_path,
123    } = request;
124    let project = ensure_hydrolysis_backend_ready(project_path).await?;
125    write_preview_bindings(&project, source, theme, None).await?;
126    stage_hydrolysis_resources(&project, theme, sccache_path.as_deref()).await?;
127
128    let mut build_options = BuildOptions::development(BuildProfile::Debug);
129    if let Some(sccache_path) = sccache_path {
130        build_options = build_options.with_sccache(sccache_path);
131    }
132    build_hydrolysis_with_envs_and_features(
133        &project,
134        TargetPlatform::MacOS,
135        build_options,
136        &[],
137        &[HYDROLYSIS_PREVIEW_FEATURE],
138    )
139    .await?;
140
141    let binary_path = built_hydrolysis_binary_path(
142        &project,
143        TargetPlatform::MacOS,
144        "debug",
145        RustLinkage::SharedRuntime,
146    )
147    .await?;
148    stage_hydrolysis_shared_runtime(&binary_path, TargetPlatform::MacOS).await?;
149    run_preview_binary(&project, &binary_path, width, height, output_path, scenario).await
150}
151
152/// Run a semantic preview test session via the managed Hydrolysis backend binary.
153///
154/// # Errors
155/// Returns an error if the managed backend cannot be prepared, built, or executed.
156pub async fn test_preview_with_hydrolysis(
157    request: HydrolysisPreviewRequest<'_>,
158    automation_body: &str,
159) -> Result<String> {
160    let HydrolysisPreviewRequest {
161        project_path,
162        source,
163        theme,
164        width,
165        height,
166        sccache_path,
167    } = request;
168    let project = ensure_hydrolysis_backend_ready(project_path).await?;
169    write_preview_bindings(&project, source, theme, Some(automation_body)).await?;
170    stage_hydrolysis_resources(&project, theme, sccache_path.as_deref()).await?;
171
172    let mut build_options = BuildOptions::development(BuildProfile::Debug);
173    if let Some(sccache_path) = sccache_path {
174        build_options = build_options.with_sccache(sccache_path);
175    }
176    build_hydrolysis_with_envs_and_features(
177        &project,
178        TargetPlatform::MacOS,
179        build_options,
180        &[],
181        &[HYDROLYSIS_PREVIEW_TEST_FEATURE],
182    )
183    .await?;
184
185    let binary_path = built_hydrolysis_binary_path(
186        &project,
187        TargetPlatform::MacOS,
188        "debug",
189        RustLinkage::SharedRuntime,
190    )
191    .await?;
192    stage_hydrolysis_shared_runtime(&binary_path, TargetPlatform::MacOS).await?;
193    run_preview_test_binary(&project, &binary_path, width, height).await
194}
195
196/// Stages the project's assets and the selected theme's fonts into the
197/// generated backend's `resources/` directory. Shared by the preview and MCP
198/// runtime modes.
199pub async fn stage_hydrolysis_resources(
200    project: &Project,
201    theme: HydrolysisPreviewTheme,
202    sccache_path: Option<&Path>,
203) -> Result<()> {
204    let resources_dir = project
205        .backend_path::<HydrolysisBackend>()
206        .join("resources");
207    let manifest =
208        assets::stage_project_assets_for_gtk(project, &resources_dir, sccache_path, false).await?;
209
210    let mut font_declarations = assets::scan_fonts(project).await?;
211    font_declarations.extend(theme.font_declarations());
212    let mut resolved_fonts = assets::resolve_fonts(font_declarations).await?;
213    resolved_fonts.extend(assets::scan_project_font_assets(&manifest)?);
214    if resolved_fonts.is_empty() {
215        return Ok(());
216    }
217
218    let fonts_dest = resources_dir.join("fonts");
219    assets::copy_fonts(&resolved_fonts, &fonts_dest).await?;
220    Ok(())
221}
222
223/// Opens the project and makes sure its managed Hydrolysis backend exists and
224/// matches the current templates. Shared by the preview and MCP flows.
225pub async fn ensure_hydrolysis_backend_ready(project_path: &Path) -> Result<Project> {
226    let mut project = Project::open(project_path).await?;
227    if project.hydrolysis_backend().is_none() && !project.is_playground() {
228        bail!("Hydrolysis backend is not configured. Run `water backend add hydrolysis`.");
229    }
230
231    if HydrolysisBackend::requires_regeneration(&project).await? {
232        reinit_backend::<HydrolysisBackend>(&project).await?;
233        project = Project::open(project_path).await?;
234    }
235
236    Ok(project)
237}
238
239async fn write_preview_bindings(
240    project: &Project,
241    source: HydrolysisPreviewSource<'_>,
242    theme: HydrolysisPreviewTheme,
243    automation_body: Option<&str>,
244) -> Result<()> {
245    let file_name = if automation_body.is_some() {
246        "preview_test.rs"
247    } else {
248        "preview_symbol.rs"
249    };
250    let module_path = project
251        .backend_path::<HydrolysisBackend>()
252        .join("src")
253        .join(file_name);
254    let crate_name_ident = project.crate_name().rust_ident();
255    let (expression_mode, preview_symbol, preview_expression) = match source {
256        HydrolysisPreviewSource::Symbol(symbol) => (false, symbol, ""),
257        HydrolysisPreviewSource::Expression(expression) => (true, "", expression),
258    };
259    let rendered = HydrolysisPreviewBindingsTemplate {
260        expression_mode,
261        preview_symbol,
262        preview_expression,
263        crate_name_ident: crate_name_ident.as_str(),
264        preview_theme_installer: theme.installer(),
265        include_automation: automation_body.is_some(),
266        semantic_automation_body: automation_body.unwrap_or(""),
267    }
268    .render()
269    .wrap_err("Failed to render hydrolysis preview bindings template")?;
270    smol::fs::write(&module_path, rendered)
271        .await
272        .wrap_err_with(|| format!("Failed to write {}", module_path.display()))?;
273    Ok(())
274}
275
276/// Writes the run config JSON next to the backend sources and returns its
277/// path; the file is overwritten per invocation.
278async fn write_run_config(project: &Project, config: &PreviewRunConfig) -> Result<PathBuf> {
279    let path = project
280        .backend_path::<HydrolysisBackend>()
281        .join("preview-run.json");
282    let json = serde_json::to_vec_pretty(config)
283        .wrap_err("Failed to serialize hydrolysis preview run config")?;
284    smol::fs::write(&path, json)
285        .await
286        .wrap_err_with(|| format!("Failed to write {}", path.display()))?;
287    Ok(path)
288}
289
290async fn run_preview_binary(
291    project: &Project,
292    binary_path: &Path,
293    width: f32,
294    height: f32,
295    output_path: &Path,
296    scenario: Option<&HydrolysisPreviewScenario>,
297) -> Result<()> {
298    let mode = match scenario {
299        Some(scenario) => PreviewRunMode::Scenario {
300            output_dir: absolute_output_path(&scenario.output_dir)?,
301            captures_ms: scenario.captures_ms.clone(),
302            events: scenario.events.clone(),
303        },
304        None => PreviewRunMode::Image {
305            output: absolute_output_path(output_path)?,
306        },
307    };
308    let config = PreviewRunConfig {
309        width,
310        height,
311        mode,
312    };
313    let config_path = write_run_config(project, &config).await?;
314    let backend_path = project.backend_path::<HydrolysisBackend>();
315
316    let mut child = smol::process::Command::new(binary_path);
317    let child = command(&mut child);
318    child.current_dir(&backend_path);
319    child.env(PREVIEW_RUN_CONFIG_ENV, &config_path);
320
321    let output = child.output().await.wrap_err_with(|| {
322        format!(
323            "Failed to run hydrolysis preview binary {}",
324            binary_path.display()
325        )
326    })?;
327
328    if !output.status.success() {
329        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
330        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
331        let details = if !stderr.is_empty() {
332            stderr
333        } else if !stdout.is_empty() {
334            stdout
335        } else {
336            format!("exit status {}", output.status)
337        };
338        bail!("Hydrolysis preview binary failed: {details}");
339    }
340
341    match config.mode {
342        PreviewRunMode::Scenario {
343            ref output_dir,
344            ref captures_ms,
345            ..
346        } => {
347            for capture_ms in captures_ms {
348                let frame_path = scenario_frame_path(output_dir, *capture_ms);
349                expect_nonempty_output(&frame_path, "scenario frame").await?;
350            }
351        }
352        PreviewRunMode::Image { ref output } => {
353            expect_nonempty_output(output, "output").await?;
354        }
355        PreviewRunMode::Semantic => {
356            unreachable!("render runs only produce images or scenarios")
357        }
358    }
359
360    Ok(())
361}
362
363async fn run_preview_test_binary(
364    project: &Project,
365    binary_path: &Path,
366    width: f32,
367    height: f32,
368) -> Result<String> {
369    let config = PreviewRunConfig {
370        width,
371        height,
372        mode: PreviewRunMode::Semantic,
373    };
374    let config_path = write_run_config(project, &config).await?;
375    let backend_path = project.backend_path::<HydrolysisBackend>();
376
377    let mut child = smol::process::Command::new(binary_path);
378    let child = command(&mut child);
379    child.current_dir(&backend_path);
380    child.env(PREVIEW_RUN_CONFIG_ENV, &config_path);
381
382    let output = child.output().await.wrap_err_with(|| {
383        format!(
384            "Failed to run hydrolysis preview test binary {}",
385            binary_path.display()
386        )
387    })?;
388
389    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
390    if !output.status.success() {
391        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
392        let details = if !stderr.is_empty() {
393            stderr
394        } else if !stdout.is_empty() {
395            stdout
396        } else {
397            format!("exit status {}", output.status)
398        };
399        bail!("Hydrolysis preview test binary failed: {details}");
400    }
401
402    Ok(stdout)
403}
404
405async fn expect_nonempty_output(path: &Path, what: &str) -> Result<()> {
406    let metadata = smol::fs::metadata(path).await.wrap_err_with(|| {
407        format!(
408            "Hydrolysis preview did not produce {what} {}",
409            path.display()
410        )
411    })?;
412    if metadata.len() == 0 {
413        bail!(
414            "Hydrolysis preview wrote empty {what} to {}",
415            path.display()
416        );
417    }
418    Ok(())
419}
420
421fn scenario_frame_path(output_dir: &Path, capture_ms: u64) -> PathBuf {
422    output_dir.join(format!("frame-{capture_ms:04}ms.png"))
423}
424
425fn absolute_output_path(path: &Path) -> Result<PathBuf> {
426    if path.is_absolute() {
427        return Ok(path.to_path_buf());
428    }
429    Ok(std::env::current_dir()?.join(path))
430}