mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
Inspect: scope document.inspect / doc_inspect by ids, kind, limit and offset (#2167)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
d55b4f77cf
commit
1eb9bea82a
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
|
||||
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
@@ -172,6 +172,26 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if summary { Ok(inspect::node_summary_opts(n, opts, &doc.symbol_boxes())) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
|
||||
}
|
||||
|
||||
/// Most node ids `document.inspect {ids}` takes.
|
||||
const MAX_INSPECT_IDS: usize = 10_000;
|
||||
|
||||
fn doc_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.inspect";
|
||||
let opts = slice_opts(p, C)?;
|
||||
let ids = match p.get("ids") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) if a.len() > MAX_INSPECT_IDS => return Err(bad(C, format!("`ids` takes at most {MAX_INSPECT_IDS} ids"))),
|
||||
Some(_) => Some(checked_ids_param(s, p, "ids", C)?),
|
||||
};
|
||||
let kind = match p.get("kind") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(k)) => Some(k.clone()),
|
||||
Some(_) => return Err(bad(C, "`kind` must be a string")),
|
||||
};
|
||||
let scope = inspect::Scope { ids, kind, limit: opt_u64(p, C, "limit")?, offset: opt_u64(p, C, "offset")?.unwrap_or(0) };
|
||||
Ok(inspect::document_scoped(s, opts, &scope))
|
||||
}
|
||||
|
||||
/// `depth` / `childLimit` for a summary read: absent (or null) means no limit.
|
||||
fn slice_opts(p: &Value, cmd: &str) -> Result<inspect::SummaryOpts> {
|
||||
Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")?, compound_member: false })
|
||||
|
||||
@@ -131,9 +131,113 @@ pub fn document(s: &Session) -> Value {
|
||||
/// The same summary with [`SummaryOpts`] applied to the layer tree (artboards and
|
||||
/// the rest always come whole): `depth: 0` is the skeleton — top layers with counts.
|
||||
pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value {
|
||||
document_scoped(s, opts, &Scope::default())
|
||||
}
|
||||
|
||||
/// Which nodes `document.inspect` lists in place of the whole layer tree.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Scope {
|
||||
/// Only these nodes (any level, already checked to exist), in the order given.
|
||||
pub ids: Option<Vec<vectorcraft_doc::NodeId>>,
|
||||
/// Only nodes of this panel label (case-insensitive), searched in the subtrees of `ids`
|
||||
/// or else of every layer.
|
||||
pub kind: Option<String>,
|
||||
/// At most this many entries after `offset` (`None`: all).
|
||||
pub limit: Option<u64>,
|
||||
/// Entries skipped first.
|
||||
pub offset: u64,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
fn scoped(&self) -> bool {
|
||||
self.ids.is_some() || self.kind.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// A node with the ids of its ancestors (layer first).
|
||||
type Hit<'a> = (&'a Node, Vec<u64>);
|
||||
|
||||
fn ancestors(d: &Document, id: vectorcraft_doc::NodeId) -> Vec<u64> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = d.parent_of(id);
|
||||
// The bound keeps a malformed tree from looping.
|
||||
while let Some(p) = cur.filter(|_| out.len() < 10_000) {
|
||||
out.push(p.0);
|
||||
cur = d.parent_of(p);
|
||||
}
|
||||
out.reverse();
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_kind<'a>(n: &'a Node, kind: &str, path: &mut Vec<u64>, out: &mut Vec<Hit<'a>>) {
|
||||
if n.kind_label().eq_ignore_ascii_case(kind) {
|
||||
out.push((n, path.clone()));
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
path.push(n.id.0);
|
||||
for c in ch.iter().rev() {
|
||||
collect_kind(c, kind, path, out);
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// [`document_opts`] with the layer tree replaced by the nodes `scope` picks. Without `ids`
|
||||
/// or `kind` they are the top layers (`layers`, paged by `limit`/`offset`); with either they
|
||||
/// are `nodes` (each with its `path` of ancestor ids when picked by `kind`). A paged or scoped
|
||||
/// reply also reports `total`, the entries before paging.
|
||||
pub fn document_scoped(s: &Session, opts: SummaryOpts, scope: &Scope) -> Value {
|
||||
let Some(st) = s.active() else { return Value::Null };
|
||||
let d = &st.doc;
|
||||
let symbols = d.symbol_boxes();
|
||||
let mut hits: Vec<Hit> = Vec::new();
|
||||
match (&scope.ids, scope.kind.as_deref().filter(|k| !k.is_empty())) {
|
||||
(None, None) => hits.extend(d.layers.iter().rev().map(|l| (&**l, Vec::new()))),
|
||||
(Some(ids), None) => hits.extend(ids.iter().filter_map(|&i| d.node(i)).map(|n| (n, Vec::new()))),
|
||||
(ids, Some(kind)) => {
|
||||
let mut path = Vec::new();
|
||||
match ids {
|
||||
Some(ids) => {
|
||||
for n in ids.iter().filter_map(|&i| d.node(i)) {
|
||||
path = ancestors(d, n.id);
|
||||
collect_kind(n, kind, &mut path, &mut hits);
|
||||
}
|
||||
}
|
||||
None => d.layers.iter().rev().for_each(|l| collect_kind(l, kind, &mut path, &mut hits)),
|
||||
}
|
||||
}
|
||||
}
|
||||
let total = hits.len();
|
||||
let skip = usize::try_from(scope.offset).unwrap_or(usize::MAX);
|
||||
let take = scope.limit.map_or(usize::MAX, |l| usize::try_from(l).unwrap_or(usize::MAX));
|
||||
let by_kind = scope.kind.as_deref().is_some_and(|k| !k.is_empty());
|
||||
let nodes: Vec<Value> = hits
|
||||
.into_iter()
|
||||
.skip(skip)
|
||||
.take(take)
|
||||
.map(|(n, path)| {
|
||||
let member = d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. }));
|
||||
let mut v = node_summary_opts(n, SummaryOpts { compound_member: member, ..opts }, &symbols);
|
||||
if by_kind {
|
||||
v["path"] = json!(path);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let mut out = document_base(s);
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert(if scope.scoped() { "nodes" } else { "layers" }.into(), Value::Array(nodes));
|
||||
if scope.scoped() || scope.limit.is_some() || scope.offset > 0 {
|
||||
o.insert("total".into(), json!(total));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Everything `document.inspect` reports except the layer tree.
|
||||
fn document_base(s: &Session) -> Value {
|
||||
let Some(st) = s.active() else { return Value::Null };
|
||||
let d = &st.doc;
|
||||
json!({
|
||||
"title": st.title(),
|
||||
"path": st.path,
|
||||
@@ -142,8 +246,6 @@ pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value {
|
||||
"units": d.units.label(),
|
||||
"colorMode": format!("{:?}", d.color_mode),
|
||||
"artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::<Vec<_>>(),
|
||||
// Top of the stack first, like the Layers panel.
|
||||
"layers": d.layers.iter().rev().map(|l| node_summary_opts(l, opts, &symbols)).collect::<Vec<_>>(),
|
||||
"currentLayer": st.active_layer.map(|l| l.0),
|
||||
// The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`).
|
||||
"layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
|
||||
@@ -2042,6 +2042,65 @@ fn document_inspect_slices_the_layer_tree() {
|
||||
assert!(s.execute("document.inspect", &json!({"depth": "deep"})).is_err());
|
||||
}
|
||||
|
||||
/// `document.inspect` scoped by `ids`, `kind`, `limit` and `offset`: each entry is the node as
|
||||
/// the full tree lists it, bad input is an error, and the unscoped reply keeps its shape.
|
||||
#[test]
|
||||
fn document_inspect_scopes_by_ids_kind_and_page() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0;
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0).0;
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0).0;
|
||||
let mut ins = |p: Value| s.execute("document.inspect", &p).unwrap();
|
||||
|
||||
let full = ins(json!({}));
|
||||
assert!(full.get("nodes").is_none() && full.get("total").is_none(), "{full}");
|
||||
let layer = full["layers"][0].clone();
|
||||
let layer_id = layer["id"].as_u64().unwrap();
|
||||
let find = |tree: &Value, id: u64| -> Value {
|
||||
fn walk(n: &Value, id: u64) -> Option<Value> {
|
||||
if n["id"] == id {
|
||||
return Some(n.clone());
|
||||
}
|
||||
n["children"].as_array()?.iter().find_map(|c| walk(c, id))
|
||||
}
|
||||
walk(tree, id).unwrap()
|
||||
};
|
||||
|
||||
// ids: just those nodes, in the order given, equal to their part of the full tree.
|
||||
let some = ins(json!({"ids": [c, group]}));
|
||||
assert!(some.get("layers").is_none());
|
||||
assert_eq!(some["nodes"], json!([find(&layer, c), find(&layer, group)]));
|
||||
assert_eq!(some["total"], 2);
|
||||
assert_eq!(some["artboards"], full["artboards"]);
|
||||
assert_eq!(ins(json!({"ids": [group], "depth": 0}))["nodes"][0].get("children"), None);
|
||||
|
||||
// kind: every match with its ancestor path, top of the stack first; paged.
|
||||
let paths = ins(json!({"kind": "rectangle"}));
|
||||
let ids: Vec<u64> = paths["nodes"].as_array().unwrap().iter().map(|n| n["id"].as_u64().unwrap()).collect();
|
||||
assert_eq!(ids, [c, b, a]);
|
||||
assert_eq!(paths["nodes"][1]["path"], json!([layer_id, group]));
|
||||
assert_eq!(paths["total"], 3);
|
||||
let page = ins(json!({"kind": "Rectangle", "offset": 1, "limit": 1}));
|
||||
assert_eq!(page["nodes"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(page["nodes"][0]["id"], b);
|
||||
assert_eq!(page["total"], 3);
|
||||
assert_eq!(ins(json!({"kind": "Rectangle", "offset": 99}))["nodes"], json!([]));
|
||||
assert_eq!(ins(json!({"kind": "Rectangle", "ids": [group]}))["total"], 2);
|
||||
|
||||
// limit/offset alone page the top layers.
|
||||
let paged = ins(json!({"limit": 0}));
|
||||
assert_eq!(paged["layers"], json!([]));
|
||||
assert_eq!(paged["total"], 1);
|
||||
|
||||
for bad in
|
||||
[json!({"ids": [999_999]}), json!({"ids": "all"}), json!({"ids": [-1]}), json!({"kind": 3}), json!({"limit": -1}), json!({"offset": 1.5})]
|
||||
{
|
||||
assert!(s.execute("document.inspect", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `document.find` searches names, kinds and type content across the whole tree and
|
||||
/// answers ids with ancestor paths plus the total, so capped replies stay explicit.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user