mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
The layers were only in document.inspect, together with history, channels and much more, so agents looked for a layer.* command that didn't exist. layer.list returns the layers top-down with id, name, kind, visibility, bounds, selected and active; groups list their children, or with "flat": true every layer is one row with its depth and parent group. It takes the same optional "document" index as document.inspect, is not journaled and bad params are errors.
This commit is contained in:
@@ -1283,6 +1283,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
});
|
||||
}
|
||||
v.extend(crate::layer_style::specs());
|
||||
v.extend(crate::layer_list_cmds::specs());
|
||||
v.extend(crate::filters::specs());
|
||||
v.extend(crate::filters_ext::specs());
|
||||
v.extend(crate::color_to_alpha_cmds::specs());
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! `layer.list`: the layer tree alone, for agents and scripts (#5059). `document.inspect` also has
|
||||
//! it, buried among history, channels and much more; an agent looking for the layers' ids looks
|
||||
//! for a `layer.*` command first.
|
||||
|
||||
use photocraft_doc::{Layer, LayerContent, LayerId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
const ID: &str = "layer.list";
|
||||
|
||||
/// Groups nest at most this deep in the listing; deeper layers are left out (documents can be
|
||||
/// deep, and the tree is built recursively).
|
||||
const MAX_DEPTH: usize = 256;
|
||||
|
||||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
|
||||
}
|
||||
|
||||
/// One layer's row: what an agent needs to pick a layer to work on.
|
||||
fn row(l: &Layer, selected: &[LayerId], active: Option<LayerId>) -> Value {
|
||||
let bounds = crate::layer_multi_cmds::layer_bounds(l).map(|r| [r.x0, r.y0, r.width() as i32, r.height() as i32]);
|
||||
json!({
|
||||
"id": l.id.0,
|
||||
"name": l.name,
|
||||
"kind": l.content.kind_name(),
|
||||
"visible": l.visible,
|
||||
"bounds": bounds,
|
||||
"selected": selected.contains(&l.id),
|
||||
"active": active == Some(l.id),
|
||||
})
|
||||
}
|
||||
|
||||
/// The layers top-down (the Layers panel's order), groups with their `children`.
|
||||
fn tree(layers: &[Layer], d: &DocState, selected: &[LayerId], depth: usize) -> Vec<Value> {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Vec::new();
|
||||
}
|
||||
layers
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|l| {
|
||||
let mut v = row(l, selected, d.active_layer);
|
||||
if let LayerContent::Group(g) = &l.content {
|
||||
v["children"] = Value::Array(tree(&g.children, d, selected, depth + 1));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The layers top-down as one list, each with its `depth` (0 = top level) and `parent` group id.
|
||||
fn flat(layers: &[Layer], d: &DocState, selected: &[LayerId], parent: Option<LayerId>, depth: usize, out: &mut Vec<Value>) {
|
||||
if depth >= MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for l in layers.iter().rev() {
|
||||
let mut v = row(l, selected, d.active_layer);
|
||||
v["depth"] = json!(depth);
|
||||
v["parent"] = json!(parent.map(|p| p.0));
|
||||
out.push(v);
|
||||
if let LayerContent::Group(g) = &l.content {
|
||||
flat(&g.children, d, selected, Some(l.id), depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bad = |msg: &str| EngineError::BadParams { cmd: ID.into(), msg: msg.into() };
|
||||
let d = match p.get("document") {
|
||||
None | Some(Value::Null) => s.active().ok_or(EngineError::NoDocument)?,
|
||||
Some(v) => {
|
||||
let i = v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad("`document` must be an index"))?;
|
||||
s.documents().get(i).ok_or_else(|| EngineError::Other(format!("no document at index {i}")))?
|
||||
}
|
||||
};
|
||||
let as_flat = match p.get("flat") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad("`flat` must be true or false"))?,
|
||||
};
|
||||
let selected = d.selected_layers();
|
||||
let layers = if as_flat {
|
||||
let mut out = Vec::new();
|
||||
flat(&d.doc.layers, d, &selected, None, 0, &mut out);
|
||||
out
|
||||
} else {
|
||||
tree(&d.doc.layers, d, &selected, 0)
|
||||
};
|
||||
Ok(json!({"layers": layers, "activeLayer": d.active_layer.map(|l| l.0)}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: ID,
|
||||
label: "List Layers",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"document":index?,"flat":bool=false} → {"layers":[{"id","name","kind","visible","bounds":[x,y,w,h]|null,"selected","active","children"?}],"activeLayer":id?} (top-down; groups list their "children", or with "flat" every layer is one row with its "depth" and "parent" group id)"##,
|
||||
enabled: has_doc,
|
||||
run,
|
||||
journal: false,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session_with_doc() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_ids_names_kinds_and_nesting_top_down() {
|
||||
let mut s = session_with_doc();
|
||||
let bg = s.active().unwrap().active_layer.unwrap().0;
|
||||
let inner = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
let group = s.active().unwrap().doc.layers.last().unwrap().id.0;
|
||||
s.execute("layer.select", json!({"layer": inner})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": bg, "visible": false})).unwrap();
|
||||
let r = s.execute(ID, json!({})).unwrap();
|
||||
let layers = r["layers"].as_array().unwrap();
|
||||
assert_eq!(layers.len(), 2, "{r}");
|
||||
assert_eq!((layers[0]["id"].as_u64(), layers[0]["kind"].as_str()), (Some(group), Some("Group")), "{r}");
|
||||
assert_eq!(layers[0]["children"][0]["id"], inner, "{r}");
|
||||
assert_eq!(layers[0]["children"][0]["active"], true, "{r}");
|
||||
assert!(layers[0]["children"][0]["bounds"].is_array(), "{r}");
|
||||
assert_eq!(layers[0]["bounds"], layers[0]["children"][0]["bounds"], "a group spans its children");
|
||||
assert_eq!((layers[1]["id"].as_u64(), layers[1]["name"].as_str(), layers[1]["visible"].as_bool()), (Some(bg), Some("Background"), Some(false)));
|
||||
assert!(layers[1].get("children").is_none());
|
||||
assert_eq!(r["activeLayer"], inner);
|
||||
// Flat: one row per layer, with its depth and parent group.
|
||||
let r = s.execute(ID, json!({"flat": true})).unwrap();
|
||||
let rows: Vec<(u64, u64, Option<u64>)> =
|
||||
r["layers"].as_array().unwrap().iter().map(|l| (l["id"].as_u64().unwrap(), l["depth"].as_u64().unwrap(), l["parent"].as_u64())).collect();
|
||||
assert_eq!(rows, [(group, 0, None), (inner, 1, Some(group)), (bg, 0, None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_a_query_and_leaves_the_document_alone() {
|
||||
let mut s = session_with_doc();
|
||||
let revision = s.active().unwrap().revision;
|
||||
let journal = s.journal.len();
|
||||
s.execute(ID, json!({})).unwrap();
|
||||
assert_eq!((s.active().unwrap().revision, s.journal.len()), (revision, journal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_errors_not_panics() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute(ID, json!({})).is_err(), "no document");
|
||||
let mut s = session_with_doc();
|
||||
for p in [json!({"flat": "yes"}), json!({"document": -1}), json!({"document": 99}), json!({"document": u64::MAX}), json!({"document": "a"})] {
|
||||
assert!(s.execute(ID, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute(ID, json!({"document": 0, "flat": false})).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ pub mod jobs;
|
||||
pub mod kys;
|
||||
pub mod layer_copy_cmds;
|
||||
pub mod layer_label_cmds;
|
||||
pub mod layer_list_cmds;
|
||||
pub mod layer_mask_props_cmds;
|
||||
pub mod layer_menu_cmds;
|
||||
pub mod layer_multi_cmds;
|
||||
|
||||
@@ -111,6 +111,7 @@ store expose and persist the same setting; scripts keep using canonical command
|
||||
| `paint.stroke` | `{"points":[[x,y,pressure],…],"size":20,"color":"#ff0000"}` |
|
||||
| `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` |
|
||||
| `document.inspect` | `{}`: layer tree, history, snapshots (`[{id, name}]`, for `history.selectSnapshot`), selection bounds |
|
||||
| `layer.list` | `{"document":index?,"flat":bool?}`: just the layers, top-down: `id`, `name`, `kind`, `visible`, `bounds` (`[x,y,w,h]` or null), `selected`, `active`; groups list their `children`, or with `flat` every layer is one row with its `depth` and `parent` group id |
|
||||
| `document.pixel` | `{"x":10,"y":10}`: composite RGBA |
|
||||
| `document.presets.list` | `{}` → `{presets:[{name,settings}]}`: saved New Document configurations |
|
||||
| `document.presets.save` | `{"name":"Product square","settings":{"width":1600,"height":1600,"background":"transparent"}}`: save a snapshot; names are trimmed, 1–255 characters, and ASCII case-insensitive duplicates are rejected |
|
||||
|
||||
Reference in New Issue
Block a user