diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index bba588ad4..0a4539427 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -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 { + 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, 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"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 05a85121b..3662b7f9d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, pub project_search: String, pub project_open_folders: BTreeSet, + /// 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, /// 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],