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