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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum HydrolysisPreviewTheme {
33 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum HydrolysisPreviewSource<'a> {
58 Symbol(&'a str),
60 Expression(&'a str),
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub struct HydrolysisPreviewScenario {
67 pub captures_ms: Vec<u64>,
69 pub events: Vec<HydrolysisPreviewScenarioEvent>,
71 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#[derive(Debug, Clone)]
92pub struct HydrolysisPreviewRequest<'a> {
93 pub project_path: &'a Path,
95 pub source: HydrolysisPreviewSource<'a>,
97 pub theme: HydrolysisPreviewTheme,
99 pub width: f32,
101 pub height: f32,
103 pub sccache_path: Option<PathBuf>,
105 pub progress: Option<BuildProgress>,
107}
108
109pub 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
158pub 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
206pub 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
240pub 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
293async 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}