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:
vangel76
2026-10-17 13:49:48 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent d55b4f77cf
commit 1eb9bea82a
8 changed files with 223 additions and 18 deletions
+21 -1
View File
@@ -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 })
+104 -2
View File
@@ -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<_>>(),
+59
View File
@@ -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]