Skip to main content

waterui_cli/tui/
mod.rs

1//! Experimental terminal (TUI) backend support.
2//!
3//! The renderer lives in the standalone `water-rs/tui` repository — the CLI
4//! only generates a thin launcher crate under the project's managed build cache
5//! and hands the invoking terminal to the built binary. The backend is opt-in
6//! through `water run --tui` alone: it is not a managed [`crate::backend::Backend`],
7//! does not appear in backend selection, and cannot be configured in
8//! `Water.toml` while experimental.
9//!
10//! Dependency resolution for the generated launcher follows the project's own
11//! framework mode: a `waterui_path` checkout supplies path dependencies and the
12//! checkout's `[patch]` table, a channel supplies the resolved framework's
13//! `[patch]` table, and a stable project resolves everything from the registry.
14//! The `waterui-tui` crate itself comes from `WATERUI_TUI_PATH`, a `water-rs/tui`
15//! checkout beside a local `waterui_path`, or the pinned
16//! [`crate::build_info::TUI_BACKEND`] revision, in that order.
17
18use std::path::{Path, PathBuf};
19
20use crate::{
21    build::{BuildProgress, RustBuild, RustLinkage},
22    project::Project,
23    templates::{self, TemplateContext},
24    water_dir,
25};
26
27/// Directory the launcher's sources are generated into.
28///
29/// The TUI launcher always lives in the project's managed build cache —
30/// including for application projects — because an experimental backend never
31/// writes into the project's own tree.
32async fn launcher_dir(project: &Project) -> eyre::Result<PathBuf> {
33    Ok(water_dir::project_build_cache_dir(project.root())
34        .await?
35        .join("tui"))
36}
37
38async fn template_context(project: &Project, dir: &Path) -> eyre::Result<TemplateContext> {
39    let manifest = project.manifest();
40    let app_name = manifest
41        .package
42        .name
43        .chars()
44        .filter(|c| c.is_alphanumeric())
45        .collect::<String>();
46    Ok(TemplateContext::for_project_manifest(
47        manifest,
48        project.crate_name().clone(),
49        app_name,
50        &project.resolved_framework().await?,
51    )
52    .with_backend_project_path(dir.to_path_buf())
53    .with_project_root_path(project.root().to_path_buf()))
54}
55
56/// Whether the generated launcher's sources differ from what the current
57/// templates would produce for this project.
58async fn requires_regeneration(project: &Project, dir: &Path) -> eyre::Result<bool> {
59    let ctx = template_context(project, dir).await?;
60    for (relative, expected) in
61        templates::tui::rendered_outputs(&ctx, project.tui_backend_crate_name().as_str())?
62    {
63        match std::fs::read(dir.join(&relative)) {
64            Ok(existing) if existing == expected => {}
65            Ok(_) | Err(_) => return Ok(true),
66        }
67    }
68    Ok(false)
69}
70
71/// Regenerate the launcher crate when it is missing or stale and return its
72/// directory.
73///
74/// # Errors
75///
76/// Returns an error if the build cache cannot be resolved, template rendering
77/// fails, or the launcher's sources cannot be written.
78pub async fn ensure_launcher(project: &Project) -> eyre::Result<PathBuf> {
79    let dir = launcher_dir(project).await?;
80    if requires_regeneration(project, &dir).await? {
81        let ctx = template_context(project, &dir).await?;
82        templates::tui::scaffold(&dir, &ctx, project.tui_backend_crate_name().as_str()).await?;
83    }
84    Ok(dir)
85}
86
87/// Build the launcher binary for the host and return its path.
88///
89/// The TUI launcher always builds the static-runtime variant: it is a leaf
90/// binary, not a plugin host, so the shared-runtime linkage has nothing to
91/// offer it.
92///
93/// # Errors
94///
95/// Returns an error if the project's target directory cannot be resolved or
96/// the Cargo build fails.
97pub async fn build(
98    project: &Project,
99    launcher_dir: &Path,
100    sccache_path: Option<PathBuf>,
101    progress: Option<BuildProgress>,
102) -> eyre::Result<PathBuf> {
103    let mut build = RustBuild::new(launcher_dir, target_lexicon::Triple::host())
104        .with_project(project)
105        .with_target_dir(project.water_target_dir(RustLinkage::Static).await?);
106    if let Some(sccache_path) = sccache_path {
107        build = build.with_sccache(sccache_path);
108    }
109    if let Some(progress) = progress {
110        build = build.with_progress(progress);
111    }
112    build
113        .build_binary(project.tui_backend_crate_name().as_str(), false)
114        .await
115        .map_err(|error| eyre::eyre!("failed to build the TUI launcher: {error}"))
116}
117
118/// Hand the invoking terminal to the built launcher.
119///
120/// On Unix the launcher replaces the CLI process via `exec`, so the terminal is
121/// owned by exactly one process and its exit status propagates unchanged — a
122/// spawned child would instead share the process group with `water` and keep
123/// the TTY after `water` itself dies on `SIGINT`. Elsewhere the launcher is
124/// spawned with inherited stdio and awaited.
125///
126/// # Errors
127///
128/// Returns an error if the launcher cannot be started; on Unix a successful
129/// `exec` never returns.
130pub fn exec(binary: &Path) -> eyre::Result<()> {
131    use std::io::Write as _;
132    // Anything still buffered in the CLI's stdout would be lost (exec) or
133    // interleave with the launcher's own escape sequences (spawn), so flush
134    // before handing over the terminal.
135    let _ = std::io::stdout().flush();
136    #[cfg(unix)]
137    {
138        use eyre::WrapErr as _;
139        use std::os::unix::process::CommandExt as _;
140        Err(std::process::Command::new(binary).exec())
141            .wrap_err_with(|| format!("failed to launch the TUI binary {}", binary.display()))
142    }
143    #[cfg(not(unix))]
144    {
145        // Blocking here is the point: the launcher owns the terminal until it
146        // exits, and nothing runs after this call.
147        let status = std::process::Command::new(binary).status()?;
148        if !status.success() {
149            eyre::bail!("the TUI application exited with {status}");
150        }
151        Ok(())
152    }
153}