use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::embedded;
pub struct Preset {
pub id: &'static str,
pub titulo: &'static str,
pub descricao: &'static str,
pub destaques: &'static [&'static str],
}
pub static PRESETS: &[Preset] = &[
Preset {
id: "completo",
titulo: "App completo",
descricao: "Janela sem decoração com titlebar própria, tema + .gss, componentes, navegação, fetch e toasts.",
destaques: &[
"views/app.gvb com <screen>, <resources> e titlebar custom",
"views/components/ — componentes com <props>",
"views/scripts/handlers/ — comportamento em Luau, um módulo por domínio",
"views/styles/ — theme.json (var()) + app.gss com pseudo-estados e @media",
],
},
Preset {
id: "minimo",
titulo: "Mínimo",
descricao: "Uma tela, um .gss e um <script> Luau. O menor projeto que ainda mostra a ideia.",
destaques: &[
"views/contador.gvb — uma <screen> com <script> Luau embutido",
"views/styles/app.gss — classes em vez de atributos inline",
],
},
Preset {
id: "janelas",
titulo: "Multi-janela + bandeja",
descricao: "GlacierDaemon com open_window, broadcast entre janelas, ícone de bandeja e instância única.",
destaques: &[
"open_window/broadcast/close_window entre janelas isoladas",
"feature `tray`: o app sobrevive ao fechar a última janela",
"single_instance + remember_window_geometry + storage_dir",
],
},
Preset {
id: "rust",
titulo: "Componente em Rust",
descricao: "O trait Component com estado tipado em Rust, em vez de comportamento em Luau.",
destaques: &[
"src/contador.rs — impl Component (name/template/init/update)",
"estado tipado em Rust, template só com markup",
],
},
Preset {
id: "wasm32",
titulo: "Web (WebAssembly)",
descricao: "O mesmo app no desktop e no navegador: componente em Rust, views/ embutida no .wasm e `glacier serve wasm` para compilar e servir.",
destaques: &[
"src/main.rs — embed_assets! só no alvo wasm32; no desktop, disco + hot-reload",
"src/contador.rs — impl Component (no navegador não há <script> Luau)",
"web/index.html — a página que carrega o app.js gerado pelo wasm-bindgen",
"Dockerfile multi-stage: a imagem de produção leva só a página, o .js e o .wasm",
"glacier serve wasm / glacier serve desktop",
],
},
Preset {
id: "android",
titulo: "Android (experimental)",
descricao: "O app mínimo rodando no desktop e no celular, com Makefile e fazer.bat que preparam a máquina (Java, SDK/NDK, cargo-apk) e geram o APK.",
destaques: &[
"Makefile (Linux) e fazer.bat (Windows): setup, doctor, build, release, install, launch, logcat, emulador",
"src/lib.rs — run() + glacier_ui::android_main!(run): a inicialização do Android fica no motor; views/ embutida no .so",
"[patch] para o fork antoniofernandodj/iced_winit — o iced_winit 0.14 com o suporte a Android que o winit exige",
"ANDROID_TODO.md — o que do glacier ainda não funciona lá (open_window, rfd, IME, storage…)",
],
},
Preset {
id: "catalogo",
titulo: "Catálogo de widgets",
descricao: "Uma sidebar de categorias; cada tela mostra dezenas de widgets do motor com um exemplo mínimo e vivo, feito para copiar.",
destaques: &[
"views/categorias/ — uma tela por seção da tabela de widgets (§2 do PLANO)",
"views/app.gvb — sidebar + roteador por {view}, sem escada de <se>",
"views/scripts/app.luau — init() semeia toda chave que os demos leem",
],
},
Preset {
id: "dashboard",
titulo: "Painel de dados",
descricao: "KPIs e gráficos que andam sozinhos: linechart de série múltipla, barchart, piechart, gauge e sparkline, alimentados por every(1000).",
destaques: &[
"views/app.gvb — cards de KPI + <linechart series>/<barchart>/<gauge>",
"views/scripts/app.luau — every(1000, …) empurra as séries sem os widgets saberem do relógio",
],
},
Preset {
id: "formulario",
titulo: "Formulário validado",
descricao: "App de cadastro: textinput/maskedinput/spinbox/dateedit/select/checkbox num <form> com regras declaradas (rules=\"…\"); o motor valida ao enviar.",
destaques: &[
"<form on_submit/on_validation_error> com rules=\"required|minlen:3|digits:11|…\" nos campos",
"pseudo-estado :invalid no .gss; <button type=\"submit\">/\"reset\"",
"validar.luau reduzido a salvar()/apontar()/limpar() — sem revalidar() na mão",
],
},
Preset {
id: "crud",
titulo: "Lista editável (model/view)",
descricao: "Uma <tableview> ligada a um array do contexto, com Novo/Editar/Excluir via prompt{} e confirm{} da camada Luau. Dados em memória.",
destaques: &[
"views/scripts/state.luau — o depósito tipado; handlers/itens.luau — a vitrine",
"prompt{}/confirm{} suspensivos; ordenação e seleção no <tableview>",
],
},
];
pub fn preset(id: &str) -> Option<&'static Preset> {
PRESETS.iter().find(|p| p.id == id)
}
const TEXTUAIS: &[&str] = &[
"gvb", "gva", "gv", "gss", "luau", "rs", "toml", "json", "md", "luaurc", "txt", "bat", "sh",
"html", "conf",
];
const TEXTUAIS_SEM_EXTENSAO: &[&str] = &["Makefile", "Dockerfile", "gitignore"];
pub fn criar(
destino: &Path,
preset: &str,
nome: &str,
versao_motor: &str,
) -> io::Result<Vec<PathBuf>> {
if destino.exists() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} já existe", destino.display()),
));
}
let arquivos = embedded::arquivos_do_preset(preset);
if arquivos.is_empty() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("preset '{preset}' não tem arquivos embutidos"),
));
}
let mut escritos = Vec::new();
for (rel, bytes) in arquivos {
let rel = renomear(rel);
let alvo = destino.join(&rel);
if let Some(pai) = alvo.parent() {
fs::create_dir_all(pai)?;
}
let nome_arquivo = alvo.file_name().and_then(|n| n.to_str()).unwrap_or("");
let ehtexto = alvo
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| TEXTUAIS.contains(&e))
|| TEXTUAIS_SEM_EXTENSAO.contains(&nome_arquivo)
|| rel.ends_with(".gitignore")
|| rel.ends_with(".dockerignore")
|| rel.ends_with(".luaurc");
if ehtexto && let Ok(texto) = std::str::from_utf8(bytes) {
fs::write(&alvo, substituir(texto, nome, versao_motor))?;
} else {
fs::write(&alvo, bytes)?;
}
executavel_se_script(&alvo)?;
escritos.push(PathBuf::from(rel));
}
escritos.sort();
Ok(escritos)
}
#[cfg_attr(not(unix), allow(unused_variables))]
fn executavel_se_script(alvo: &Path) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if alvo.extension().and_then(|e| e.to_str()) == Some("sh") {
fs::set_permissions(alvo, fs::Permissions::from_mode(0o755))?;
}
}
Ok(())
}
fn renomear(rel: &str) -> String {
let rel = rel.strip_suffix(".template").unwrap_or(rel);
match rel.rsplit_once('/') {
Some((dir, "gitignore")) => format!("{dir}/.gitignore"),
None if rel == "gitignore" => ".gitignore".to_string(),
_ => rel.to_string(),
}
}
fn substituir(texto: &str, nome: &str, versao_motor: &str) -> String {
texto
.replace("{{nome_projeto}}", nome)
.replace("{{nome_crate}}", &nome_crate(nome))
.replace("{{titulo}}", &titulo(nome))
.replace("{{versao_motor}}", versao_motor)
}
pub fn nome_crate(nome: &str) -> String {
nome.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
fn titulo(nome: &str) -> String {
nome.split(['-', '_', ' '])
.filter(|p| !p.is_empty())
.map(|p| {
let mut c = p.chars();
match c.next() {
Some(primeira) => primeira.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn validar_nome(nome: &str) -> Result<(), String> {
if nome.is_empty() {
return Err("o nome não pode ser vazio".into());
}
if nome.starts_with(|c: char| c.is_ascii_digit()) {
return Err("o nome não pode começar com dígito (é o nome do crate)".into());
}
if let Some(c) = nome
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '-' && *c != '_')
{
return Err(format!("caractere inválido para um nome de crate: '{c}'"));
}
Ok(())
}
#[cfg(test)]
mod testes {
use super::*;
#[test]
fn titulo_capitaliza_e_troca_separadores() {
assert_eq!(titulo("meu-app"), "Meu App");
assert_eq!(titulo("painel_de_controle"), "Painel De Controle");
}
#[test]
fn nome_crate_troca_hifen_por_underscore() {
assert_eq!(nome_crate("meu-app"), "meu_app");
}
#[test]
fn nome_invalido_e_recusado() {
assert!(validar_nome("2fast").is_err());
assert!(validar_nome("meu app").is_err());
assert!(validar_nome("meu-app_2").is_ok());
}
#[test]
fn marcador_em_chave_dupla_nao_toca_binding_do_gv() {
let gv = r#"<text content="{contador}" /> <!-- {{nome_projeto}} -->"#;
let saida = substituir(gv, "meu-app", "0.61");
assert!(saida.contains("{contador}"));
assert!(saida.contains("meu-app"));
}
#[test]
fn os_disfarces_do_template_sao_desfeitos_ao_criar() {
assert_eq!(renomear("gitignore"), ".gitignore");
assert_eq!(renomear("sub/gitignore"), "sub/.gitignore");
assert_eq!(renomear("Cargo.toml.template"), "Cargo.toml");
assert_eq!(renomear("views/app.gvb"), "views/app.gvb");
}
#[test]
fn todo_preset_tem_arquivos_embutidos() {
for p in PRESETS {
assert!(
!embedded::arquivos_do_preset(p.id).is_empty(),
"preset '{}' não tem templates/{}/",
p.id,
p.id
);
}
}
#[test]
fn preset_web_leva_a_imagem_docker() {
let arquivos = embedded::arquivos_do_preset("wasm32");
for esperado in ["Dockerfile", ".dockerignore", "docker/nginx.conf"] {
assert!(
arquivos.iter().any(|(c, _)| renomear(c) == esperado),
"preset 'wasm32' sem '{esperado}'"
);
}
}
#[test]
fn o_dockerfile_criado_nao_tem_marcador_sobrando() {
let destino = std::env::temp_dir().join(format!(
"glacier-scaffold-docker-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&destino);
criar(&destino, "wasm32", "meu-app", "0.108").expect("criar o preset web");
let dockerfile = fs::read_to_string(destino.join("Dockerfile")).expect("ler o Dockerfile");
assert!(
!dockerfile.contains("{{"),
"marcador não substituído no Dockerfile:\n{dockerfile}"
);
assert!(
dockerfile.contains("release/meu-app.wasm"),
"o Dockerfile não aponta para o .wasm do projeto:\n{dockerfile}"
);
assert!(destino.join("docker/nginx.conf").is_file());
assert!(destino.join(".dockerignore").is_file());
let _ = fs::remove_dir_all(&destino);
}
#[test]
fn preset_android_leva_patch_makefile_e_bat_sem_marcador_sobrando() {
let destino = std::env::temp_dir().join(format!(
"glacier-scaffold-android-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&destino);
criar(&destino, "android", "meu-app", "0.112").expect("criar o preset android");
for esperado in [
"Makefile",
"fazer.bat",
"ANDROID_TODO.md",
"src/lib.rs",
"src/main.rs",
"views/app.gvb",
] {
assert!(destino.join(esperado).is_file(), "faltou '{esperado}'");
}
for texto in [
"Makefile",
"fazer.bat",
"Cargo.toml",
"src/lib.rs",
"src/main.rs",
"views/app.gvb",
] {
let conteudo = fs::read_to_string(destino.join(texto)).expect("ler");
assert!(
!conteudo.contains("{{"),
"marcador não substituído em {texto}"
);
}
let cargo = fs::read_to_string(destino.join("Cargo.toml")).expect("ler Cargo.toml");
assert!(
cargo.contains("name = \"meu_app\""),
"lib sem o nome de crate"
);
assert!(cargo.contains("package = \"com.example.meu_app\""));
assert!(
cargo.contains("features = [\"android\"]"),
"falta a feature android do motor"
);
assert!(
cargo.contains(
"iced_winit = { git = \"https://github.com/antoniofernandodj/iced_winit\""
),
"o [patch] do iced_winit de Android sumiu"
);
let ignore = fs::read_to_string(destino.join(".gitignore")).expect("ler .gitignore");
assert!(ignore.contains("release.keystore"));
let _ = fs::remove_dir_all(&destino);
}
#[test]
fn todo_preset_tem_cargo_toml_e_main() {
for p in PRESETS {
let arquivos = embedded::arquivos_do_preset(p.id);
let tem = |alvo: &str| arquivos.iter().any(|(c, _)| renomear(c) == alvo);
assert!(tem("Cargo.toml"), "preset '{}' sem Cargo.toml", p.id);
assert!(tem("src/main.rs"), "preset '{}' sem src/main.rs", p.id);
}
}
}