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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum HydrolysisPreviewSource<'a> {
60 Symbol(&'a str),
62 Expression(&'a str),
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub struct HydrolysisPreviewScenario {
69 pub captures_ms: Vec<u64>,
71 pub events: Vec<HydrolysisPreviewScenarioEvent>,
73 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#[derive(Debug, Clone)]
94pub struct HydrolysisPreviewRequest<'a> {
95 pub project_path: &'a Path,
97 pub source: HydrolysisPreviewSource<'a>,
99 pub theme: HydrolysisPreviewTheme,
101 pub width: f32,
103 pub height: f32,
105 pub sccache_path: Option<PathBuf>,
107 pub progress: Option<BuildProgress>,
109}
110
111pub 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
160pub 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
208pub 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
242pub 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
295async 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}