Files
effectcraft/crates/engine/src/commands/project_items.rs
T
Brandon ThomasandClaude Opus 5.5 84f517e6c1 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>
2026-10-02 10:55:54 -04:00

152 lines
5.3 KiB
Rust

//! 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),
]
}