mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 05:07:51 -06:00
MCP: skeleton reads and find for large documents
document.inspect takes the same {depth?, childLimit?} slicing as
document.node (a depth-0 read is the layer skeleton), and document.find
{name?|kind?|text?, limit?} locates nodes across the tree with their
ancestor paths and a total. docs/mcp.md documents the drill-down recipe;
the CLI stdio sweep covers both.
This commit is contained in:
@@ -24,8 +24,9 @@ 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, "{} → layer tree, artboards, selection, history", has_doc, |s, _| Ok(inspect::document(s))),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree, 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.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))),
|
||||
cmd!(
|
||||
"document.setUnits",
|
||||
@@ -113,7 +114,7 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
|
||||
let opts = slice_opts(p)?;
|
||||
let opts = slice_opts(p, "document.node")?;
|
||||
let summary = bool_or(p, "summary", false);
|
||||
if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) {
|
||||
// The full object JSON is never truncated: a slice without `summary` would
|
||||
@@ -127,14 +128,28 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// `depth` / `childLimit` for a summary read: absent (or null) means no limit.
|
||||
/// A present value that is not a non-negative integer is rejected rather than
|
||||
/// silently reinterpreted.
|
||||
fn slice_opts(p: &Value) -> Result<inspect::SummaryOpts> {
|
||||
fn slice_opts(p: &Value, cmd: &str) -> Result<inspect::SummaryOpts> {
|
||||
let opt = |key: &str| match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().map(Some).ok_or_else(|| bad("document.node", format!("`{key}` must be a non-negative integer"))),
|
||||
Some(v) => v.as_u64().map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a non-negative integer"))),
|
||||
};
|
||||
Ok(inspect::SummaryOpts { depth: opt("depth")?, child_limit: opt("childLimit")? })
|
||||
}
|
||||
|
||||
fn doc_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let substring = |key: &str| str_param(p, key).filter(|v| !v.is_empty()).map(|v| v.to_lowercase());
|
||||
let filter = inspect::FindFilter { name: substring("name"), kind: substring("kind"), text: substring("text") };
|
||||
if filter.is_empty() {
|
||||
return Err(bad("document.find", "give at least one of `name`, `kind`, `text`"));
|
||||
}
|
||||
let limit = match p.get("limit") {
|
||||
None | Some(Value::Null) => 100,
|
||||
Some(v) => v.as_u64().ok_or_else(|| bad("document.find", "`limit` must be a non-negative integer"))?,
|
||||
};
|
||||
let (matches, total) = inspect::find_nodes(&s.doc()?.doc, &filter, limit);
|
||||
Ok(json!({"matches": matches, "total": total}))
|
||||
}
|
||||
|
||||
/// Document Setup's units (also `document.setup {units}`).
|
||||
fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let u = str_param(p, "units").and_then(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Agent-friendly document summaries and view-models for panels.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ArrowAlign, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -98,6 +98,12 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value {
|
||||
}
|
||||
|
||||
pub fn document(s: &Session) -> Value {
|
||||
document_opts(s, SummaryOpts::default())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let Some(st) = s.active() else { return Value::Null };
|
||||
let d = &st.doc;
|
||||
json!({
|
||||
@@ -109,7 +115,7 @@ pub fn document(s: &Session) -> Value {
|
||||
"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(l)).collect::<Vec<_>>(),
|
||||
"layers": d.layers.iter().rev().map(|l| node_summary_opts(l, opts)).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<_>>(),
|
||||
@@ -129,6 +135,69 @@ pub fn document(s: &Session) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// What `document.find` matches: every given filter must hit (AND). All matching is
|
||||
/// case-insensitive; `name` and `text` are substrings, `kind` is the exact panel label
|
||||
/// (`Layer`, `Group`, `Path`, `Type`, `Image`, ...).
|
||||
pub struct FindFilter {
|
||||
pub name: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
impl FindFilter {
|
||||
/// True when no effective filter was given (empty strings count as absent).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
[self.name.as_ref(), self.kind.as_ref(), self.text.as_ref()].iter().all(|f| f.is_none())
|
||||
}
|
||||
|
||||
fn hits(&self, n: &Node) -> bool {
|
||||
if let Some(needle) = &self.name
|
||||
&& !n.display_name().to_lowercase().contains(needle)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(kind) = &self.kind
|
||||
&& !n.kind_label().eq_ignore_ascii_case(kind)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(needle) = &self.text {
|
||||
let NodeKind::Text(t) = &n.kind else { return false };
|
||||
if !t.plain_text().to_lowercase().contains(needle) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Search the whole layer tree, top of the stack first like the Layers panel.
|
||||
/// Returns at most `limit` hits as `{id, name, kind, path}` (`path` is the ancestor
|
||||
/// chain, layer first) plus the total hit count, so a capped reply stays explicit.
|
||||
pub fn find_nodes(doc: &Document, filter: &FindFilter, limit: u64) -> (Vec<Value>, u64) {
|
||||
use std::sync::Arc;
|
||||
let mut out = Vec::new();
|
||||
let mut total = 0u64;
|
||||
let mut ancestors: Vec<u64> = Vec::new();
|
||||
fn walk(nodes: &[Arc<Node>], ancestors: &mut Vec<u64>, filter: &FindFilter, limit: u64, out: &mut Vec<Value>, total: &mut u64) {
|
||||
for n in nodes.iter().rev() {
|
||||
if filter.hits(n) {
|
||||
*total += 1;
|
||||
if (out.len() as u64) < limit {
|
||||
out.push(json!({"id": n.id.0, "name": n.display_name(), "kind": n.kind_label(), "path": ancestors}));
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
ancestors.push(n.id.0);
|
||||
walk(ch, ancestors, filter, limit, out, total);
|
||||
ancestors.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(&doc.layers, &mut ancestors, filter, limit, &mut out, &mut total);
|
||||
(out, total)
|
||||
}
|
||||
|
||||
/// Cap names as `stroke.set` takes them.
|
||||
pub const CAPS: [(LineCap, &str); 3] = [(LineCap::Butt, "butt"), (LineCap::Round, "round"), (LineCap::Square, "square")];
|
||||
/// Join names as `stroke.set` takes them.
|
||||
|
||||
@@ -414,6 +414,60 @@ fn document_node_summary_slices_with_depth_and_limit() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `document.inspect` slices the layer tree with the same options; artboards and the
|
||||
/// rest always come whole, and the default output is unchanged.
|
||||
#[test]
|
||||
fn document_inspect_slices_the_layer_tree() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
|
||||
let full = s.execute("document.inspect", &json!({})).unwrap();
|
||||
assert!(full["layers"][0].get("childCount").is_none(), "{full}");
|
||||
let flat = s.execute("document.inspect", &json!({"depth": 0})).unwrap();
|
||||
assert!(flat["layers"][0].get("children").is_none(), "{flat}");
|
||||
assert_eq!(flat["layers"][0]["childCount"], 1);
|
||||
assert_eq!(flat["artboards"], full["artboards"]);
|
||||
assert_eq!(flat["objects"], full["objects"]);
|
||||
let one = s.execute("document.inspect", &json!({"childLimit": 0})).unwrap();
|
||||
assert_eq!(one["layers"][0]["children"], json!([]));
|
||||
assert_eq!(one["layers"][0]["childCount"], 1);
|
||||
assert!(s.execute("document.inspect", &json!({"depth": "deep"})).is_err());
|
||||
}
|
||||
|
||||
/// `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]
|
||||
fn document_find_searches_names_kinds_and_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0;
|
||||
s.execute("object.setProps", &json!({"ids": [a], "name": "Hero Banner"})).unwrap();
|
||||
rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut find = |p: Value| s.execute("document.find", &p).unwrap();
|
||||
|
||||
let v = find(json!({"name": "hero"}));
|
||||
assert_eq!(v["total"], 1);
|
||||
assert_eq!(v["matches"][0]["id"], a);
|
||||
assert_eq!(v["matches"][0]["path"], json!([1, group]));
|
||||
|
||||
let v = find(json!({"kind": "group"}));
|
||||
assert_eq!(v["total"], 1);
|
||||
assert_eq!(v["matches"][0]["id"], group);
|
||||
|
||||
// `limit: 0` counts without listing; a missing filter is rejected, not a full dump.
|
||||
let v = find(json!({"kind": "rectangle", "limit": 0}));
|
||||
assert_eq!(v["matches"].as_array().map(Vec::len), Some(0));
|
||||
assert_eq!(v["total"], 2);
|
||||
assert!(s.execute("document.find", &json!({})).is_err());
|
||||
assert!(s.execute("document.find", &json!({"name": ""})).is_err());
|
||||
assert!(s.execute("document.find", &json!({"name": "hero", "limit": "many"})).is_err());
|
||||
assert_eq!(s.execute("document.find", &json!({"kind": "nope"})).unwrap()["total"], 0);
|
||||
}
|
||||
|
||||
/// Type reports the paint its characters show, and its own object-level paint apart from it.
|
||||
#[test]
|
||||
fn inspect_reports_the_paint_of_types_characters() {
|
||||
|
||||
Reference in New Issue
Block a user