|
|
|
|
@ -1,8 +1,10 @@
|
|
|
|
|
//! The Home screen (shown in the Composition panel at launch, from the header's Home button, and
|
|
|
|
|
//! by the Learn workspace): New Project / Open Project / New Composition / Open Demo, the recent
|
|
|
|
|
//! projects (File ▸ Open Recent, from Settings) with thumbnails, folders and dates, the
|
|
|
|
|
//! Templates tab (New from Template gallery, `templates`), the Learn tab, and where After
|
|
|
|
|
//! Effects has its Learn / What's New area, the ArtCraft community links.
|
|
|
|
|
//! The Home screen, laid out like After Effects' (shown over the whole workspace at launch, from
|
|
|
|
|
//! the Tools bar's Home button and by the Learn workspace). A left rail holds New Project / Open
|
|
|
|
|
//! Project, the Home / Templates / Learn pages and, at its foot, the ArtCraft community links.
|
|
|
|
|
//! The Home page welcomes you with quick-start tiles (New Composition, the demo project, Import,
|
|
|
|
|
//! New from Template) over the recent projects (File ▸ Open Recent, from Settings): a filterable
|
|
|
|
|
//! table with thumbnails and Name / Opened / Size / Kind columns. Templates is the New from
|
|
|
|
|
//! Template gallery (`templates`), Learn the guided tutorials (`learn`).
|
|
|
|
|
//!
|
|
|
|
|
//! Thumbnails: when a project is opened or saved and the viewer has its frame, a 96×54 RGB
|
|
|
|
|
//! thumbnail is stored in the config store (`thumb-<hash>.txt`, hex) and shown here.
|
|
|
|
|
@ -29,6 +31,10 @@ pub struct RecentEntry {
|
|
|
|
|
pub folder: String,
|
|
|
|
|
/// Last modified (`2026-10-02 14:05`, UTC), when the file is readable.
|
|
|
|
|
pub modified: Option<String>,
|
|
|
|
|
/// Last modified, Unix seconds.
|
|
|
|
|
pub modified_secs: Option<i64>,
|
|
|
|
|
/// File size in bytes.
|
|
|
|
|
pub size: Option<u64>,
|
|
|
|
|
pub exists: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -41,17 +47,19 @@ pub fn recent_entries(prefs: &effectcraft_engine::prefs::Prefs) -> Vec<RecentEnt
|
|
|
|
|
.map(|(index, p)| {
|
|
|
|
|
let path = std::path::Path::new(p);
|
|
|
|
|
let meta = std::fs::metadata(path).ok();
|
|
|
|
|
let modified = meta
|
|
|
|
|
let modified_secs = meta
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|m| m.modified().ok())
|
|
|
|
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
|
|
|
|
.map(|d| format_utc(d.as_secs() as i64));
|
|
|
|
|
.map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
|
|
|
|
|
RecentEntry {
|
|
|
|
|
index,
|
|
|
|
|
path: p.clone(),
|
|
|
|
|
name: path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()),
|
|
|
|
|
folder: path.parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
|
|
|
|
|
modified,
|
|
|
|
|
modified: modified_secs.map(format_utc),
|
|
|
|
|
modified_secs,
|
|
|
|
|
size: meta.as_ref().filter(|m| m.is_file()).map(|m| m.len()),
|
|
|
|
|
exists: meta.is_some(),
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
@ -75,6 +83,45 @@ pub fn format_utc(secs: i64) -> String {
|
|
|
|
|
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem % 3600) / 60)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// How long ago `then` was, as the Home screen's Opened column shows it ("3 hours ago", then the
|
|
|
|
|
/// date after a week).
|
|
|
|
|
pub fn format_ago(now: i64, then: i64) -> String {
|
|
|
|
|
let d = now.saturating_sub(then);
|
|
|
|
|
let plural = |n: i64, unit: &str| format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" });
|
|
|
|
|
match d {
|
|
|
|
|
i64::MIN..60 => "Just now".to_string(),
|
|
|
|
|
60..3_600 => plural(d / 60, "minute"),
|
|
|
|
|
3_600..86_400 => plural(d / 3_600, "hour"),
|
|
|
|
|
86_400..172_800 => "Yesterday".to_string(),
|
|
|
|
|
172_800..604_800 => plural(d / 86_400, "day"),
|
|
|
|
|
_ => format_utc(then).get(..10).unwrap_or_default().to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A file size as the Home screen shows it (`12 KB`, `3.4 MB`).
|
|
|
|
|
pub fn format_size(bytes: u64) -> String {
|
|
|
|
|
let b = bytes as f64;
|
|
|
|
|
if b < 1024.0 {
|
|
|
|
|
format!("{bytes} B")
|
|
|
|
|
} else if b < 1024.0 * 1024.0 {
|
|
|
|
|
format!("{:.0} KB", b / 1024.0)
|
|
|
|
|
} else if b < 1024.0 * 1024.0 * 1024.0 {
|
|
|
|
|
format!("{:.1} MB", b / (1024.0 * 1024.0))
|
|
|
|
|
} else {
|
|
|
|
|
format!("{:.1} GB", b / (1024.0 * 1024.0 * 1024.0))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The Kind column: what a recent file is, by its extension.
|
|
|
|
|
fn kind_of(path: &str) -> &'static str {
|
|
|
|
|
match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
|
|
|
|
|
Some("ecproj") => "EffectCraft project",
|
|
|
|
|
Some("aep" | "aepx") => "After Effects project",
|
|
|
|
|
Some("json" | "lottie") => "Lottie animation",
|
|
|
|
|
_ => "Project",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The config-store name of a project's thumbnail.
|
|
|
|
|
pub fn thumb_name(path: &str) -> String {
|
|
|
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
|
|
|
@ -157,191 +204,261 @@ fn thumb_texture(app: &mut EffectcraftApp, ctx: &egui::Context, path: &str) -> O
|
|
|
|
|
tex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Draw the Home screen.
|
|
|
|
|
/// Width of the Home screen's left rail.
|
|
|
|
|
const RAIL_W: f32 = 236.0;
|
|
|
|
|
|
|
|
|
|
/// Draw the Home screen over `rect` (the whole workspace).
|
|
|
|
|
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|
|
|
|
let t = app.tokens;
|
|
|
|
|
let ctx = ui.ctx().clone();
|
|
|
|
|
let p = ui.painter().with_clip_rect(rect);
|
|
|
|
|
p.rect_filled(rect, 0.0, Color32::from_rgb(0x1b, 0x1d, 0x24));
|
|
|
|
|
let wide = rect.width() >= 820.0;
|
|
|
|
|
let pad = 32.0;
|
|
|
|
|
let left_w = if wide { 250.0 } else { rect.width() - pad * 2.0 };
|
|
|
|
|
p.rect_filled(rect, 0.0, t.app_bg);
|
|
|
|
|
let mut actions: Vec<(&str, serde_json::Value)> = vec![];
|
|
|
|
|
let rail = Rect::from_min_size(rect.min, vec2(RAIL_W.min(rect.width() * 0.4), rect.height()));
|
|
|
|
|
p.rect_filled(rail, 0.0, t.panel_bg);
|
|
|
|
|
p.line_segment([rail.right_top(), rail.right_bottom()], Stroke::new(1.0, t.separator));
|
|
|
|
|
rail_ui(app, ui, &p, rail, &mut actions);
|
|
|
|
|
let main = Rect::from_min_max(pos2(rail.max.x + 40.0, rect.min.y + 32.0), pos2(rect.max.x - 40.0, rect.max.y - 16.0));
|
|
|
|
|
if app.ui.home_learn {
|
|
|
|
|
super::learn::home_tab(app, ui, main);
|
|
|
|
|
} else if app.ui.home_templates {
|
|
|
|
|
super::templates::home_tab(app, ui, main);
|
|
|
|
|
} else {
|
|
|
|
|
home_page(app, ui, &p, main, &ctx, &mut actions);
|
|
|
|
|
}
|
|
|
|
|
run_actions(app, &ctx, actions);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- left: brand + start actions.
|
|
|
|
|
let x0 = rect.min.x + pad;
|
|
|
|
|
let mut y = rect.min.y + pad;
|
|
|
|
|
crate::header::paint_logo(&p, Rect::from_min_size(pos2(x0, y), vec2(40.0, 40.0)));
|
|
|
|
|
p.text(pos2(x0 + 52.0, y + 12.0), Align2::LEFT_CENTER, "EffectCraft", Tokens::semibold(22.0), Color32::WHITE);
|
|
|
|
|
p.text(pos2(x0 + 52.0, y + 32.0), Align2::LEFT_CENTER, format!("Version {}", env!("CARGO_PKG_VERSION")), Tokens::ui(11.0), t.text_faint);
|
|
|
|
|
y += 56.0;
|
|
|
|
|
// Home / Templates / Learn tabs.
|
|
|
|
|
let tab = if app.ui.home_learn {
|
|
|
|
|
/// The left rail: New / Open Project, the pages, and the community links at its foot.
|
|
|
|
|
fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: Rect, actions: &mut Vec<(&'static str, serde_json::Value)>) {
|
|
|
|
|
let t = app.tokens;
|
|
|
|
|
let x0 = rail.min.x + 16.0;
|
|
|
|
|
let w = rail.width() - 32.0;
|
|
|
|
|
let mut y = rail.min.y + 20.0;
|
|
|
|
|
for (label, id, primary) in [("New Project", "file.newProject", true), ("Open Project…", "file.open", false)] {
|
|
|
|
|
let r = Rect::from_min_size(pos2(x0, y), vec2(w, 34.0));
|
|
|
|
|
if widgets::text_button(ui, r, label, primary, &t, egui::Id::new(("home", id))).clicked() {
|
|
|
|
|
actions.push((id, json!({})));
|
|
|
|
|
}
|
|
|
|
|
app.auto.add(&format!("home.{id}"), r, label);
|
|
|
|
|
y += 42.0;
|
|
|
|
|
}
|
|
|
|
|
y += 10.0;
|
|
|
|
|
// Pages.
|
|
|
|
|
let page = if app.ui.home_learn {
|
|
|
|
|
2
|
|
|
|
|
} else if app.ui.home_templates {
|
|
|
|
|
1
|
|
|
|
|
} else {
|
|
|
|
|
0
|
|
|
|
|
};
|
|
|
|
|
for (k, label) in ["Home", "Templates", "Learn"].into_iter().enumerate() {
|
|
|
|
|
let r = Rect::from_min_size(pos2(x0 + k as f32 * 84.0, y), vec2(78.0, 28.0));
|
|
|
|
|
for (k, (label, icon)) in [("Home", Icon::Home), ("Templates", Icon::Grid), ("Learn", Icon::Sparkle)].into_iter().enumerate() {
|
|
|
|
|
let r = Rect::from_min_size(pos2(rail.min.x + 8.0, y), vec2(rail.width() - 16.0, 34.0));
|
|
|
|
|
let resp = ui.interact(r, egui::Id::new(("home-tab", label)), Sense::click());
|
|
|
|
|
let on = tab == k;
|
|
|
|
|
let on = page == k;
|
|
|
|
|
if on || resp.hovered() {
|
|
|
|
|
p.rect_filled(r, 6.0, if on { Color32::from_rgb(0x2e, 0x31, 0x3b) } else { t.hover });
|
|
|
|
|
}
|
|
|
|
|
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::medium(13.0), if on { Color32::WHITE } else { t.text_dim });
|
|
|
|
|
if on {
|
|
|
|
|
p.line_segment([pos2(r.min.x + 12.0, r.max.y - 1.0), pos2(r.max.x - 12.0, r.max.y - 1.0)], Stroke::new(2.0, t.accent));
|
|
|
|
|
p.rect_filled(r, 6.0, if on { t.row_selected } else { t.hover });
|
|
|
|
|
}
|
|
|
|
|
let col = if on { t.tab_text_active } else { t.text_dim };
|
|
|
|
|
icons::paint(p, Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(16.0, 16.0)), icon, col);
|
|
|
|
|
p.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(13.0), col);
|
|
|
|
|
app.auto.add(&format!("home.tab.{}", label.to_ascii_lowercase()), r, label);
|
|
|
|
|
if resp.clicked() {
|
|
|
|
|
app.ui.home_learn = k == 2;
|
|
|
|
|
app.ui.home_templates = k == 1;
|
|
|
|
|
}
|
|
|
|
|
y += 38.0;
|
|
|
|
|
}
|
|
|
|
|
y += 40.0;
|
|
|
|
|
for (label, id, primary) in [
|
|
|
|
|
("New Project", "file.newProject", true),
|
|
|
|
|
("Open Project…", "file.open", false),
|
|
|
|
|
("New Composition", "app.newComp", false),
|
|
|
|
|
("Open Demo Project", "file.openDemoProject", false),
|
|
|
|
|
("Import Footage…", "file.import", false),
|
|
|
|
|
] {
|
|
|
|
|
let r = Rect::from_min_size(pos2(x0, y), vec2(left_w.min(250.0), 32.0));
|
|
|
|
|
if widgets::text_button(ui, r, label, primary, &t, egui::Id::new(("home", id))).clicked() {
|
|
|
|
|
actions.push((id, json!({})));
|
|
|
|
|
}
|
|
|
|
|
app.auto.add(&format!("home.{id}"), r, label);
|
|
|
|
|
y += 40.0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- community (After Effects' Learn / What's New area).
|
|
|
|
|
let (cx, mut cy, cw) = if wide { (x0, y + 18.0, left_w) } else { (x0, y + 10.0, left_w) };
|
|
|
|
|
p.text(pos2(cx, cy), Align2::LEFT_CENTER, "Community", Tokens::semibold(13.0), t.text);
|
|
|
|
|
cy += 16.0;
|
|
|
|
|
p.text(pos2(cx, cy), Align2::LEFT_CENTER, "Tutorials, help and what's new", Tokens::ui(11.0), t.text_faint);
|
|
|
|
|
cy += 16.0;
|
|
|
|
|
// Community links at the foot (below the pages when the window is short).
|
|
|
|
|
let links = [
|
|
|
|
|
(Icon::Chat, "Join the ArtCraft Discord", "help.discord"),
|
|
|
|
|
(Icon::Globe, "getartcraft.com", "help.website"),
|
|
|
|
|
(Icon::Globe, "EffectCraft home page", "help.appPage"),
|
|
|
|
|
(Icon::Code, "EffectCraft on GitHub", "help.github"),
|
|
|
|
|
];
|
|
|
|
|
let sib = effectcraft_engine::links::SIBLINGS;
|
|
|
|
|
let sib_rows = sib.len().div_ceil(2) as f32;
|
|
|
|
|
let foot_h = 22.0 + links.len() as f32 * 30.0 + 22.0 + sib_rows * 26.0;
|
|
|
|
|
let mut cy = (rail.max.y - 16.0 - foot_h).max(y + 16.0);
|
|
|
|
|
p.line_segment([pos2(x0, cy - 10.0), pos2(x0 + w, cy - 10.0)], Stroke::new(1.0, t.separator));
|
|
|
|
|
p.text(pos2(x0, cy + 6.0), Align2::LEFT_CENTER, "Community", Tokens::semibold(12.0), t.text_dim);
|
|
|
|
|
cy += 22.0;
|
|
|
|
|
for (icon, label, cmd) in links {
|
|
|
|
|
let r = Rect::from_min_size(pos2(cx, cy), vec2(cw.min(250.0), 30.0));
|
|
|
|
|
let r = Rect::from_min_size(pos2(x0, cy), vec2(w, 26.0));
|
|
|
|
|
let resp = ui.interact(r, egui::Id::new(("home-link", cmd)), Sense::click());
|
|
|
|
|
let discord = cmd == "help.discord";
|
|
|
|
|
let bg = if discord {
|
|
|
|
|
Color32::from_rgb(0x58, 0x65, 0xf2)
|
|
|
|
|
if discord {
|
|
|
|
|
p.rect_filled(r, 13.0, Color32::from_rgb(0x58, 0x65, 0xf2));
|
|
|
|
|
} else if resp.hovered() {
|
|
|
|
|
t.hover
|
|
|
|
|
} else {
|
|
|
|
|
Color32::from_rgb(0x2a, 0x2c, 0x34)
|
|
|
|
|
};
|
|
|
|
|
p.rect_filled(r, 15.0, bg);
|
|
|
|
|
icons::paint(&p, Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(14.0, 14.0)), icon, Color32::WHITE);
|
|
|
|
|
p.text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.0), Color32::WHITE);
|
|
|
|
|
p.rect_filled(r, 13.0, t.hover);
|
|
|
|
|
}
|
|
|
|
|
let col = if discord { Color32::WHITE } else { t.text };
|
|
|
|
|
icons::paint(p, Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(13.0, 13.0)), icon, col);
|
|
|
|
|
p.text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.0), col);
|
|
|
|
|
app.auto.add(&format!("home.{cmd}"), r, label);
|
|
|
|
|
if resp.clicked() {
|
|
|
|
|
let _ = app.session.execute(cmd, json!({}));
|
|
|
|
|
}
|
|
|
|
|
cy += 36.0;
|
|
|
|
|
cy += 30.0;
|
|
|
|
|
}
|
|
|
|
|
cy += 8.0;
|
|
|
|
|
p.text(pos2(cx, cy), Align2::LEFT_CENTER, "More ArtCraft apps", Tokens::ui(11.0), t.text_faint);
|
|
|
|
|
cy += 12.0;
|
|
|
|
|
let sib = effectcraft_engine::links::SIBLINGS;
|
|
|
|
|
let per_row = 2usize;
|
|
|
|
|
let sw = (cw.min(250.0) - 6.0) / per_row as f32;
|
|
|
|
|
p.text(pos2(x0, cy + 8.0), Align2::LEFT_CENTER, "More ArtCraft apps", Tokens::ui(11.0), t.text_faint);
|
|
|
|
|
cy += 20.0;
|
|
|
|
|
let sw = (w - 6.0) / 2.0;
|
|
|
|
|
for (i, (name, slug)) in sib.iter().enumerate() {
|
|
|
|
|
let r = Rect::from_min_size(pos2(cx + (i % per_row) as f32 * (sw + 6.0), cy + (i / per_row) as f32 * 28.0), vec2(sw, 24.0));
|
|
|
|
|
let r = Rect::from_min_size(pos2(x0 + (i % 2) as f32 * (sw + 6.0), cy + (i / 2) as f32 * 26.0), vec2(sw, 22.0));
|
|
|
|
|
let resp = ui.interact(r, egui::Id::new(("sib", *slug)), Sense::click());
|
|
|
|
|
p.rect_filled(r, 12.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x24, 0x26, 0x2e) });
|
|
|
|
|
p.text(r.center(), Align2::CENTER_CENTER, *name, Tokens::ui(11.5), t.text);
|
|
|
|
|
p.rect_filled(r, 11.0, if resp.hovered() { t.hover } else { t.field_bg });
|
|
|
|
|
p.text(r.center(), Align2::CENTER_CENTER, *name, Tokens::ui(11.0), t.text);
|
|
|
|
|
app.auto.add(&format!("home.sibling.{slug}"), r, name);
|
|
|
|
|
if resp.clicked() {
|
|
|
|
|
let _ = app.session.execute("help.sibling", json!({"app": slug}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let community_bottom = cy + sib.len().div_ceil(per_row) as f32 * 28.0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- recent projects (or the Learn tab's tutorials).
|
|
|
|
|
let (rx, mut ry, rw) = if wide {
|
|
|
|
|
let rx = x0 + left_w + 40.0;
|
|
|
|
|
(rx, rect.min.y + pad + 6.0, rect.max.x - pad - rx)
|
|
|
|
|
} else {
|
|
|
|
|
(x0, community_bottom + 24.0, left_w)
|
|
|
|
|
};
|
|
|
|
|
if app.ui.home_learn {
|
|
|
|
|
super::learn::home_tab(app, ui, Rect::from_min_max(pos2(rx, ry), pos2(rx + rw, rect.max.y - 8.0)));
|
|
|
|
|
run_actions(app, &ctx, actions);
|
|
|
|
|
return;
|
|
|
|
|
/// The Home page: the welcome, quick-start tiles and the recent projects table.
|
|
|
|
|
fn home_page(
|
|
|
|
|
app: &mut EffectcraftApp,
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
p: &egui::Painter,
|
|
|
|
|
area: Rect,
|
|
|
|
|
ctx: &egui::Context,
|
|
|
|
|
actions: &mut Vec<(&'static str, serde_json::Value)>,
|
|
|
|
|
) {
|
|
|
|
|
let t = app.tokens;
|
|
|
|
|
let (x0, w) = (area.min.x, area.width());
|
|
|
|
|
let mut y = area.min.y;
|
|
|
|
|
p.text(pos2(x0, y + 14.0), Align2::LEFT_CENTER, "Welcome to EffectCraft", Tokens::semibold(26.0), t.tab_text_active);
|
|
|
|
|
y += 40.0;
|
|
|
|
|
p.text(
|
|
|
|
|
pos2(x0, y + 8.0),
|
|
|
|
|
Align2::LEFT_CENTER,
|
|
|
|
|
format!("Motion graphics and visual effects · version {}", env!("CARGO_PKG_VERSION")),
|
|
|
|
|
Tokens::ui(12.5),
|
|
|
|
|
t.text_dim,
|
|
|
|
|
);
|
|
|
|
|
y += 36.0;
|
|
|
|
|
// Quick start.
|
|
|
|
|
let tiles = [
|
|
|
|
|
("New Composition", "app.newComp", Icon::NewComp),
|
|
|
|
|
("Open Demo Project", "file.openDemoProject", Icon::Play),
|
|
|
|
|
("Import Footage…", "file.import", Icon::Footage),
|
|
|
|
|
("New from Template", "templates", Icon::Grid),
|
|
|
|
|
];
|
|
|
|
|
let gap = 12.0;
|
|
|
|
|
let tw = ((w - gap * 3.0) / 4.0).clamp(120.0, 220.0);
|
|
|
|
|
for (k, (label, id, icon)) in tiles.into_iter().enumerate() {
|
|
|
|
|
let r = Rect::from_min_size(pos2(x0 + k as f32 * (tw + gap), y), vec2(tw, 64.0));
|
|
|
|
|
let resp = ui.interact(r, egui::Id::new(("home-tile", id)), Sense::click());
|
|
|
|
|
p.rect_filled(r, 8.0, if resp.hovered() { t.hover } else { t.panel_bg });
|
|
|
|
|
p.rect_stroke(r, 8.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.separator }), StrokeKind::Inside);
|
|
|
|
|
let ir = Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(32.0, 32.0));
|
|
|
|
|
p.rect_filled(ir, 7.0, t.accent.gamma_multiply(0.18));
|
|
|
|
|
icons::paint(p, ir.shrink(8.0), icon, t.accent);
|
|
|
|
|
p.with_clip_rect(r.shrink(2.0)).text(pos2(ir.max.x + 12.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), t.text);
|
|
|
|
|
app.auto.add(&format!("home.{id}"), r, label);
|
|
|
|
|
if resp.clicked() {
|
|
|
|
|
if id == "templates" {
|
|
|
|
|
app.ui.home_templates = true;
|
|
|
|
|
app.ui.home_learn = false;
|
|
|
|
|
} else {
|
|
|
|
|
actions.push((id, json!({})));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if app.ui.home_templates {
|
|
|
|
|
super::templates::home_tab(app, ui, Rect::from_min_max(pos2(rx, ry), pos2(rx + rw, rect.max.y - 8.0)));
|
|
|
|
|
run_actions(app, &ctx, actions);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
p.text(pos2(rx, ry + 6.0), Align2::LEFT_CENTER, "Recent", Tokens::semibold(16.0), Color32::WHITE);
|
|
|
|
|
y += 64.0 + 36.0;
|
|
|
|
|
|
|
|
|
|
// Recent: heading, filter, clear.
|
|
|
|
|
p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, "Recent", Tokens::semibold(17.0), t.tab_text_active);
|
|
|
|
|
let entries = recent_entries(&app.session.prefs);
|
|
|
|
|
let fid = egui::Id::new("home-filter");
|
|
|
|
|
let mut filter: String = ctx.data(|d| d.get_temp(fid)).unwrap_or_default();
|
|
|
|
|
let fr = Rect::from_min_size(pos2(x0 + w - 240.0, y), vec2(240.0, 26.0));
|
|
|
|
|
widgets::search_field(ui, fr, &mut filter, "Filter recent files", &t);
|
|
|
|
|
app.auto.add("home.filter", fr, "Filter recent files");
|
|
|
|
|
ctx.data_mut(|d| d.insert_temp(fid, filter.clone()));
|
|
|
|
|
if !entries.is_empty() {
|
|
|
|
|
let cr = Rect::from_min_size(pos2(rx + rw - 100.0, ry - 4.0), vec2(100.0, 20.0));
|
|
|
|
|
let cr = Rect::from_min_size(pos2(fr.min.x - 96.0, y + 3.0), vec2(84.0, 20.0));
|
|
|
|
|
let resp = ui.interact(cr, egui::Id::new("home-clear-recent"), Sense::click());
|
|
|
|
|
p.text(cr.right_center(), Align2::RIGHT_CENTER, "Clear list", Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_faint });
|
|
|
|
|
p.text(cr.right_center(), Align2::RIGHT_CENTER, "Clear list", Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint });
|
|
|
|
|
app.auto.add("home.clearRecent", cr, "Clear Recent Projects");
|
|
|
|
|
if resp.clicked() {
|
|
|
|
|
actions.push(("file.clearRecent", json!({})));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ry += 26.0;
|
|
|
|
|
// Column headings.
|
|
|
|
|
let date_w = 130.0;
|
|
|
|
|
p.text(pos2(rx + 112.0, ry), Align2::LEFT_CENTER, "Name", Tokens::ui(11.0), t.text_faint);
|
|
|
|
|
p.text(pos2(rx + rw - date_w, ry), Align2::LEFT_CENTER, "Modified", Tokens::ui(11.0), t.text_faint);
|
|
|
|
|
ry += 10.0;
|
|
|
|
|
p.line_segment([pos2(rx, ry), pos2(rx + rw, ry)], Stroke::new(1.0, t.separator));
|
|
|
|
|
ry += 6.0;
|
|
|
|
|
if entries.is_empty() {
|
|
|
|
|
p.text(pos2(rx, ry + 20.0), Align2::LEFT_CENTER, "No recent projects. Projects you open or save appear here.", Tokens::ui(12.0), t.text_faint);
|
|
|
|
|
y += 42.0;
|
|
|
|
|
// Column headings (Name / Opened / Size / Kind, as in After Effects).
|
|
|
|
|
let (opened_x, size_x, kind_x) = (x0 + w * 0.52, x0 + w * 0.68, x0 + w * 0.80);
|
|
|
|
|
for (label, x) in [("NAME", x0 + 76.0), ("OPENED", opened_x), ("SIZE", size_x), ("KIND", kind_x)] {
|
|
|
|
|
p.text(pos2(x, y), Align2::LEFT_CENTER, label, Tokens::medium(10.5), t.text_faint);
|
|
|
|
|
}
|
|
|
|
|
let row_h = 66.0;
|
|
|
|
|
for e in &entries {
|
|
|
|
|
let r = Rect::from_min_size(pos2(rx, ry), vec2(rw, row_h - 4.0));
|
|
|
|
|
if r.min.y > rect.max.y {
|
|
|
|
|
y += 12.0;
|
|
|
|
|
p.line_segment([pos2(x0, y), pos2(x0 + w, y)], Stroke::new(1.0, t.separator));
|
|
|
|
|
y += 4.0;
|
|
|
|
|
let q = filter.trim().to_lowercase();
|
|
|
|
|
let shown: Vec<&RecentEntry> =
|
|
|
|
|
entries.iter().filter(|e| q.is_empty() || e.name.to_lowercase().contains(&q) || e.folder.to_lowercase().contains(&q)).collect();
|
|
|
|
|
if shown.is_empty() {
|
|
|
|
|
let c = pos2(area.center().x, y + 90.0);
|
|
|
|
|
icons::paint(p, Rect::from_center_size(c, vec2(40.0, 40.0)), Icon::Folder, t.text_faint);
|
|
|
|
|
let (head, sub) = if entries.is_empty() {
|
|
|
|
|
("Your recent files will show here", "Create a new project, or open the demo project to look around.")
|
|
|
|
|
} else {
|
|
|
|
|
("No recent files match the filter", "Try another name or folder.")
|
|
|
|
|
};
|
|
|
|
|
p.text(c + vec2(0.0, 40.0), Align2::CENTER_CENTER, head, Tokens::semibold(14.0), t.text);
|
|
|
|
|
p.text(c + vec2(0.0, 62.0), Align2::CENTER_CENTER, sub, Tokens::ui(12.0), t.text_faint);
|
|
|
|
|
}
|
|
|
|
|
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
|
|
|
|
|
let row_h = 48.0;
|
|
|
|
|
for e in shown {
|
|
|
|
|
let r = Rect::from_min_size(pos2(x0, y), vec2(w, row_h - 2.0));
|
|
|
|
|
if r.min.y > area.max.y {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
let resp = ui.interact(r, egui::Id::new(("home-recent", e.index)), Sense::click()).on_hover_text(&e.path);
|
|
|
|
|
if resp.hovered() {
|
|
|
|
|
p.rect_filled(r, 6.0, t.hover);
|
|
|
|
|
}
|
|
|
|
|
let tr = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(THUMB_W as f32, THUMB_H as f32));
|
|
|
|
|
match thumb_texture(app, &ctx, &e.path) {
|
|
|
|
|
let tr = Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - 18.0), vec2(64.0, 36.0));
|
|
|
|
|
match thumb_texture(app, ctx, &e.path) {
|
|
|
|
|
Some(tex) => {
|
|
|
|
|
p.image(tex.id(), tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
p.rect_filled(tr, 3.0, Color32::from_rgb(0x24, 0x26, 0x2e));
|
|
|
|
|
icons::paint(&p, Rect::from_center_size(tr.center(), vec2(20.0, 20.0)), Icon::Comp, t.text_faint);
|
|
|
|
|
p.rect_filled(tr, 3.0, t.field_bg);
|
|
|
|
|
icons::paint(p, Rect::from_center_size(tr.center(), vec2(16.0, 16.0)), Icon::Comp, t.text_faint);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
p.rect_stroke(tr, 3.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
|
|
|
|
let tx = tr.max.x + 12.0;
|
|
|
|
|
let text_clip = p.with_clip_rect(Rect::from_min_max(pos2(tx, r.min.y), pos2(r.max.x - date_w - 8.0, r.max.y)));
|
|
|
|
|
text_clip.text(pos2(tx, r.min.y + 20.0), Align2::LEFT_CENTER, &e.name, Tokens::semibold(13.0), if e.exists { Color32::WHITE } else { t.text_dim });
|
|
|
|
|
let name_clip = p.with_clip_rect(Rect::from_min_max(pos2(tr.max.x + 6.0, r.min.y), pos2(opened_x - 12.0, r.max.y)));
|
|
|
|
|
name_clip.text(
|
|
|
|
|
pos2(tr.max.x + 6.0, r.center().y - 8.0),
|
|
|
|
|
Align2::LEFT_CENTER,
|
|
|
|
|
&e.name,
|
|
|
|
|
Tokens::medium(13.0),
|
|
|
|
|
if e.exists { t.tab_text_active } else { t.text_dim },
|
|
|
|
|
);
|
|
|
|
|
let sub = if e.exists { e.folder.clone() } else { format!("{} (missing)", e.folder) };
|
|
|
|
|
text_clip.text(pos2(tx, r.min.y + 40.0), Align2::LEFT_CENTER, sub, Tokens::ui(11.0), t.text_faint);
|
|
|
|
|
if let Some(d) = &e.modified {
|
|
|
|
|
p.text(pos2(r.max.x - date_w, r.min.y + 20.0), Align2::LEFT_CENTER, d, Tokens::ui(11.0), t.text_dim);
|
|
|
|
|
}
|
|
|
|
|
name_clip.text(pos2(tr.max.x + 6.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub, Tokens::ui(11.0), t.text_faint);
|
|
|
|
|
let cell = |x: f32, x1: f32, text: String| {
|
|
|
|
|
p.with_clip_rect(Rect::from_min_max(pos2(x, r.min.y), pos2(x1 - 8.0, r.max.y))).text(
|
|
|
|
|
pos2(x, r.center().y),
|
|
|
|
|
Align2::LEFT_CENTER,
|
|
|
|
|
text,
|
|
|
|
|
Tokens::ui(12.0),
|
|
|
|
|
t.text_dim,
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
cell(opened_x, size_x, e.modified_secs.map(|m| format_ago(now, m)).unwrap_or_else(|| "—".into()));
|
|
|
|
|
cell(size_x, kind_x, e.size.map(format_size).unwrap_or_else(|| "—".into()));
|
|
|
|
|
cell(kind_x, r.max.x, kind_of(&e.path).to_string());
|
|
|
|
|
app.auto.add(&format!("home.recent.{}", e.index), r, &e.path);
|
|
|
|
|
if resp.clicked() {
|
|
|
|
|
actions.push(("file.openRecent", json!({"index": e.index})));
|
|
|
|
|
}
|
|
|
|
|
ry += row_h;
|
|
|
|
|
y += row_h;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
run_actions(app, &ctx, actions);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, serde_json::Value)>) {
|
|
|
|
|
@ -403,6 +520,20 @@ mod tests {
|
|
|
|
|
assert_ne!(thumb_name("/a.ecproj"), thumb_name("/b.ecproj"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn opened_and_size_columns() {
|
|
|
|
|
let now = 1_790_000_000;
|
|
|
|
|
assert_eq!(format_ago(now, now - 5), "Just now");
|
|
|
|
|
assert_eq!(format_ago(now, now - 60), "1 minute ago");
|
|
|
|
|
assert_eq!(format_ago(now, now - 3 * 3_600), "3 hours ago");
|
|
|
|
|
assert_eq!(format_ago(now, now - 90_000), "Yesterday");
|
|
|
|
|
assert_eq!(format_ago(now, now - 3 * 86_400), "3 days ago");
|
|
|
|
|
assert_eq!(format_ago(now, now - 30 * 86_400), "2026-08-22");
|
|
|
|
|
assert_eq!(format_ago(now, now + 100), "Just now");
|
|
|
|
|
assert_eq!((format_size(900), format_size(12_800), format_size(3_500_000)), ("900 B".into(), "13 KB".into(), "3.3 MB".into()));
|
|
|
|
|
assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn home_screen_registers_its_controls() {
|
|
|
|
|
let mut s = effectcraft_engine::Session::default();
|
|
|
|
|
@ -425,6 +556,9 @@ mod tests {
|
|
|
|
|
"home.help.discord",
|
|
|
|
|
"home.help.github",
|
|
|
|
|
"home.recent.0",
|
|
|
|
|
"home.filter",
|
|
|
|
|
"home.file.import",
|
|
|
|
|
"home.templates",
|
|
|
|
|
"home.tab.home",
|
|
|
|
|
"home.tab.templates",
|
|
|
|
|
"home.tab.learn",
|
|
|
|
|
|