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#[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}
106
107pub 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
152pub 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
196pub 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
223pub 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
276async 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}