use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use console::style;
use crate::ui;
const SCAFFOLD_TUI_VERSION: &str = "0.4";
pub fn run(args: &[String]) {
match args.first().map(|s| s.as_str()) {
Some("new") => {
let name = args.get(1).map(|s| s.as_str()).unwrap_or_else(|| {
ui::error("Usage: crepus tui new <name>");
});
scaffold_tui_app(name);
}
Some("build") => {
let release = args.iter().any(|a| a == "--release");
let extra = args
.iter()
.skip(1)
.filter(|a| !matches!(a.as_str(), "--release"))
.cloned()
.collect::<Vec<_>>();
build_tui_app(release, &extra);
}
Some("run") => {
let release = args.iter().any(|a| a == "--release");
let extra = args
.iter()
.skip(1)
.filter(|a| !matches!(a.as_str(), "--release"))
.cloned()
.collect::<Vec<_>>();
run_tui_app(release, &extra);
}
Some("preview") => {
let path = args.get(1).map(PathBuf::from).unwrap_or_else(|| {
ui::error("Usage: crepus tui preview <file.crepus>");
});
preview_tui_template(&path);
}
_ => print_tui_usage(),
}
}
fn scaffold_tui_app(name: &str) {
let dir = Path::new(name);
fs::create_dir_all(dir).unwrap_or_else(|e| {
ui::error(&format!("failed to create directory: {}", e));
});
let cargo_toml = format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
[dependencies]
# Bump these as needed; the scaffold is pinned to the version of `crepus` you
# scaffolded with.
crepuscularity-tui = "{tui_version}"
ratatui = {{ version = "0.29", default-features = false, features = ["crossterm"] }}
crossterm = "0.28"
anyhow = "1.0"
"#,
name = name,
tui_version = SCAFFOLD_TUI_VERSION,
);
let main_rs = r###"//! Generated by `crepus tui new`. Edit `app.crepus` and run `crepus tui run`
//! (or `cargo run`). The template hot-reloads on save.
use std::time::Duration;
use crepuscularity_tui::{HotTemplate, ReloadOutcome};
use crossterm::event::{Event, KeyCode};
use ratatui::prelude::*;
fn main() -> anyhow::Result<()> {
let mut hot = HotTemplate::watch("app.crepus").map_err(|e| anyhow::anyhow!(e))?;
hot.template_mut()
.set("title", "My TUI App")
.set("message", "Hello from Crepuscularity!")
.set("quit_hint", "Press 'q' to quit");
let mut terminal = ratatui::init();
let result = run(&mut terminal, &mut hot);
ratatui::restore();
result
}
fn run(terminal: &mut Terminal<impl Backend>, hot: &mut HotTemplate) -> anyhow::Result<()> {
loop {
terminal.draw(|frame| {
let _ = hot.poll_and_draw_full(frame);
})?;
if crossterm::event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = crossterm::event::read()? {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
return Ok(());
}
}
}
// Avoid an unused-variant warning if a future ReloadOutcome::Error is added.
let _ = ReloadOutcome::Unchanged;
}
}
"###;
fs::write(dir.join("Cargo.toml"), cargo_toml).unwrap_or_else(|e| {
ui::error(&format!("failed to write Cargo.toml: {}", e));
});
fs::create_dir_all(dir.join("src")).unwrap_or_else(|e| {
ui::error(&format!("failed to create src directory: {}", e));
});
fs::write(dir.join("src/main.rs"), main_rs).unwrap_or_else(|e| {
ui::error(&format!("failed to write src/main.rs: {}", e));
});
let template_content = r#"div bg-black text-white flex flex-col gap-4
div text-2xl font-bold text-green-400
"Welcome to {title}"
div text-lg
"{message}"
div text-sm text-gray-500
"{quit_hint}"
"#;
fs::write(dir.join("app.crepus"), template_content).unwrap_or_else(|e| {
ui::error(&format!("failed to write app.crepus: {}", e));
});
ui::success(&format!(
"Created new TUI app '{}' in directory '{}'",
name,
dir.display()
));
eprintln!("Run 'cd {} && cargo run' to start the app", name);
}
fn build_tui_app(release: bool, extra: &[String]) {
ensure_cargo_project_for_tui();
let mut cmd = Command::new("cargo");
cmd.arg("build");
if release {
cmd.arg("--release");
}
cmd.args(extra);
delegate_to_cargo(cmd, "build");
}
fn run_tui_app(release: bool, extra: &[String]) {
ensure_cargo_project_for_tui();
let mut cmd = Command::new("cargo");
cmd.arg("run");
if release {
cmd.arg("--release");
}
if !extra.is_empty() {
cmd.arg("--").args(extra);
}
delegate_to_cargo(cmd, "run");
}
fn ensure_cargo_project_for_tui() {
if !Path::new("Cargo.toml").exists() {
ui::error(
"no Cargo.toml in the current directory. Run `crepus tui build|run` from the \
root of a TUI app scaffolded with `crepus tui new <name>`.",
);
}
}
fn delegate_to_cargo(mut cmd: Command, verb: &str) {
match cmd.status() {
Ok(status) if status.success() => {}
Ok(status) => {
std::process::exit(status.code().unwrap_or(1));
}
Err(e) => {
ui::error(&format!(
"failed to invoke `cargo {verb}`: {e}. Is the Rust toolchain installed and on PATH?"
));
}
}
}
fn print_tui_usage() {
eprintln!(
"{}",
style("crepus tui — Terminal User Interface applications")
.cyan()
.bold()
);
eprintln!();
eprintln!("{}", style("COMMANDS").dim());
eprintln!(
" {} {}",
style("new <name> ").green(),
style("scaffold a new TUI app").dim()
);
eprintln!(
" {} {}",
style("build [--release]").green(),
style("build the TUI app").dim()
);
eprintln!(
" {} {}",
style("run ").green(),
style("run the TUI app").dim()
);
eprintln!(
" {} {}",
style("preview <file> ").green(),
style("live-preview a .crepus template in the terminal").dim()
);
eprintln!();
eprintln!("{}", style("EXAMPLES").dim());
eprintln!(" crepus tui new my-tui-app");
eprintln!(" cd my-tui-app && crepus tui build");
eprintln!(" crepus tui run");
eprintln!(" crepus tui preview app.crepus # hot-reload preview, q/Esc to quit");
}
fn preview_tui_template(path: &Path) {
use crepuscularity_tui::{HotTemplate, ReloadOutcome};
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use std::time::{Duration, Instant};
if !path.exists() {
ui::error(&format!("file not found: {}", path.display()));
}
let mut hot = match HotTemplate::watch(path) {
Ok(h) => h,
Err(e) => ui::error(&format!("could not load template: {e}")),
};
if let Some(dir) = path.parent() {
let ctx_path = dir.join("context.toml");
if ctx_path.exists() {
load_tui_context_toml(&ctx_path, hot.template_mut().context_mut());
}
}
eprintln!(
"{} previewing {} (q/Esc to quit, r to force reload)",
style("crepus tui").dim(),
style(path.display().to_string()).cyan().bold()
);
let mut terminal = ratatui::init();
let mut last_reload: Option<(Instant, ReloadOutcome)> = None;
let result: Result<(), String> = loop {
let outcome_holder = std::cell::Cell::new(ReloadOutcome::Unchanged);
if let Err(e) = terminal.draw(|frame| match hot.poll_and_draw_full(frame) {
Ok(o) => outcome_holder.set(o),
Err(_) => outcome_holder.set(ReloadOutcome::Unchanged),
}) {
break Err(format!("terminal draw: {e}"));
}
match outcome_holder.into_inner() {
ReloadOutcome::Unchanged => {}
other => last_reload = Some((Instant::now(), other)),
}
if matches!(event::poll(Duration::from_millis(100)), Ok(true)) {
match event::read() {
Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
KeyCode::Char('r') => {
if let Err(e) = hot.template_mut().reload() {
last_reload = Some((Instant::now(), ReloadOutcome::Error(e)));
} else {
last_reload = Some((Instant::now(), ReloadOutcome::Reloaded));
}
}
_ => {}
},
Ok(_) => {}
Err(e) => break Err(format!("event read: {e}")),
}
}
if let Some((t, _)) = &last_reload {
if t.elapsed() > Duration::from_secs(2) {
last_reload = None;
}
}
};
ratatui::restore();
if let Some((_, ReloadOutcome::Error(msg))) = last_reload {
eprintln!("{} {}", style("⚠").yellow(), msg);
}
if let Err(e) = result {
ui::error(&e);
}
}
fn load_tui_context_toml(path: &Path, ctx: &mut crepuscularity_tui::TemplateContext) {
use crepuscularity_tui::TemplateValue;
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(eq) = line.find('=') {
let key = line[..eq].trim();
let val = line[eq + 1..].trim();
if val == "true" {
ctx.set(key, TemplateValue::Bool(true));
} else if val == "false" {
ctx.set(key, TemplateValue::Bool(false));
} else if let Ok(n) = val.parse::<i64>() {
ctx.set(key, TemplateValue::Int(n));
} else if let Ok(f) = val.parse::<f64>() {
ctx.set(key, TemplateValue::Float(f));
} else {
ctx.set(key, strip_one_outer_quote_pair(val).to_string());
}
}
}
}
fn strip_one_outer_quote_pair(s: &str) -> &str {
let bytes = s.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return &s[1..s.len() - 1];
}
}
s
}
#[cfg(test)]
mod tests {
use super::strip_one_outer_quote_pair;
#[test]
fn strips_single_outer_double_quotes() {
assert_eq!(strip_one_outer_quote_pair("\"hello\""), "hello");
}
#[test]
fn strips_single_outer_single_quotes() {
assert_eq!(strip_one_outer_quote_pair("'hi'"), "hi");
}
#[test]
fn preserves_inner_quotes_when_repeated() {
assert_eq!(
strip_one_outer_quote_pair("\"\"keep\"\""),
"\"keep\"",
"only the outermost pair should be stripped"
);
}
#[test]
fn preserves_mismatched_quotes() {
assert_eq!(strip_one_outer_quote_pair("\"hi'"), "\"hi'");
assert_eq!(strip_one_outer_quote_pair("'hi\""), "'hi\"");
}
#[test]
fn preserves_unquoted_value() {
assert_eq!(strip_one_outer_quote_pair("plain"), "plain");
}
#[test]
fn handles_short_inputs() {
assert_eq!(strip_one_outer_quote_pair(""), "");
assert_eq!(strip_one_outer_quote_pair("\""), "\"");
assert_eq!(strip_one_outer_quote_pair("\"\""), "");
}
}