272 lines
11 KiB
Rust
272 lines
11 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, query};
|
|
|
|
/// `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)
|
|
}
|
|
|
|
/// The items deleting `ids` removes: them and, for folders, everything inside.
|
|
fn doomed_items(project: &Project, ids: &[ItemId]) -> Vec<ItemId> {
|
|
project.items.keys().copied().filter(|id| ids.iter().any(|d| is_within(project, *id, *d))).collect()
|
|
}
|
|
|
|
/// What deleting `items` takes with it: (layers using them, in how many comps), counting only
|
|
/// the comps that stay.
|
|
fn usage_of(project: &Project, items: &[ItemId]) -> (usize, usize) {
|
|
let mut layers = 0;
|
|
let mut comps = 0;
|
|
for (_, comp) in project.comps().filter(|(cid, _)| !items.contains(cid)) {
|
|
let n = comp.layers.iter().filter(|l| l.source.item().is_some_and(|i| items.contains(&i))).count();
|
|
layers += n;
|
|
comps += usize::from(n > 0);
|
|
}
|
|
(layers, comps)
|
|
}
|
|
|
|
/// What deleting the items (default: selected) would delete: the items (with folder contents)
|
|
/// and the layers in other compositions that use them. The Project panel asks before deleting
|
|
/// items in use, as After Effects does.
|
|
fn usage(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = items_p(s, p, "project.usage")?;
|
|
let doomed = doomed_items(&s.project, &ids);
|
|
let (layers, comps) = usage_of(&s.project, &doomed);
|
|
Ok(json!({"items": doomed.len(), "layers": layers, "comps": comps}))
|
|
}
|
|
|
|
/// Delete project items (folders with their contents). Layers that use a deleted item and Render
|
|
/// Queue items of deleted comps go too, like After Effects' Edit ▸ Clear in the Project panel.
|
|
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let c = "project.delete";
|
|
let ids = items_p(s, p, c)?;
|
|
let doomed = doomed_items(&s.project, &ids);
|
|
let n = doomed.len();
|
|
s.edit("Delete Items", None, |proj, st| {
|
|
for id in &doomed {
|
|
proj.items.remove(id);
|
|
}
|
|
let comp_ids: Vec<ItemId> = proj.comps().map(|(id, _)| *id).collect();
|
|
for cid in comp_ids {
|
|
let uses = proj.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.item().is_some_and(|i| doomed.contains(&i))));
|
|
if !uses {
|
|
continue;
|
|
}
|
|
if let Some(comp) = proj.comp_mut(cid) {
|
|
let gone: Vec<effectcraft_project::LayerId> =
|
|
comp.layers.iter().filter(|l| l.source.item().is_some_and(|i| doomed.contains(&i))).map(|l| l.id).collect();
|
|
comp.layers.retain(|l| !gone.contains(&l.id));
|
|
for l in &mut comp.layers {
|
|
if l.parent.is_some_and(|x| gone.contains(&x)) {
|
|
l.parent = None;
|
|
}
|
|
if l.track_matte.is_some_and(|m| gone.contains(&m.layer)) {
|
|
l.track_matte = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
proj.render_queue.retain(|r| !doomed.contains(&r.comp));
|
|
st.project_selection.retain(|i| !doomed.contains(i));
|
|
Ok(())
|
|
})?;
|
|
s.sanitize_state();
|
|
Ok(json!(n))
|
|
}
|
|
|
|
/// Duplicate project items (Edit ▸ Duplicate in the Project panel): comps get fresh layer ids and
|
|
/// a numbered name; the copies are selected.
|
|
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let c = "project.duplicate";
|
|
let ids = items_p(s, p, c)?;
|
|
let mut created = vec![];
|
|
s.edit("Duplicate", None, |proj, st| {
|
|
for id in &ids {
|
|
let it = proj.item(*id).ok_or(EngineError::Other("item vanished".into()))?.clone();
|
|
if it.is_folder() {
|
|
return Err(bad(c, "folders can't be duplicated"));
|
|
}
|
|
let base = it.name.trim_end_matches(|ch: char| ch.is_ascii_digit()).trim_end().to_string();
|
|
let name = (2..).map(|i| format!("{base} {i}")).find(|n| proj.find_by_name(n).is_none()).unwrap_or_default();
|
|
let mut kind = it.kind.clone();
|
|
if let effectcraft_project::ItemKind::Comp(comp) = &mut kind {
|
|
let comp = std::sync::Arc::make_mut(comp);
|
|
let mut map = std::collections::HashMap::new();
|
|
for l in &mut comp.layers {
|
|
let new = effectcraft_project::LayerId(proj.alloc());
|
|
map.insert(l.id, new);
|
|
l.id = new;
|
|
let mut next = proj.next_id;
|
|
l.props.reassign_uids(&mut next);
|
|
proj.next_id = next + 1;
|
|
}
|
|
for l in &mut comp.layers {
|
|
l.parent = l.parent.and_then(|p| map.get(&p).copied());
|
|
if let Some(m) = &mut l.track_matte {
|
|
m.layer = map.get(&m.layer).copied().unwrap_or(m.layer);
|
|
}
|
|
}
|
|
}
|
|
let nid = proj.add_item(&name, it.label, it.parent, kind);
|
|
created.push(nid);
|
|
}
|
|
st.project_selection = created.clone();
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"items": created.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
|
}
|
|
|
|
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),
|
|
cmd!(
|
|
"project.delete",
|
|
"Delete Project Items",
|
|
[],
|
|
None,
|
|
"{items?: [id|name] (default: selected)} (folders with their contents; layers using the items go too)",
|
|
always,
|
|
delete
|
|
),
|
|
query!("project.usage", "Project Item Usage", "{items?: [id|name] (default: selected)} → {items, layers, comps} that deleting them removes", usage),
|
|
cmd!("project.duplicate", "Duplicate Project Items", [], None, "{items?: [id|name] (default: selected)} → {items}", always, duplicate),
|
|
]
|
|
}
|