M0.9: Project panel parity — sortable columns (folders mixed by name), Frame Rate column, Size = file size, AE durations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 21:38:34 -04:00
co-authored by Claude Opus 5.5
parent 4f47a01fed
commit 62cfa66496
2 changed files with 130 additions and 17 deletions
+118 -17
View File
@@ -23,10 +23,48 @@ fn item_icon(it: &Item) -> Icon {
}
}
fn fmt_dur(secs: f64, fps: f64) -> String {
let f = (secs * fps).round() as i64;
/// Duration as AE prints it in the Project panel header: `0:00:10:00` (h:mm:ss:ff).
pub fn fmt_dur(secs: f64, fps: f64) -> String {
let fpsi = fps.round().max(1.0) as i64;
format!("0;{:02};{:02};{:02}", f / fpsi / 60, (f / fpsi) % 60, f % fpsi)
let f = (secs * fps).round() as i64;
let s = f / fpsi;
format!("{}:{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60, f % fpsi)
}
/// File size the way AE's Size column shows it (`512 KB`, `12.3 MB`).
pub fn fmt_size(bytes: u64) -> String {
const K: f64 = 1024.0;
let b = bytes as f64;
if b < K * K {
format!("{} KB", (b / K).ceil() as u64)
} else if b < K * K * K {
format!("{:.1} MB", b / (K * K))
} else {
format!("{:.2} GB", b / (K * K * K))
}
}
/// Footage file size on disk (comps, solids and folders have none).
fn file_size(it: &Item) -> Option<u64> {
match &it.kind {
ItemKind::Footage(f) => std::fs::metadata(&f.path).ok().map(|m| m.len()),
_ => None,
}
}
/// Sort siblings by the Project panel's sort column; ties fall back to the name.
pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) {
let name = |i: &Item| i.name.to_lowercase();
items.sort_by(|a, b| {
let o = match col {
"type" => a.type_name().cmp(b.type_name()),
"size" => file_size(a).cmp(&file_size(b)),
"fps" => a.frame_rate().map(|r| r.as_f64()).partial_cmp(&b.frame_rate().map(|r| r.as_f64())).unwrap_or(std::cmp::Ordering::Equal),
_ => std::cmp::Ordering::Equal,
}
.then_with(|| name(a).cmp(&name(b)));
if desc { o.reverse() } else { o }
});
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
@@ -82,13 +120,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Column header.
let hdr = Rect::from_min_size(pos2(rect.min.x, sr.max.y + 6.0), vec2(rect.width(), 20.0));
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
let name_w = (rect.width() * 0.48).max(140.0);
let name_w = (rect.width() * 0.40).max(120.0);
let col_type = rect.min.x + name_w + 30.0;
let col_size = col_type + 76.0;
p.text(pos2(rect.min.x + 26.0, hdr.center().y), Align2::LEFT_CENTER, "Name", Tokens::ui(11.0), t.text_dim);
let col_size = col_type + 80.0;
let col_fps = col_size + 52.0;
// Clickable headers sort (click again to reverse), with AE's ▲/▼ indicator.
let cols = [
("name", "Name", rect.min.x + 26.0, name_w - 26.0),
("type", "Type", col_type, 76.0),
("size", "Size", col_size, 48.0),
("fps", "Frame Rate", col_fps, 70.0),
];
for (key, label, x, w) in cols {
let hr = Rect::from_min_size(pos2(x - 4.0, hdr.min.y), vec2(w, hdr.height()));
let resp = ui.interact(hr, egui::Id::new(("proj-sort", key)), Sense::click());
p.with_clip_rect(hr).text(pos2(x, hdr.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim });
if app.ui.project_sort == key {
let c = pos2(hr.max.x - 10.0, hdr.center().y);
let d = if app.ui.project_sort_desc { 1.0 } else { -1.0 };
p.add(egui::Shape::convex_polygon(
vec![pos2(c.x - 4.0, c.y - 2.0 * d), pos2(c.x + 4.0, c.y - 2.0 * d), pos2(c.x, c.y + 2.5 * d)],
t.text_dim,
Stroke::NONE,
));
}
app.auto.add(&format!("project.sort.{key}"), hr, label);
if resp.clicked() {
if app.ui.project_sort == key {
app.ui.project_sort_desc = !app.ui.project_sort_desc;
} else {
app.ui.project_sort = key.to_string();
app.ui.project_sort_desc = false;
}
}
}
icons::paint(&p, Rect::from_center_size(pos2(rect.min.x + name_w + 14.0, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim);
p.text(pos2(col_type, hdr.center().y), Align2::LEFT_CENTER, "Type", Tokens::ui(11.0), t.text_dim);
p.text(pos2(col_size, hdr.center().y), Align2::LEFT_CENTER, "Size", Tokens::ui(11.0), t.text_dim);
// Rows.
let footer_h = 28.0;
let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h));
@@ -97,7 +163,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let query = app.ui.project_search.to_lowercase();
let mut rows: Vec<(ItemId, usize)> = vec![];
fn walk(app: &EffectcraftApp, folder: Option<ItemId>, depth: usize, q: &str, out: &mut Vec<(ItemId, usize)>) {
for it in app.session.project.children(folder) {
let mut kids = app.session.project.children(folder);
sort_items(&mut kids, &app.ui.project_sort, app.ui.project_sort_desc);
for it in kids {
if !q.is_empty() && !it.is_folder() && !it.name.to_lowercase().contains(q) {
continue;
}
@@ -140,17 +208,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
icons::paint(
&lp,
Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)),
item_icon(&it),
if it.is_folder() { Color32::from_rgb(0xd8, 0xc0, 0x60) } else { t.text_dim },
icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(&it), t.text_dim);
let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 6.0, r.max.y)).intersect(list);
lp.with_clip_rect(name_clip).text(
pos2(x0 + 30.0, r.center().y),
Align2::LEFT_CENTER,
&it.name,
Tokens::ui(12.0),
if selected { Color32::WHITE } else { t.text },
);
lp.text(pos2(x0 + 30.0, r.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(12.0), if selected { Color32::WHITE } else { t.text });
lp.rect_filled(Rect::from_center_size(pos2(r.min.x + name_w + 14.0, r.center().y), vec2(10.0, 10.0)), 2.0, Tokens::label(it.label));
lp.text(pos2(col_type, r.center().y), Align2::LEFT_CENTER, it.type_name(), Tokens::ui(11.5), t.text_dim);
if let Some((w, h)) = it.dimensions() {
lp.text(pos2(col_size, r.center().y), Align2::LEFT_CENTER, format!("{w} x {h}"), Tokens::ui(11.5), t.text_dim);
if let Some(b) = file_size(&it) {
lp.text(pos2(col_size, r.center().y), Align2::LEFT_CENTER, fmt_size(b), Tokens::ui(11.5), t.text_dim);
}
if let Some(fr) = it.frame_rate() {
lp.text(pos2(col_fps + 66.0, r.center().y), Align2::RIGHT_CENTER, format!("{:.2}", fr.as_f64()), Tokens::ui(11.5), t.text_dim);
}
let resp = ui.interact(r.intersect(list), egui::Id::new(("pitem", id.0)), Sense::click_and_drag());
app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
@@ -262,3 +335,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ae_duration_and_size_formats() {
assert_eq!(fmt_dur(10.0, 29.97), "0:00:10:00");
assert_eq!(fmt_dur(3725.48, 25.0), "1:02:05:12");
assert_eq!(fmt_size(1000), "1 KB");
assert_eq!(fmt_size(5 * 1024 * 1024 + 300_000), "5.3 MB");
}
#[test]
fn folders_sort_with_items_by_name() {
let mut p = effectcraft_engine::project::Project::default();
let l = effectcraft_engine::color::Label::Yellow;
p.add_item("Solids", l, None, ItemKind::Folder);
p.add_item("Precomps", l, None, ItemKind::Folder);
p.add_item("EffectCraft Intro", l, None, ItemKind::Folder);
let mut kids = p.children(None);
sort_items(&mut kids, "name", false);
let names: Vec<&str> = kids.iter().map(|i| i.name.as_str()).collect();
assert_eq!(names, ["EffectCraft Intro", "Precomps", "Solids"]);
sort_items(&mut kids, "name", true);
assert_eq!(kids[0].name, "Solids");
}
}
+12
View File
@@ -268,6 +268,10 @@ impl Default for TimelineState {
}
}
fn default_project_sort() -> String {
"name".into()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UiState {
pub tool: Tool,
@@ -286,6 +290,12 @@ pub struct UiState {
pub effects_open: BTreeSet<String>,
pub project_search: String,
pub project_open_folders: BTreeSet<u64>,
/// Project panel sort column (`name`, `type`, `size`, `fps`) and direction, as in AE's
/// clickable column headers. Folders sort with everything else.
#[serde(default = "default_project_sort")]
pub project_sort: String,
#[serde(default)]
pub project_sort_desc: bool,
/// Effect Controls twirl state (group uids that are collapsed).
pub fx_closed: BTreeSet<u64>,
/// Shape tool options.
@@ -318,6 +328,8 @@ impl Default for UiState {
effects_open: BTreeSet::new(),
project_search: String::new(),
project_open_folders: BTreeSet::new(),
project_sort: default_project_sort(),
project_sort_desc: false,
fx_closed: BTreeSet::new(),
fill_color: [0.24, 0.55, 0.96],
stroke_color: [1.0, 1.0, 1.0],