effectcraft/crates/ui-egui/src/panels/home.rs

455 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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, and where
//! After Effects has its Learn / What's New area, the ArtCraft community links.
//!
//! 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.
use std::hash::{Hash, Hasher};
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde::Serialize;
use serde_json::json;
use crate::icons::{self, Icon};
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
pub const THUMB_W: usize = 96;
pub const THUMB_H: usize = 54;
/// One row of the recent projects list.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct RecentEntry {
pub index: usize,
pub path: String,
pub name: String,
pub folder: String,
/// Last modified (`2026-10-02 14:05`, UTC), when the file is readable.
pub modified: Option<String>,
pub exists: bool,
}
/// The recent projects, most recent first (Settings ▸ General ▸ recent items).
pub fn recent_entries(prefs: &effectcraft_engine::prefs::Prefs) -> Vec<RecentEntry> {
prefs
.recent_projects
.iter()
.enumerate()
.map(|(index, p)| {
let path = std::path::Path::new(p);
let meta = std::fs::metadata(path).ok();
let modified = 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));
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,
exists: meta.is_some(),
}
})
.collect()
}
/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds.
pub fn format_utc(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem % 3600) / 60)
}
/// 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();
path.hash(&mut h);
format!("thumb-{:016x}.txt", h.finish())
}
/// Downscale an image to a letterboxed THUMB_W×THUMB_H RGB thumbnail (hex text).
pub fn encode_thumb(img: &egui::ColorImage) -> String {
let [w, h] = img.size;
let mut out = String::with_capacity(THUMB_W * THUMB_H * 6);
let k = (THUMB_W as f32 / w.max(1) as f32).min(THUMB_H as f32 / h.max(1) as f32);
let (dw, dh) = (w as f32 * k, h as f32 * k);
let (ox, oy) = ((THUMB_W as f32 - dw) / 2.0, (THUMB_H as f32 - dh) / 2.0);
for y in 0..THUMB_H {
for x in 0..THUMB_W {
let (fx, fy) = ((x as f32 + 0.5 - ox) / k, (y as f32 + 0.5 - oy) / k);
let c = if fx >= 0.0 && fy >= 0.0 && (fx as usize) < w && (fy as usize) < h {
let p = img.pixels[fy as usize * w + fx as usize];
[p.r(), p.g(), p.b()]
} else {
[0x16, 0x17, 0x1c]
};
for v in c {
out.push_str(&format!("{v:02x}"));
}
}
}
out
}
pub fn decode_thumb(text: &str) -> Option<egui::ColorImage> {
let t = text.trim();
if t.len() != THUMB_W * THUMB_H * 6 {
return None;
}
let b: Vec<u8> = (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16)).collect::<Result<_, _>>().ok()?;
let px = b.chunks_exact(3).map(|c| Color32::from_rgb(c[0], c[1], c[2])).collect();
Some(egui::ColorImage::new([THUMB_W, THUMB_H], px))
}
/// Store the viewer's frame as the open project's thumbnail after it is opened or saved (once per
/// saved revision, when the viewer shows that revision).
pub fn capture_thumbnail(app: &mut EffectcraftApp) {
let s = &app.session;
let (Some(path), Some(_)) = (s.path.clone(), s.config.as_ref()) else { return };
if s.is_dirty() {
return;
}
let key = (path.clone(), s.saved_revision);
if app.home_thumb_saved.as_ref() == Some(&key) {
return;
}
if app.viewer_shown.as_ref().map(|(_, k)| k.revision) != Some(s.revision) {
return;
}
let Some(img) = app.viewer_pixels() else { return };
let text = encode_thumb(&img);
if let Some(c) = &app.session.config
&& let Err(e) = c.write(&thumb_name(&path), &text)
{
log::warn!("project thumbnail: {e}");
}
app.home_thumbs.remove(&path);
app.home_thumb_saved = Some(key);
}
fn thumb_texture(app: &mut EffectcraftApp, ctx: &egui::Context, path: &str) -> Option<egui::TextureHandle> {
if let Some(t) = app.home_thumbs.get(path) {
return t.clone();
}
let tex = app
.session
.config
.as_ref()
.and_then(|c| c.read(&thumb_name(path)))
.and_then(|t| decode_thumb(&t))
.map(|img| ctx.load_texture(format!("home-thumb-{path}"), img, egui::TextureOptions::LINEAR));
app.home_thumbs.insert(path.to_string(), tex.clone());
tex
}
/// Draw the Home screen.
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 };
let mut actions: Vec<(&str, serde_json::Value)> = vec![];
// ---- 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 / Learn tabs.
for (k, (label, learn)) in [("Home", false), ("Learn", true)].into_iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + k as f32 * 84.0, y), vec2(78.0, 28.0));
let resp = ui.interact(r, egui::Id::new(("home-tab", label)), Sense::click());
let on = app.ui.home_learn == learn;
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));
}
app.auto.add(&format!("home.tab.{}", label.to_ascii_lowercase()), r, label);
if resp.clicked() {
app.ui.home_learn = learn;
}
}
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;
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"),
];
for (icon, label, cmd) in links {
let r = Rect::from_min_size(pos2(cx, cy), vec2(cw.min(250.0), 30.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)
} 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);
app.auto.add(&format!("home.{cmd}"), r, label);
if resp.clicked() {
let _ = app.session.execute(cmd, json!({}));
}
cy += 36.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;
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 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);
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;
}
p.text(pos2(rx, ry + 6.0), Align2::LEFT_CENTER, "Recent", Tokens::semibold(16.0), Color32::WHITE);
let entries = recent_entries(&app.session.prefs);
if !entries.is_empty() {
let cr = Rect::from_min_size(pos2(rx + rw - 100.0, ry - 4.0), vec2(100.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 });
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);
}
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 {
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) {
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_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 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);
}
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;
}
run_actions(app, &ctx, actions);
}
fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, serde_json::Value)>) {
for (id, params) in actions {
let before = (app.session.path.clone(), app.session.revision);
match crate::menus::invoke(app, ctx, id, params) {
Err(e) => app.ui.status = e,
// Leave Home unless only the list changed or a file dialog was cancelled.
Ok(_) => {
let cancelled = matches!(id, "file.open" | "file.import") && (app.session.path.clone(), app.session.revision) == before;
if id != "file.clearRecent" && !cancelled {
app.ui.start_screen = false;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recent_list_comes_from_settings() {
let mut prefs = effectcraft_engine::prefs::Prefs::default();
prefs.push_recent("/projects/a/Alpha.ecproj");
prefs.push_recent("/projects/b/Beta.ecproj");
let e = recent_entries(&prefs);
assert_eq!(e.len(), 2);
assert_eq!((e[0].name.as_str(), e[0].folder.as_str(), e[0].index), ("Beta", "/projects/b", 0));
assert_eq!(e[1].name, "Alpha");
assert!(!e[0].exists && e[0].modified.is_none());
// A real file has a date.
let dir = std::env::temp_dir().join(format!("ec-home-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("Real.ecproj");
std::fs::write(&f, "{}").unwrap();
prefs.push_recent(&f.to_string_lossy());
let e = recent_entries(&prefs);
assert!(e[0].exists && e[0].modified.as_ref().is_some_and(|d| d.len() == 16), "{:?}", e[0]);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn dates_and_thumbnails() {
assert_eq!(format_utc(0), "1970-01-01 00:00");
assert_eq!(format_utc(1_790_000_000), "2026-09-21 14:13");
assert_eq!(format_utc(951_782_400), "2000-02-29 00:00");
let img = egui::ColorImage::new([200, 100], vec![Color32::from_rgb(10, 200, 30); 200 * 100]);
let text = encode_thumb(&img);
let back = decode_thumb(&text).unwrap();
assert_eq!(back.size, [THUMB_W, THUMB_H]);
// Letterboxed: the middle is the image, the top row is the background (2:1 into 16:9).
assert_eq!(back.pixels[THUMB_H / 2 * THUMB_W + THUMB_W / 2], Color32::from_rgb(10, 200, 30));
assert_ne!(back.pixels[THUMB_W / 2], Color32::from_rgb(10, 200, 30));
assert!(decode_thumb("abc").is_none());
assert_ne!(thumb_name("/a.ecproj"), thumb_name("/b.ecproj"));
}
#[test]
fn home_screen_registers_its_controls() {
let mut s = effectcraft_engine::Session::default();
s.prefs.push_recent("/projects/Alpha.ecproj");
let mut app = EffectcraftApp::new(s);
let ctx = egui::Context::default();
crate::theme::install(&ctx, &app.tokens);
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
ctx.run_ui(Default::default(), |ui| {
app.auto.begin_frame();
show(&mut app, ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)));
})
.textures_delta
.clear();
for id in [
"home.file.newProject",
"home.file.open",
"home.app.newComp",
"home.file.openDemoProject",
"home.help.discord",
"home.help.github",
"home.recent.0",
"home.tab.home",
"home.tab.learn",
] {
assert!(app.auto.find(id).is_some(), "{id}");
}
}
#[test]
fn learn_tab_lists_and_starts_tutorials() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.ui.start_screen = true;
app.ui.home_learn = true;
let ctx = egui::Context::default();
crate::theme::install(&ctx, &app.tokens);
let frame = |app: &mut EffectcraftApp| {
ctx.run_ui(Default::default(), |ui| {
app.auto.begin_frame();
show(app, ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)));
crate::panels::learn::coach(app, ui.ctx());
})
.textures_delta
.clear();
};
frame(&mut app);
frame(&mut app);
for t in effectcraft_engine::learn::tutorials() {
assert!(app.auto.find(&format!("home.learn.{}.start", t.id)).is_some(), "{}", t.id);
}
assert!(app.auto.find("home.recent.0").is_none());
// Start through the session (as the Start button does); the coach card appears.
app.session.execute("learn.start", json!({"id": "animate-title"})).unwrap();
app.ui.start_screen = false;
frame(&mut app);
frame(&mut app);
for id in ["learn.coach", "learn.showMe", "learn.next", "learn.close"] {
assert!(app.auto.find(id).is_some(), "{id}");
}
// Show me performs the step and the coach moves on.
app.session.execute("learn.step", json!({"action": "showMe"})).unwrap();
assert_eq!(app.session.learn.as_ref().unwrap().step, 1);
assert!(app.session.active_comp().is_some());
}
}