Merge pull request #51 from storytold/home-screen-ae

M13.30–M13.31: ArtCraft logo in the Tools bar; Home screen laid out like After Effects'
This commit is contained in:
BFlat authored and GitHub committed 2026-10-05 12:52:18 +07:00
commit fc0462a855
10 files changed
+295 -148

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Other ArtCraft Apps",
[],
None,
"{app: photocraft|drawcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
"{app: photocraft|vectorcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
always,
sibling
),
+1 -1
View File
@@ -12,7 +12,7 @@ pub const ISSUES: &str = "https://github.com/storytold/effectcraft/issues";
/// Sibling apps of the ArtCraft family: (name, what it is, GitHub repo, app page).
pub const SIBLINGS: &[(&str, &str)] = &[
("PhotoCraft", "photocraft"),
("DrawCraft", "drawcraft"),
("VectorCraft", "vectorcraft"),
("FilmCraft", "filmcraft"),
("LightCraft", "lightcraft"),
("PrintCraft", "printcraft"),
+1 -1
View File
@@ -1,7 +1,7 @@
//! Curve-preserving boolean operations on filled [`BezPath`]s, built on `linesweeper`'s robust
//! sweep-line topology (MIT OR Apache-2.0).
//!
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code,
//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code,
//! MIT OR Apache-2.0), reworked to take and return kurbo paths directly.
//!
//! Inputs are closed implicitly (a filled open path is painted as if closed). The sweep splits
+1 -1
View File
@@ -1,4 +1,4 @@
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code,
//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code,
//! MIT OR Apache-2.0).
//!
//! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end
+1 -1
View File
@@ -2,7 +2,7 @@
//! offsetting (kurbo's stroker offsets curves analytically and refits them) and robust overlap
//! removal (the boolean sweep).
//!
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/offset.rs`; our own code,
//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/offset.rs`; our own code,
//! MIT OR Apache-2.0).
use kurbo::{BezPath, PathEl, Stroke, StrokeOpts};
+7
View File
@@ -160,6 +160,13 @@ impl EffectcraftApp {
pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) {
let t = self.tokens;
let ctx = ui.ctx().clone();
if self.ui.start_screen {
// Home covers the whole workspace, as in After Effects (the panels wait behind it).
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("home"));
child.set_clip_rect(body.intersect(ui.clip_rect()));
panels::home::show(self, &mut child, body);
return;
}
// The maximized panel fills the area; the tree is kept as it is.
let maximized = self.ui.maximized.filter(|m| self.ui.dock.contains(*m));
let mut dock = match maximized {
+20 -16
View File
@@ -293,21 +293,25 @@ pub fn color_popup(ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f3
changed
}
/// The EffectCraft mark: a rounded square with a gradient-like split and a keyframe diamond.
/// The ArtCraft mark as supplied in `docs/brand` (the README's and getartcraft.com's logo; used
/// unmodified, see `docs/brand/LICENSE-brand.txt`).
static ARTCRAFT_MARK: &[u8] = include_bytes!("../../../docs/brand/artcraft-mark.png");
/// The ArtCraft mark in `r` (decoded once into a mipmapped texture, so it stays crisp from the
/// 24 px Tools bar to the About dialog). Nothing is drawn if it can't be decoded.
pub fn paint_logo(p: &egui::Painter, r: Rect) {
p.rect_filled(r, 6.0, Color32::from_rgb(0x1d, 0x2b, 0x5e));
let inner = r.shrink(2.0);
p.rect_filled(
Rect::from_min_max(inner.min, pos2(inner.center().x, inner.max.y)),
egui::CornerRadius { nw: 5, sw: 5, ne: 0, se: 0 },
Color32::from_rgb(0x2d, 0x8c, 0xeb),
);
p.rect_filled(
Rect::from_min_max(pos2(inner.center().x, inner.min.y), inner.max),
egui::CornerRadius { nw: 0, sw: 0, ne: 5, se: 5 },
Color32::from_rgb(0x8e, 0x6b, 0xff),
);
let c = r.center();
let s = r.width() * 0.26;
p.add(egui::Shape::convex_polygon(vec![c + vec2(0.0, -s), c + vec2(s, 0.0), c + vec2(0.0, s), c + vec2(-s, 0.0)], Color32::WHITE, Stroke::NONE));
let ctx = p.ctx();
let id = egui::Id::new("artcraft-mark");
let tex = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)).unwrap_or_else(|| {
let tex = image::load_from_memory_with_format(ARTCRAFT_MARK, image::ImageFormat::Png).ok().map(|img| {
let img = img.to_rgba8();
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw());
ctx.load_texture("artcraft-mark", ci, egui::TextureOptions::LINEAR.with_mipmap_mode(Some(egui::TextureFilter::Linear)))
});
ctx.data_mut(|d| d.insert_temp(id, tex.clone()));
tex
});
if let Some(t) = tex {
p.image(t.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
+254 -120
View File
@@ -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",
+1 -7
View File
@@ -82,13 +82,7 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String {
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) {
match p {
PanelKind::Composition => {
if app.ui.start_screen {
home::show(app, ui, rect);
} else {
viewer::show(app, ui, rect)
}
}
PanelKind::Composition => viewer::show(app, ui, rect),
PanelKind::Timeline => timeline::show(app, ui, rect),
PanelKind::Project => project::show(app, ui, rect),
PanelKind::EffectControls => effect_controls::show(app, ui, rect),
+8
View File
@@ -422,6 +422,14 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra
spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph
Editor keys are small squares whatever their interpolation, as in After Effects.
Since (M13.30–M13.31): the Tools bar and About dialog carry the ArtCraft mark. The
Home screen is laid out like After Effects' and covers the whole workspace. A left rail holds New
Project / Open Project, the Home, Templates and Learn pages and, at its foot, the community
links. The Home page has a "Welcome to EffectCraft" heading, quick-start tiles (New Composition,
Open Demo Project, Import Footage, New from Template) and the recent projects as a filterable
table with thumbnails and Name / Opened ("3 hours ago") / Size / Kind columns. New automation ids:
`home.filter`, `home.templates`, `home.file.import`.
Since (M4.13): motion blur follows a collapsed precomp layer's own motion. Its nested layers are
drawn with the precomp layer's transform at every sub-sample, and with the containing comp's
shutter, as they are drawn into its frames. Shape and text layers whose content animates within