diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index fb3b015fd..9ad9f0eae 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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()); diff --git a/crates/engine/src/commands/project_items.rs b/crates/engine/src/commands/project_items.rs new file mode 100644 index 000000000..832f5851a --- /dev/null +++ b/crates/engine/src/commands/project_items.rs @@ -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 { + 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> { + 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 { + let ids = match p.get("items") { + Some(Value::Array(a)) => a.iter().map(|v| item_ref(s, v, "project.select")).collect::>>()?, + _ => 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::>())) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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 { + 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 { + 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), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1244ca08c..dfee381ec 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_project_items.rs b/crates/engine/src/tests_project_items.rs new file mode 100644 index 000000000..4d097aca4 --- /dev/null +++ b/crates/engine/src/tests_project_items.rs @@ -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, ""); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c9c12fd12..d60b2ed20 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 58e7389a3..0bdec4cd6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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!({})); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 31d7d4de1..1824fec50 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -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 { } } +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) -> Option { + 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 { + 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::(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::(edit_id())).is_none() + && ctx.input(|i| i.key_pressed(egui::Key::Enter)) + { + begin_rename(app, &ctx); + } + let editing: Option = ctx.data(|d| d.get_temp(edit_id())); + let dragging = egui::DragAndDrop::payload::(&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 = 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::(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::(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 = 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 = 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::(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::(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::(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 = 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::(&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, 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"]); + } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 686855ef2..edd790416 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -350,6 +350,10 @@ fn default_project_sort() -> String { "name".into() } +pub fn default_project_columns() -> Vec { + ["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, /// Effect Controls twirl state (group uids that are collapsed). pub fx_closed: BTreeSet, /// 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,