PRJ-2: Project panel folders drag/drop, rename, label picker, optional columns, thumbnails

- project.select/rename/move/setLabel/setComment engine commands (undoable; no folder into
  itself).
- Drag items onto folders / the root, Enter or double-click the name to rename, double-click
  the Comment cell to edit, label colour popup on the label column, Media Duration / File
  Path / Comment columns shown or hidden from the header context menu, rendered comp/footage
  thumbnails in the info header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 10:55:54 -04:00
co-authored by Claude Opus 5.5
parent 3cdbf22aac
commit 84f517e6c1
8 changed files with 518 additions and 55 deletions
+2
View File
@@ -21,6 +21,7 @@ mod markers;
mod mask;
pub mod paint;
mod paths;
mod project_items;
mod prop;
pub mod puppet;
mod query;
@@ -96,6 +97,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(text_anim::specs());
v.extend(layer_time::specs());
v.extend(prop::specs());
v.extend(project_items::specs());
v.extend(anim::specs());
v.extend(anim_tools::specs());
v.extend(link::specs());
+151
View File
@@ -0,0 +1,151 @@
//! Project panel item edits: select, rename, move into / out of folders, label and comment.
//! Every edit is undoable.
use effectcraft_project::{ItemId, Project};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, str_p};
use crate::{EngineError, Result, Session, cmd};
/// `item` (id or name) → id.
fn item_ref(s: &Session, v: &Value, cmd: &str) -> Result<ItemId> {
match v {
Value::Number(n) => n.as_u64().map(ItemId).filter(|i| s.project.item(*i).is_some()),
Value::String(name) => s.project.find_by_name(name).map(|i| i.id),
_ => None,
}
.ok_or_else(|| bad(cmd, format!("no project item {v}")))
}
/// `items` (array) / `item`, else the Project panel selection.
fn items_p(s: &Session, p: &Value, cmd: &str) -> Result<Vec<ItemId>> {
if let Some(Value::Array(a)) = p.get("items") {
return a.iter().map(|v| item_ref(s, v, cmd)).collect();
}
if let Some(v) = p.get("item") {
return Ok(vec![item_ref(s, v, cmd)?]);
}
if s.state.project_selection.is_empty() {
return Err(bad(cmd, "no `items` given and nothing selected in the Project panel"));
}
Ok(s.state.project_selection.clone())
}
/// Whether `folder` is `item` or inside it.
pub fn is_within(project: &Project, folder: ItemId, item: ItemId) -> bool {
let mut cur = Some(folder);
let mut guard = 0;
while let Some(c) = cur {
if c == item {
return true;
}
cur = project.item(c).and_then(|i| i.parent);
guard += 1;
if guard > 256 {
return true;
}
}
false
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let ids = match p.get("items") {
Some(Value::Array(a)) => a.iter().map(|v| item_ref(s, v, "project.select")).collect::<Result<Vec<_>>>()?,
_ => vec![],
};
if b_p(p, "add").unwrap_or(false) {
for i in ids {
if !s.state.project_selection.contains(&i) {
s.state.project_selection.push(i);
}
}
} else {
s.state.project_selection = ids;
}
Ok(json!(s.state.project_selection.iter().map(|i| i.0).collect::<Vec<_>>()))
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let c = "project.rename";
let id =
item_ref(s, p.get("item").unwrap_or(&Value::Null), c).or_else(|_| s.state.project_selection.first().copied().ok_or_else(|| bad(c, "no `item`")))?;
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(c, "missing `name`"))?.to_string();
s.edit("Rename", None, |proj, _| {
proj.item_mut(id).ok_or_else(|| bad(c, "item vanished"))?.name = name;
Ok(())
})?;
Ok(Value::Null)
}
/// Move items into `folder` (id/name; `null` = the project root).
fn move_items(s: &mut Session, p: &Value) -> Result<Value> {
let c = "project.move";
let ids = items_p(s, p, c)?;
let folder = match p.get("folder") {
None | Some(Value::Null) => None,
Some(v) => {
let f = item_ref(s, v, c)?;
if !s.project.item(f).is_some_and(|i| i.is_folder()) {
return Err(bad(c, format!("{v} is not a folder")));
}
Some(f)
}
};
if let Some(f) = folder
&& let Some(bad_id) = ids.iter().find(|i| is_within(&s.project, f, **i))
{
return Err(bad(c, format!("can't move folder {} into itself", bad_id.0)));
}
s.edit("Move Items", None, |proj, _| {
for i in &ids {
if let Some(it) = proj.item_mut(*i) {
it.parent = folder;
}
}
Ok(())
})?;
Ok(json!({"moved": ids.len()}))
}
fn set_label(s: &mut Session, p: &Value) -> Result<Value> {
let c = "project.setLabel";
let ids = items_p(s, p, c)?;
let lab = match p.get("label") {
Some(Value::Number(n)) => effectcraft_color::Label::ALL.get(n.as_u64().unwrap_or(0) as usize).copied(),
Some(Value::String(name)) => s.prefs.label_from_name(name),
_ => None,
}
.ok_or_else(|| bad(c, "missing or unknown `label` (name or index 0-16)"))?;
s.edit("Label", None, |proj, _| {
for i in &ids {
if let Some(it) = proj.item_mut(*i) {
it.label = lab;
}
}
Ok(())
})?;
Ok(Value::Null)
}
fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
let c = "project.setComment";
let ids = items_p(s, p, c)?;
let text = str_p(p, "comment").ok_or_else(|| bad(c, "missing `comment`"))?.to_string();
s.edit("Comment", None, |proj, _| {
for i in &ids {
proj.item_mut(*i).ok_or(EngineError::Other("item vanished".into()))?.comment = text.clone();
}
Ok(())
})?;
Ok(Value::Null)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("project.select", "Select Project Items", [], None, "{items: [id|name], add?}", always, select),
cmd!("project.rename", "Rename Item", [], None, "{item?: id|name (default: selected), name}", always, rename),
cmd!("project.move", "Move to Folder", [], None, "{items?: [id|name] (default: selected), folder: id|name|null (root)}", always, move_items),
cmd!("project.setLabel", "Item Label", [], None, "{items?, label: name|index}", always, set_label),
cmd!("project.setComment", "Item Comment", [], None, "{items?, comment}", always, set_comment),
]
}
+2
View File
@@ -530,6 +530,8 @@ mod tests_markers;
#[cfg(test)]
mod tests_menu_cmds;
#[cfg(test)]
mod tests_project_items;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_stubs;
+41
View File
@@ -0,0 +1,41 @@
//! Project panel item edits: move into folders, rename, label, comment, with undo.
use effectcraft_project::ItemId;
use serde_json::json;
use crate::Session;
#[test]
fn move_into_folder_rename_label_comment_with_undo() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "A", "width": 64, "height": 64, "frameRate": 24, "duration": 1})).unwrap();
let comp = s.active_comp_id().unwrap();
let f = ItemId(s.execute("project.newFolder", json!({"name": "Shots"})).unwrap()["item"].as_u64().unwrap());
let g = ItemId(s.execute("project.newFolder", json!({"name": "Inner", "parent": f.0})).unwrap()["item"].as_u64().unwrap());
s.execute("project.move", json!({"items": [comp.0], "folder": "Shots"})).unwrap();
assert_eq!(s.project.item(comp).unwrap().parent, Some(f));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.item(comp).unwrap().parent, None);
s.execute("edit.redo", json!({})).unwrap();
// Out of the folder (to the root) via the selection.
s.execute("project.select", json!({"items": [comp.0]})).unwrap();
s.execute("project.move", json!({"folder": null})).unwrap();
assert_eq!(s.project.item(comp).unwrap().parent, None);
// A folder can't go into itself or its descendants.
assert!(s.execute("project.move", json!({"items": [f.0], "folder": g.0})).is_err());
assert!(s.execute("project.move", json!({"items": [comp.0], "folder": comp.0})).is_err());
s.execute("project.rename", json!({"item": comp.0, "name": "Hero"})).unwrap();
assert_eq!(s.project.item(comp).unwrap().name, "Hero");
assert!(s.execute("project.rename", json!({"item": comp.0, "name": " "})).is_err());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.item(comp).unwrap().name, "A");
s.execute("project.setLabel", json!({"items": [comp.0], "label": "Pink"})).unwrap();
assert_eq!(s.project.item(comp).unwrap().label.name(), "Pink");
s.execute("project.setComment", json!({"item": comp.0, "comment": "final"})).unwrap();
assert_eq!(s.project.item(comp).unwrap().comment, "final");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.item(comp).unwrap().comment, "");
}
+8
View File
@@ -182,6 +182,14 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
if let Some(b) = p.get("menuBar").and_then(Value::as_bool) {
app.ui.show_menu_bar = b;
}
// Project panel optional columns, e.g. ["type", "duration", "path", "comment"].
if let Some(Value::Array(a)) = p.get("projectColumns") {
let mut cols = vec![];
for k in a.iter().filter_map(Value::as_str) {
crate::panels::project::set_column(&mut cols, k, true);
}
app.ui.project_columns = cols;
}
// Wiggler / Smoother / Motion Sketch settings (fields of `AnimToolsState`).
if let Some(Value::Object(m)) = p.get("animTools") {
let mut cur = serde_json::to_value(&app.ui.anim_tools).unwrap_or_default();
+4
View File
@@ -902,6 +902,10 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
if app.dialog.is_some() {
continue;
}
// Enter in the Project panel renames the selected item (handled by the panel).
if key == egui::Key::Enter && !mods.any() && app.ui.focused == PanelKind::Project {
continue;
}
// Delete/Backspace clears selection.
if matches!(key, egui::Key::Delete | egui::Key::Backspace) && !mods.any() {
let _ = invoke(app, ctx, "edit.clear", json!({}));
+301 -55
View File
@@ -1,5 +1,9 @@
//! The Project panel: item preview header, search, item list with columns, folders, and the
//! bottom bar (new folder, new comp, bit depth, delete).
//! The Project panel: item preview header (rendered thumbnail of the comp / footage frame),
//! search, item list with columns (Name, Label, then the visible optional columns: Type, Size,
//! Media Duration, Frame Rate, File Path, Comment — shown or hidden from the header's context
//! menu), folders (drag items into and out of them), renaming (Enter, or double-click the name),
//! the label colour picker, and the bottom bar (interpret, new folder, new comp, bit depth,
//! delete). Edits are `project.*` engine commands, so they are undoable.
use effectcraft_engine::project::{Item, ItemId, ItemKind};
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
@@ -52,6 +56,23 @@ fn file_size(it: &Item) -> Option<u64> {
}
}
fn file_path(it: &Item) -> &str {
match &it.kind {
ItemKind::Footage(f) => &f.path,
_ => "",
}
}
/// Optional columns: (key, header, width).
pub const COLUMNS: [(&str, &str, f32); 6] = [
("type", "Type", 84.0),
("size", "Size", 56.0),
("duration", "Media Duration", 100.0),
("fps", "Frame Rate", 72.0),
("path", "File Path", 220.0),
("comment", "Comment", 150.0),
];
/// 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();
@@ -60,6 +81,10 @@ pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) {
"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),
"duration" => a.duration().cmp(&b.duration()),
"path" => file_path(a).cmp(file_path(b)),
"comment" => a.comment.cmp(&b.comment),
"label" => (a.label as u8).cmp(&(b.label as u8)),
_ => std::cmp::Ordering::Equal,
}
.then_with(|| name(a).cmp(&name(b)));
@@ -67,6 +92,69 @@ pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) {
});
}
/// Where a drop at a row lands: into a folder row, else into the row's folder; `None` row = root.
pub fn drop_folder(project: &effectcraft_engine::project::Project, row: Option<ItemId>) -> Option<ItemId> {
let it = project.item(row?)?;
if it.is_folder() { Some(it.id) } else { it.parent }
}
/// Thumbnail of a comp (its current time) or footage (first frame), cached per item/revision.
fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, it: &Item) -> Option<egui::TextureHandle> {
let key = egui::Id::new(("proj-thumb", it.id.0));
let rev = app.session.revision;
if let Some((r, tex)) = ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(key))
&& r == rev
{
return Some(tex);
}
let (w, h, px) = match &it.kind {
ItemKind::Comp(_) => {
let t = app.session.state.times.get(&it.id).copied().unwrap_or_default();
app.session.render_rgba8(it.id, t, 192).ok()?
}
ItemKind::Footage(f) if f.has_video => {
let img = app.session.footage.frame(it.id, f, effectcraft_engine::time::Tick(0))?;
// Point-sample down to at most 192 px on the long side.
let step = (img.width.max(img.height) as f32 / 192.0).ceil().max(1.0) as u32;
let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step));
let full = img.to_rgba8();
let mut px = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let i = (((y * step) * img.width + x * step) * 4) as usize;
px.extend_from_slice(&full[i..i + 4]);
}
}
(w, h, px)
}
_ => return None,
};
let ci = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &px);
let tex = ctx.load_texture(format!("proj-thumb-{}", it.id.0), ci, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(key, (rev, tex.clone())));
Some(tex)
}
fn fit(r: Rect, w: f32, h: f32) -> Rect {
let s = (r.width() / w.max(1.0)).min(r.height() / h.max(1.0));
Rect::from_center_size(r.center(), vec2(w * s, h * s))
}
/// An inline text edit in progress: (item, field `name`|`comment`, text).
type Editing = (u64, String, String);
fn edit_id() -> egui::Id {
egui::Id::new("proj-inline-edit")
}
/// Start renaming the selected item (Enter in the Project panel).
pub fn begin_rename(app: &EffectcraftApp, ctx: &egui::Context) {
if let Some(id) = app.session.state.project_selection.first()
&& let Some(it) = app.session.project.item(*id)
{
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "name".into(), it.name.clone())));
}
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().clone();
@@ -81,17 +169,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ItemKind::Solid(s) => {
p.rect_filled(thumb.shrink(4.0), 2.0, Color32::from_rgb((s.color[0] * 255.0) as u8, (s.color[1] * 255.0) as u8, (s.color[2] * 255.0) as u8));
}
ItemKind::Comp(_) => {
if let Some((tex, k)) = &app.viewer_tex
&& k.comp == it.id.0
{
p.image(tex.id(), thumb, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
} else {
icons::paint(&p, Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), Icon::Comp, t.text_dim);
_ => match thumbnail(app, &ctx, &it) {
Some(tex) => {
let [w, h] = tex.size();
p.image(tex.id(), fit(thumb, w as f32, h as f32), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
_ => icons::paint(&p, Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), item_icon(&it), t.text_dim),
None => icons::paint(&p, Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), item_icon(&it), t.text_dim),
},
}
app.auto.add("project.thumbnail", thumb, &it.name);
let tx = thumb.max.x + 10.0;
p.text(pos2(tx, thumb.min.y + 6.0), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), t.text);
let mut lines = vec![];
@@ -117,44 +203,50 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
widgets::search_field(ui, sr, &mut q, "Search project", &t);
app.ui.project_search = q;
app.auto.add("project.search", sr, "Search");
// Column header.
// Column header: Name, Label, then the visible optional columns.
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.40).max(120.0);
let col_type = rect.min.x + name_w + 30.0;
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 name_w = (rect.width() * 0.40).clamp(120.0, 260.0);
let label_x = rect.min.x + name_w + 14.0;
let mut cols: Vec<(&'static str, &'static str, f32, f32)> = vec![("name", "Name", rect.min.x + 26.0, name_w - 26.0), ("label", "", label_x - 8.0, 18.0)];
let mut x = label_x + 16.0;
for key in app.ui.project_columns.clone() {
if let Some((k, l, w)) = COLUMNS.iter().find(|c| c.0 == key) {
cols.push((k, l, x, *w));
x += w + 4.0;
}
}
let hp = p.with_clip_rect(hdr);
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.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click());
hp.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 *key == "label" {
icons::paint(&hp, Rect::from_center_size(pos2(label_x, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, 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(
hp.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);
app.auto.add(&format!("project.sort.{key}"), hr, if label.is_empty() { "Label" } else { label });
if resp.clicked() {
if app.ui.project_sort == key {
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;
}
}
column_menu(app, &resp);
}
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);
let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::new("proj-hdr-rest"), Sense::click());
column_menu(app, &hresp);
app.auto.add("project.columns", hdr, "Columns (right-click to show or hide)");
// 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));
@@ -176,6 +268,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
walk(app, None, 0, &query, &mut rows);
// Enter renames the selected item (Project panel focused, not typing).
if app.ui.focused == crate::dock::PanelKind::Project
&& app.dialog.is_none()
&& !ctx.egui_wants_keyboard_input()
&& ctx.data(|d| d.get_temp::<Editing>(edit_id())).is_none()
&& ctx.input(|i| i.key_pressed(egui::Key::Enter))
{
begin_rename(app, &ctx);
}
let editing: Option<Editing> = ctx.data(|d| d.get_temp(edit_id()));
let dragging = egui::DragAndDrop::payload::<DragPayload>(&ctx).and_then(|p| match *p {
DragPayload::Item(i) => Some(i),
_ => None,
});
let hover_y = ctx.pointer_hover_pos().filter(|p| list.contains(*p)).map(|p| p.y);
let mut drop_row: Option<ItemId> = None;
let mut actions: Vec<(String, serde_json::Value)> = vec![];
for (i, (id, depth)) in rows.iter().enumerate() {
let Some(it) = app.session.project.item(*id).cloned() else { continue };
@@ -184,6 +292,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if r.min.y > list.max.y {
break;
}
if hover_y.is_some_and(|hy| hy >= r.min.y && hy < r.max.y) {
drop_row = Some(*id);
}
let selected = app.session.state.project_selection.contains(id);
lp.rect_filled(
r,
@@ -207,26 +318,93 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.ui.project_open_folders.insert(id.0);
}
}
app.auto.add(&format!("project.item.{}.twirl", id.0), tw, &it.name);
}
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.rect_filled(Rect::from_center_size(pos2(r.min.x + name_w + 14.0, r.center().y), vec2(10.0, 10.0)), 2.0, t.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(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);
let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0, r.max.y)).intersect(list);
let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0));
// Inline edits (rename / comment).
let cell_edit = |app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Vec<(String, serde_json::Value)>, field: &str, cell: Rect| -> bool {
let Some((eid, ef, mut buf)) = editing.clone().filter(|(e, f, _)| *e == id.0 && f == field) else { return false };
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cell));
let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(cell.width()).font(Tokens::ui(12.0)));
app.auto.add(&format!("project.item.{}.{field}Edit", id.0), cell, field);
if resp.lost_focus() {
ctx.data_mut(|d| d.remove::<Editing>(edit_id()));
if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
let (cmd, params) = if ef == "name" {
("project.rename", json!({"item": eid, "name": buf}))
} else {
("project.setComment", json!({"items": [eid], "comment": buf}))
};
actions.push((cmd.into(), params));
}
} else {
resp.request_focus();
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (eid, ef, buf)));
}
true
};
if !cell_edit(app, ui, &mut actions, "name", name_rect) {
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 },
);
}
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);
// Label swatch: click for the label colour menu.
let sw = Rect::from_center_size(pos2(label_x, r.center().y), vec2(10.0, 10.0));
lp.rect_filled(sw, 2.0, t.label(it.label));
let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::new(("plabel", id.0)), Sense::click());
app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name());
let pop = egui::Id::new(("plabel-pop", id.0));
if sresp.clicked() {
widgets::open_popup(ui, pop);
}
let names: Vec<String> = effectcraft_engine::color::Label::ALL.iter().map(|l| app.session.prefs.label_name(*l)).collect();
let cur = effectcraft_engine::color::Label::ALL.iter().position(|l| *l == it.label);
if let Some(li) = widgets::popup_menu(ui, pop, sw.left_bottom(), &names, cur) {
let items: Vec<u64> = if selected { app.session.state.project_selection.iter().map(|i| i.0).collect() } else { vec![id.0] };
actions.push(("project.setLabel".into(), json!({"items": items, "label": li})));
}
// Optional columns.
for (key, _, cx, w) in cols.iter().skip(2) {
let cell = Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list);
let cp = lp.with_clip_rect(cell);
let txt = match *key {
"type" => it.type_name().to_string(),
"size" => file_size(&it).map(fmt_size).unwrap_or_default(),
"duration" => it.duration().map(|d| fmt_dur(d.seconds(), it.frame_rate().map(|r| r.as_f64()).unwrap_or(30.0))).unwrap_or_default(),
"fps" => it.frame_rate().map(|f| format!("{:.2}", f.as_f64())).unwrap_or_default(),
"path" => file_path(&it).to_string(),
"comment" => {
let cr = Rect::from_min_size(pos2(*cx - 2.0, r.min.y + 2.0), vec2(*w, r.height() - 4.0));
if cell_edit(app, ui, &mut actions, "comment", cr) {
continue;
}
let cresp = ui.interact(cell, egui::Id::new(("pcomment", id.0)), Sense::click());
app.auto.add(&format!("project.item.{}.comment", id.0), cell, &it.comment);
if cresp.double_clicked() {
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "comment".into(), it.comment.clone())));
}
it.comment.clone()
}
_ => String::new(),
};
let align = if *key == "fps" { Align2::RIGHT_CENTER } else { Align2::LEFT_CENTER };
let tx = if *key == "fps" { cx + w - 6.0 } else { *cx };
cp.text(pos2(tx, r.center().y), align, txt, Tokens::ui(11.5), t.text_dim);
}
if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() {
lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
}
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);
let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::click_and_drag());
app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
let resp = resp.union(nresp.clone());
if resp.clicked() {
let add = ui.input(|i| i.modifiers.command || i.modifiers.shift);
if add {
@@ -237,7 +415,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.session.state.project_selection = vec![*id];
}
}
if resp.double_clicked() {
if nresp.double_clicked() {
// Double-click the name: rename in place.
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "name".into(), it.name.clone())));
} else if resp.double_clicked() {
match &it.kind {
ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))),
ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => {
@@ -246,7 +427,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
_ => {}
}
}
if resp.drag_started() && !it.is_folder() {
if resp.drag_started() {
egui::DragAndDrop::set_payload(&ctx, DragPayload::Item(id.0));
}
resp.context_menu(|ui| {
@@ -254,6 +435,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
actions.push(("comp.open".into(), json!({"comp": id.0})));
ui.close();
}
if ui.button("Rename").clicked() {
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "name".into(), it.name.clone())));
ui.close();
}
if it.parent.is_some() && ui.button("Move to Project Root").clicked() {
actions.push(("project.move".into(), json!({"items": [id.0], "folder": null})));
ui.close();
}
if !it.is_folder() && ui.button("Add to Composition").clicked() {
actions.push(("layer.addItem".into(), json!({"item": id.0})));
ui.close();
@@ -268,6 +457,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
});
}
// Dropping a dragged item on the list: into the folder under the pointer (or the folder of
// the item under it), or the project root below the rows.
if let Some(iid) = dragging
&& hover_y.is_some()
{
if drop_row.is_none() {
lp.rect_stroke(list.shrink(1.0), 0.0, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), egui::StrokeKind::Inside);
}
if ctx.input(|i| i.pointer.any_released()) && drop_row != Some(ItemId(iid)) {
let folder = drop_folder(&app.session.project, drop_row);
let cur = app.session.project.item(ItemId(iid)).and_then(|i| i.parent);
if folder != cur {
let moving: Vec<u64> = if app.session.state.project_selection.contains(&ItemId(iid)) {
app.session.state.project_selection.iter().map(|i| i.0).collect()
} else {
vec![iid]
};
actions.push(("project.move".into(), json!({"items": moving, "folder": folder.map(|f| f.0)})));
if let Some(f) = folder {
app.ui.project_open_folders.insert(f.0);
}
}
egui::DragAndDrop::clear_payload(&ctx);
}
}
// Drag ghost.
if let Some(DragPayload::Item(iid)) = egui::DragAndDrop::payload::<DragPayload>(&ctx).as_deref()
&& let Some(pos) = ctx.pointer_hover_pos()
@@ -294,13 +508,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if resp.clicked() {
match id {
"newComp" => crate::panels::dialogs::open_new_comp(app),
"newFolder" => {
let _ = app.session.edit("New Folder", None, |proj, st| {
let id = proj.add_item("Untitled Folder", effectcraft_engine::color::Label::Yellow, None, ItemKind::Folder);
st.project_selection = vec![id];
Ok(())
});
}
"newFolder" => actions.push(("project.newFolder".into(), json!({}))),
_ => app.ui.status = "Interpret Footage: select a footage item".into(),
}
}
@@ -340,6 +548,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// The column header's context menu: show or hide the optional columns.
fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) {
resp.context_menu(|ui| {
ui.label(egui::RichText::new("Columns").weak());
for (key, label, _) in COLUMNS {
let mut on = app.ui.project_columns.iter().any(|c| c == key);
if ui.checkbox(&mut on, label).changed() {
set_column(&mut app.ui.project_columns, key, on);
}
}
});
}
/// Show or hide a column, keeping the canonical column order.
pub fn set_column(cols: &mut Vec<String>, key: &str, on: bool) {
cols.retain(|c| c != key);
if on {
cols.push(key.to_string());
cols.sort_by_key(|c| COLUMNS.iter().position(|x| x.0 == c).unwrap_or(usize::MAX));
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -366,4 +596,20 @@ mod tests {
sort_items(&mut kids, "name", true);
assert_eq!(kids[0].name, "Solids");
}
#[test]
fn drop_targets_and_column_toggles() {
let mut p = effectcraft_engine::project::Project::default();
let l = effectcraft_engine::color::Label::Yellow;
let f = p.add_item("F", l, None, ItemKind::Folder);
let inner = p.add_item("I", l, Some(f), ItemKind::Folder);
assert_eq!(drop_folder(&p, Some(f)), Some(f));
assert_eq!(drop_folder(&p, Some(inner)), Some(inner));
assert_eq!(drop_folder(&p, None), None);
let mut cols = crate::state::default_project_columns();
set_column(&mut cols, "comment", true);
set_column(&mut cols, "path", true);
set_column(&mut cols, "size", false);
assert_eq!(cols, ["type", "duration", "fps", "path", "comment"]);
}
}
+9
View File
@@ -350,6 +350,10 @@ fn default_project_sort() -> String {
"name".into()
}
pub fn default_project_columns() -> Vec<String> {
["type", "size", "duration", "fps"].map(String::from).to_vec()
}
/// What an Effect Controls crosshair / eyedropper click in the viewer sets.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FxPick {
@@ -447,6 +451,10 @@ pub struct UiState {
pub project_sort: String,
#[serde(default)]
pub project_sort_desc: bool,
/// Visible Project panel columns after Name and Label, in order (`type`, `size`, `fps`,
/// `duration`, `path`, `comment`); toggled from the column header's context menu.
#[serde(default = "default_project_columns")]
pub project_columns: Vec<String>,
/// Effect Controls twirl state (group uids that are collapsed).
pub fx_closed: BTreeSet<u64>,
/// Slider params whose slider row is twirled open (AE hides sliders by default).
@@ -526,6 +534,7 @@ impl Default for UiState {
project_open_folders: BTreeSet::new(),
project_sort: default_project_sort(),
project_sort_desc: false,
project_columns: default_project_columns(),
fx_closed: BTreeSet::new(),
fx_slider_open: BTreeSet::new(),
fx_pick: None,