mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 20:16:52 -06:00
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:
co-authored by
Claude Opus 5.5
parent
4f47a01fed
commit
62cfa66496
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user