M13.31: Home screen laid out like After Effects'
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
2039688245
commit
d31094692e
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
/// 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 {
|
||||
(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;
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue