Files
craft-anything/crates/ui-egui/src/make.rs
BFlat dc344ea305 The Make screen says Craft Anything looks at the original, not an unnamed "we" (#107)
The Make screen's question and its Clean room and Deep answers name Craft Anything instead of an unnamed we; the French catalog follows. Fixes #75.
2026-11-01 23:23:06 +07:00

436 lines
19 KiB
Rust

//! Simple mode's front door, for people new to computers: two numbered questions in plain words
//! ("What do you want to recreate?", "Say what you want"), big targets and one big Start button. Below it,
//! the projects made so far.
use crate::i18n::tr;
use crate::state::MakeDraft;
use crate::theme::{self, Tokens};
use crate::{CraftApp, Pick, icons, widgets};
use craft_anything_engine::workspace::{Status, TargetRef};
use egui::{Margin, RichText, Sense, Ui, Vec2};
const SUGGESTIONS_WEB: &[&str] = &["Recreate this website", "Recreate it with darker colours"];
const SUGGESTIONS_APP: &[&str] = &["Make my own version of this app", "Recreate just its main feature"];
const SUGGESTIONS_GAME: &[&str] = &["Make a game that plays like this", "Make a simpler version of this game"];
const SUGGESTIONS_NONE: &[&str] = &["A website for my bakery", "A simple snake game"];
/// The first http(s) address in some text.
pub fn find_url(text: &str) -> Option<String> {
text.split_whitespace()
.map(|w| w.trim_matches(|c: char| matches!(c, '"' | '\'' | '(' | ')' | '<' | '>' | ',' | '.' | ';')))
.find(|w| w.starts_with("http://") || w.starts_with("https://"))
.map(str::to_string)
}
/// Why a typed web address can't be used, or `None` when it's fine (or empty).
pub fn url_problem(typed: &str) -> Option<&'static str> {
let t = typed.trim();
if t.is_empty() {
return None;
}
let url = normalize_url(t);
let host = url.split("://").nth(1).unwrap_or_default().split(['/', '?', '#']).next().unwrap_or_default();
let host = host.rsplit('@').next().unwrap_or_default().split(':').next().unwrap_or_default();
let ok = (url.starts_with("http://") || url.starts_with("https://"))
&& !t.chars().any(char::is_whitespace)
&& (host == "localhost" || (host.contains('.') && !host.starts_with('.') && !host.ends_with('.')));
(!ok).then_some("Enter a full web address, like example.com or https://example.com/page.")
}
/// "example.com" → "https://example.com"; anything with a scheme is kept.
pub fn normalize_url(s: &str) -> String {
let t = s.trim();
if t.is_empty() || t.contains("://") { t.to_string() } else { format!("https://{t}") }
}
/// The target the draft will use: the picked one, the typed address, or an address in the request.
pub fn resolve_target(d: &MakeDraft) -> TargetRef {
if d.target != TargetRef::None {
return d.target.clone();
}
if !d.url.trim().is_empty() {
return TargetRef::parse(&normalize_url(&d.url));
}
find_url(&d.request).map(|url| TargetRef::Website { url }).unwrap_or(TargetRef::None)
}
/// A game is being copied or made, so it's built with a game engine ("Build with").
pub fn is_game(kinds: &craft_anything_ai::rebuild::KindRegistry, d: &MakeDraft) -> bool {
use craft_anything_ai::rebuild::GameKind;
d.found.is_some()
|| (d.game && matches!(d.target, TargetRef::Local { .. }))
|| kinds.choose(&d.request, &resolve_target(d), false).is_some_and(|(k, _)| k.id() == GameKind::ID)
}
/// The front door: make your own version of something, or just look inside it.
pub fn hero(app: &mut CraftApp, ui: &mut Ui, w: f32) {
let t = Tokens::get(ui.ctx());
if let Some(i) = widgets::segmented_sized(ui, &["Make my own version", "Just look inside"], usize::from(app.state.make.look), 38.0, 12.5) {
app.state.make.look = i == 1;
}
ui.add_space(18.0);
if app.state.make.look {
look(app, ui, w);
return;
}
ui.label(theme::heading(tr("What do you want to make?"), theme::TITLE));
ui.add_space(6.0);
ui.label(RichText::new(tr("Choose what to recreate, say what you want, then press Start.")).size(theme::LEAD).color(t.text_dim));
ui.add_space(16.0);
widgets::card(&t).inner_margin(Margin::same(20)).show(ui, |ui| {
ui.set_width(w - 50.0);
step_title(ui, 1, "What do you want to recreate?");
ui.add_space(12.0);
choose_target(app, ui);
// How closely the original may be looked at, once there is one.
let d = &app.state.make;
if matches!(d.target, TargetRef::Local { .. }) || d.editing_url || !d.url.is_empty() {
ui.add_space(14.0);
look_inside(app, ui);
}
ui.add_space(20.0);
step_title(ui, 2, "Say what you want");
ui.add_space(12.0);
// The example matches the tile picked above (a "recreate this website" hint under
// "Nothing — start fresh" contradicts the choice).
let d = &app.state.make;
let hint = match &d.target {
TargetRef::Local { .. } if d.game => "For example: the same game, with a high-score table",
TargetRef::Local { .. } => "For example: the same app, but simpler",
TargetRef::Website { .. } => "For example: recreate this website",
TargetRef::None if d.editing_url || !d.url.is_empty() => "For example: recreate this website",
TargetRef::None => "For example: a website for my bakery",
};
// Websites are photographed for the builder; anything else looks right from pictures.
let website = matches!(resolve_target(d), TargetRef::Website { .. }) || d.editing_url;
let out = crate::prompt_box::show(
ui,
&mut app.state.make.request,
&app.state.make.pictures,
crate::prompt_box::PromptBox {
name: "Say what you want",
hint: tr(hint),
rows: (3, 10),
attach: (!website).then_some("Add pictures of the original (optional: screenshots help it look right)"),
stop_button: None,
send: false,
recall: false,
history: None,
font_size: theme::LEAD,
},
);
if let Some(i) = out.remove {
app.state.make.pictures.remove(i);
}
if out.attach {
app.pick(Pick::Pictures);
}
let submit = out.send;
if app.state.make.request.trim().is_empty() {
ui.add_space(10.0);
suggestions(app, ui);
}
if is_game(&app.kinds, &app.state.make) {
ui.add_space(22.0);
crate::engine_ui::build_with(app, ui);
ui.add_space(14.0);
let on = app.state.make.generate_assets;
crate::engine_ui::assets_toggle(app, ui, on);
}
ui.add_space(16.0);
ui.horizontal(|ui| {
crate::builders::built_by(app, ui);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ready = !app.state.make.request.trim().is_empty() && url_problem(&app.state.make.url).is_none() && app.creating.is_none();
let start = widgets::primary_sized(ui, Some("sparkles"), "Start", ready, 50.0, 14.0);
let start = if ready { start } else { start.on_disabled_hover_text(tr("Say what you want first.")) };
if (start.clicked() || submit) && ready {
app.start_make();
}
});
});
// Where it's saved: secondary to Start, so under it.
ui.add_space(10.0);
crate::folders::make_line(app, ui);
});
}
/// "Just look inside": what to look at, each opening straight away. Nothing is built or run.
fn look(app: &mut CraftApp, ui: &mut Ui, w: f32) {
let t = Tokens::get(ui.ctx());
ui.label(theme::heading(tr("What do you want to look inside?"), theme::TITLE));
ui.add_space(6.0);
ui.label(RichText::new(tr("We only read it; nothing runs. Every answer shows how sure we are.")).size(theme::LEAD).color(t.text_dim));
ui.add_space(22.0);
widgets::card(&t).inner_margin(Margin::same(24)).show(ui, |ui| {
ui.set_width(w - 50.0);
// Each of these reads the original, which Clean room never does.
if !app.settings.deep_mode {
crate::engine_ui::deep_needed(app, ui);
return;
}
let size = Vec2::new(ui.available_width(), 72.0);
ui.spacing_mut().item_spacing.y = 10.0;
if widgets::choice_tile(ui, size, "app-window", "An app or a file", "See how a program works: what it does, what it talks to, what's inside.", false)
.clicked()
{
app.pick(Pick::OpenFile);
}
if widgets::choice_tile(ui, size, "globe", "A website", "See how a page is built: its layout, styles, scripts and the requests it makes.", false)
.clicked()
{
app.state.modal = Some(crate::state::Modal::Website);
}
if widgets::choice_tile(ui, size, "package", "A game's files", "See what a game packs, and save its pictures as PNG files.", false).clicked() {
app.state.screen = crate::state::Screen::GameFiles;
}
});
}
/// "1 What do you want to recreate?" — a numbered circle and a plain question.
fn step_title(ui: &mut Ui, n: u32, text: &str) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(Vec2::splat(28.0), Sense::hover());
ui.painter().rect(r, r.width() / 2.0, t.raised, egui::Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside);
let g = widgets::label_galley(ui, &n.to_string(), 13.0, t.text);
ui.painter().galley_with_override_text_color(r.center() - g.size() / 2.0, g, t.text);
ui.add_space(4.0);
ui.label(theme::heading(tr(text), theme::SECTION));
});
}
fn choose_target(app: &mut CraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let d = &app.state.make;
let local = matches!(d.target, TargetRef::Local { .. }).then_some(d.game);
let website = local.is_none() && (d.editing_url || !d.url.is_empty());
let scratch = local.is_none() && !website;
let gap = 10.0;
let cols = if ui.available_width() > 760.0 { 4 } else { 2 };
let tile_w = ((ui.available_width() - gap * (cols as f32 - 1.0)) / cols as f32).floor();
let size = Vec2::new(tile_w, 60.0);
let mut pick = None;
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = Vec2::splat(gap);
for (i, (icon, label, on)) in [
("globe", "A website", website),
("app-window", "An app", local == Some(false)),
("puzzle", "A game", local == Some(true)),
("sparkles", "Nothing — start fresh", scratch),
]
.into_iter()
.enumerate()
{
if widgets::choice_tile(ui, size, icon, label, "", on).clicked() {
pick = Some(i);
}
}
});
let m = &mut app.state.make;
if matches!(pick, Some(0) | Some(3)) {
// A different kind of start: forget the last game's engine and the engine picked for it.
m.found = None;
m.builder = None;
}
match pick {
Some(0) => {
m.target = TargetRef::None;
m.editing_url = true;
}
Some(1) => app.pick(Pick::TargetFile),
Some(2) => app.pick(Pick::TargetFolder),
Some(_) => {
m.target = TargetRef::None;
m.url.clear();
m.editing_url = false;
}
None => {}
}
let m = &mut app.state.make;
// Recomputed so the address field shows on the very frame "A website" is clicked.
let website = !matches!(m.target, TargetRef::Local { .. }) && (m.editing_url || !m.url.is_empty());
if let TargetRef::Local { path } = m.target.clone() {
ui.add_space(14.0);
ui.horizontal(|ui| {
ui.label(RichText::new(tr("You chose:")).size(theme::BODY).color(t.text_dim));
ui.label(RichText::new(m.target.label()).font(theme::semibold(theme::BODY)));
ui.label(RichText::new(crate::short_path(&path)).color(t.text_faint).size(theme::SMALL));
});
} else if website {
ui.add_space(16.0);
ui.label(RichText::new(tr("Type the website's address:")).size(theme::BODY).color(t.text_dim));
ui.add_space(6.0);
let r = ui.add(
egui::TextEdit::singleline(&mut m.url)
.hint_text("example.com")
.desired_width(520.0)
.font(egui::FontId::proportional(theme::LEAD))
.frame(widgets::text_field_frame(&t).inner_margin(Margin::symmetric(14, 12))),
);
if pick == Some(0) {
r.request_focus();
}
// Say what's wrong right under the field, once something has been typed.
if let Some(problem) = url_problem(&m.url) {
ui.add_space(4.0);
ui.horizontal(|ui| {
icons::show(ui, "triangle-alert", 16.0, t.danger);
ui.label(RichText::new(tr(problem)).color(t.danger).size(theme::BODY));
});
}
}
}
/// Clean room or Deep for what is recreated: the title bar's switch, as a plain choice.
fn look_inside(app: &mut CraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// The app's one setting (the title bar's switch), not this project's: said where it's chosen.
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(tr("How closely may Craft Anything look at it?")).size(theme::BODY).color(t.text_dim));
ui.label(RichText::new(tr("For everything you make, like the switch in the title bar.")).size(theme::SMALL).color(t.text_faint));
});
ui.add_space(6.0);
let deep = app.settings.deep_mode;
let gap = 10.0;
let size = Vec2::new(((ui.available_width() - gap) / 2.0).floor(), 72.0);
let mut pick = None;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = gap;
for (on, icon, label, blurb) in [
(false, "shield-check", "Clean room", "Nothing inside it is read; Craft Anything works from what you can see. Your version gets its own art."),
(true, "microscope", "Deep", "Craft Anything reads its code and files with every tool it has. For your private use."),
] {
if widgets::choice_tile(ui, size, icon, label, blurb, deep == on).clicked() {
pick = Some(on);
}
}
});
if let Some(on) = pick {
app.set_deep(on);
}
// Ghidra reads programs, not websites.
if !matches!(app.state.make.target, TargetRef::Local { .. }) {
return;
}
if app.settings.deep_mode && !app.ghidra_missing() {
ui.add_space(8.0);
ui.horizontal_wrapped(|ui| {
icons::show(ui, "cpu", 16.0, t.icon);
let line = format!("{} {}", crate::engine_ui::ghidra_memory_words(app), tr("More makes a big program's first look faster."));
ui.label(RichText::new(line).size(theme::SMALL).color(t.text_dim)).on_hover_text(crate::engine_ui::ghidra_memory_guide());
if widgets::link(ui, "Change").clicked() {
app.state.settings_open = true;
app.state.settings_tab = crate::state::SettingsTab::Engines;
}
});
}
if app.settings.deep_mode && app.ghidra_missing() {
ui.add_space(8.0);
ui.horizontal(|ui| {
icons::show(ui, "triangle-alert", 16.0, t.warning);
ui.label(RichText::new(tr("Ghidra isn't on this computer yet, so the built-in decompiler stands in.")).size(theme::SMALL).color(t.text_dim));
if app.ghidra.job.is_none() && widgets::secondary(ui, Some("download"), "Get Ghidra").clicked() {
app.state.modal = Some(crate::state::Modal::Ghidra);
}
});
}
}
fn suggestions(app: &mut CraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let list = match resolve_target(&app.state.make) {
TargetRef::Website { .. } => SUGGESTIONS_WEB,
TargetRef::Local { .. } if app.state.make.game => SUGGESTIONS_GAME,
TargetRef::Local { .. } => SUGGESTIONS_APP,
TargetRef::None => SUGGESTIONS_NONE,
};
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = Vec2::new(8.0, 8.0);
ui.label(RichText::new(tr("Ideas:")).size(theme::BODY).color(t.text_dim));
for s in list.iter().take(2) {
if widgets::suggestion(ui, s).clicked() {
app.state.make.request = tr(s);
}
}
});
}
/// Time since `ms` in plain words.
pub fn ago(ms: u64) -> String {
let now = craft_anything_engine::inbox::now_ms();
let s = now.saturating_sub(ms) / 1000;
match s {
0..=59 => tr("just now"),
60..=3599 => crate::i18n::trf("{n} min ago", &[("n", &(s / 60).to_string())]),
3600..=86_399 => crate::i18n::trf("{n} h ago", &[("n", &(s / 3600).to_string())]),
86_400..=172_799 => tr("yesterday"),
_ => crate::i18n::trf("{n} days ago", &[("n", &(s / 86_400).to_string())]),
}
}
/// A project's status as the app shows it: waiting its turn, interrupted (its run stopped
/// part-way: the app or the computer closed), or the one it saved. (label, text, fill).
pub fn status_shown(app: &CraftApp, ws: &craft_anything_engine::workspace::Workspace, t: &Tokens) -> (&'static str, egui::Color32, egui::Color32) {
let s = ws.manifest.status;
let (label, look) = if app.asking.contains(&ws.root) {
("Needs you", Status::NeedsYou)
} else if app.runs.position(&ws.root).is_some() {
("Waiting", s)
} else if crate::project::interrupted(app, ws) {
("Interrupted", Status::NeedsYou)
} else {
(s.label(), s)
};
let (fg, bg) = status_colors(t, look);
(label, fg, bg)
}
pub fn status_colors(t: &Tokens, s: Status) -> (egui::Color32, egui::Color32) {
match s {
Status::Ready => (t.success, t.raised),
Status::NeedsYou => (t.warning, t.raised),
Status::Stopped | Status::New => (t.text_dim, t.raised),
_ => (t.accent_ink, t.accent_soft),
}
}
/// A project's icon: a game (built with a game engine), a website, an app, or something new.
pub fn project_icon(m: &craft_anything_engine::workspace::Manifest) -> &'static str {
if craft_anything_engines::game::Builder::from_stack(&m.stack).is_some() {
return "puzzle";
}
match m.target {
TargetRef::Website { .. } => "globe",
TargetRef::Local { .. } => "app-window",
TargetRef::None => "sparkles",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn web_addresses_are_checked_inline() {
for ok in ["", "example.com", "https://a.b/c?d", "http://localhost:5173", "sub.example.co.uk/page"] {
assert!(url_problem(ok).is_none(), "{ok}");
}
for bad in ["example", "https://", "two words.com", "ftp://x.y", "https://.com", "x."] {
assert!(url_problem(bad).is_some(), "{bad}");
}
}
#[test]
fn targets_from_the_draft() {
let mut d = MakeDraft { request: "Please recreate https://example.com/pricing.".into(), ..Default::default() };
assert_eq!(resolve_target(&d), TargetRef::Website { url: "https://example.com/pricing".into() });
d.url = "acme.io".into();
assert_eq!(resolve_target(&d), TargetRef::Website { url: "https://acme.io".into() }, "the typed address wins");
d.url.clear();
d.request = "a snake game".into();
assert_eq!(resolve_target(&d), TargetRef::None);
assert_eq!(normalize_url("http://x.y"), "http://x.y");
}
}