From d31094692edb3aa1303c75dd74f8fc29c82b90cb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 12:49:51 +0700 Subject: [PATCH] M13.31: Home screen laid out like After Effects' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home covers the whole workspace below the Tools bar (the panels wait behind it) instead of the Composition panel. - Left rail: New Project (primary) and Open Project, the Home / Templates / Learn pages with icons, and the community links and ArtCraft apps at its foot. - Home page: "Welcome to EffectCraft", quick-start tiles (New Composition, Open Demo Project, Import Footage, New from Template) and Recent as a table — thumbnail, name and folder, Opened (relative), Size, Kind — with a filter field, Clear list and an empty state. - Theme tokens throughout (light themes follow); web_time for "now" (works on the web). - Tests: formatters (opened / size / kind) and the new automation ids. Docs: parity. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock_ui.rs | 7 + crates/ui-egui/src/panels/home.rs | 374 ++++++++++++++++++++---------- crates/ui-egui/src/panels/mod.rs | 8 +- docs/parity.md | 8 + 4 files changed, 270 insertions(+), 127 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 565cb995..f4d6eb9f 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -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 { diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index fc07fd36..58f669e8 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -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-.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, + /// Last modified, Unix seconds. + pub modified_secs: Option, + /// File size in bytes. + pub size: Option, pub exists: bool, } @@ -41,17 +47,19 @@ pub fn recent_entries(prefs: &effectcraft_engine::prefs::Prefs) -> Vec 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", diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 46557feb..bd2ce039 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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), diff --git a/docs/parity.md b/docs/parity.md index 9dccad34..57fd16c7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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