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(|built| built.artifact)
116        .map_err(|error| eyre::eyre!("failed to build the TUI launcher: {error}"))
117}
118
119/// Hand the invoking terminal to the built launcher.
120///
121/// On Unix the launcher replaces the CLI process via `exec`, so the terminal is
122/// owned by exactly one process and its exit status propagates unchanged — a
123/// spawned child would instead share the process group with `water` and keep
124/// the TTY after `water` itself dies on `SIGINT`. Elsewhere the launcher is
125/// spawned with inherited stdio and awaited.
126///
127/// # Errors
128///
129/// Returns an error if the launcher cannot be started; on Unix a successful
130/// `exec` never returns.
131pub fn exec(binary: &Path) -> eyre::Result<()> {
132    use std::io::Write as _;
133    // Anything still buffered in the CLI's stdout would be lost (exec) or
134    // interleave with the launcher's own escape sequences (spawn), so flush
135    // before handing over the terminal.
136    let _ = std::io::stdout().flush();
137    #[cfg(unix)]
138    {
139        use eyre::WrapErr as _;
140        use std::os::unix::process::CommandExt as _;
141        Err(std::process::Command::new(binary).exec())
142            .wrap_err_with(|| format!("failed to launch the TUI binary {}", binary.display()))
143    }
144    #[cfg(not(unix))]
145    {
146        // Blocking here is the point: the launcher owns the terminal until it
147        // exits, and nothing runs after this call.
148        let status = std::process::Command::new(binary).status()?;
149        if !status.success() {
150            eyre::bail!("the TUI application exited with {status}");
151        }
152        Ok(())
153    }
154}