mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 22:20:50 -06:00
Merge pull request #328 from fahrim/mcp-small-wins
MCP small wins: panel ids, object reads via document.node, stdio sweep
This commit is contained in:
@@ -25,7 +25,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, "{} → layer tree, artboards, selection, history", has_doc, |s, _| Ok(inspect::document(s))),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id} → full object JSON", has_doc, doc_node),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: the compact document.inspect-style summary instead of the full object JSON} → one object", has_doc, doc_node),
|
||||
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",
|
||||
@@ -106,7 +106,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 n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
Ok(serde_json::to_value(n).unwrap_or(Value::Null))
|
||||
if bool_or(p, "summary", false) { Ok(inspect::node_summary(n)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
|
||||
}
|
||||
|
||||
/// Document Setup's units (also `document.setup {units}`).
|
||||
|
||||
@@ -312,6 +312,21 @@ fn inspect_lists_everything() {
|
||||
assert!(s.commands().len() > 100);
|
||||
}
|
||||
|
||||
/// `document.node {summary: true}` answers the `document.inspect` shape for one node:
|
||||
/// the same fields the full-tree summary carries, without serializing the whole tree.
|
||||
#[test]
|
||||
fn document_node_summary_matches_inspect() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0;
|
||||
let full = s.execute("document.node", &json!({"id": id})).unwrap();
|
||||
assert!(full.get("bounds").is_none(), "full object JSON carries geometry, not bounds: {full}");
|
||||
let summary = s.execute("document.node", &json!({"id": id, "summary": true})).unwrap();
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let node = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap();
|
||||
assert_eq!(summary, node, "{summary} vs {node}");
|
||||
assert!(s.execute("document.node", &json!({"id": 999999, "summary": true})).is_err());
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
+26
-25
@@ -44,7 +44,7 @@ pub static TEMPLATES: &[Template] = &[
|
||||
uri: "vectorcraft://object/{id}",
|
||||
name: "object",
|
||||
title: "One layer or object",
|
||||
description: "The layer or object with this id, with its children, bounds and paint (document.inspect)",
|
||||
description: "The layer or object with this id, with its children, bounds and paint (document.node {summary: true})",
|
||||
mime: "application/json",
|
||||
live: Live::Objects,
|
||||
},
|
||||
@@ -118,7 +118,9 @@ pub fn read(b: &mut dyn Backend, uri: &str) -> Result<Value, ReadError> {
|
||||
return Err(ReadError::Invalid(format!("{} needs a value for `{}`", template.uri, variable_name(template.uri))));
|
||||
}
|
||||
let found = match template.name {
|
||||
"object" => find_object(b, &var).map_err(ReadError::Backend)?,
|
||||
// One direct `document.node` lookup through `engine.execute` (which reaches both
|
||||
// backends), instead of pulling the whole layer tree out of `document.inspect`.
|
||||
"object" => read_object(b, &var).map_err(ReadError::Backend)?,
|
||||
// The command catalogue is a control method; the rest are engine commands.
|
||||
"command" => find_in(b.call("engine.commands", json!({})).map_err(ReadError::Backend)?, None, "id", &var),
|
||||
"effect" => find_in(exec(b, "effect.list").map_err(ReadError::Backend)?, Some("catalog"), "id", &var),
|
||||
@@ -171,30 +173,23 @@ fn decode(s: &str) -> String {
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// The layer or object with this id in the active document, with its subtree.
|
||||
fn find_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> {
|
||||
let doc = exec(b, "document.inspect")?;
|
||||
Ok(find_node(doc.get("layers"), id))
|
||||
}
|
||||
|
||||
/// Depth-first search of the layer tree for a node whose `id` matches.
|
||||
fn find_node(node: Option<&Value>, id: &str) -> Option<Value> {
|
||||
let nodes = node?.as_array()?;
|
||||
let mut stack: Vec<&Value> = nodes.iter().rev().collect();
|
||||
while let Some(n) = stack.pop() {
|
||||
let hit = match n.get("id") {
|
||||
Some(Value::String(s)) => s == id,
|
||||
Some(Value::Number(number)) => number.to_string() == id,
|
||||
_ => false,
|
||||
};
|
||||
if hit {
|
||||
return Some(n.clone());
|
||||
}
|
||||
if let Some(kids) = n.get("children").and_then(Value::as_array) {
|
||||
stack.extend(kids.iter().rev());
|
||||
}
|
||||
/// The layer or object with this id in the active document, as its compact summary
|
||||
/// (bounds, paint labels, subtree — the `document.inspect` shape for one node).
|
||||
///
|
||||
/// One `document.node {summary: true}` lookup through `engine.execute` (which reaches
|
||||
/// both backends), instead of pulling the whole layer tree out of `document.inspect`
|
||||
/// and scanning it: the reply matches the old read field for field, without the
|
||||
/// O(document) serialize-and-summarize on every call.
|
||||
///
|
||||
/// A non-numeric id, or one that names nothing, is "not found" (the caller turns it into
|
||||
/// `-32602`); backend failures are swallowed the same way, matching how [`crate::prompts`]
|
||||
/// treats an unreachable catalogue (empty rather than an error).
|
||||
fn read_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> {
|
||||
let Ok(num) = id.parse::<u64>() else { return Ok(None) };
|
||||
match b.call("engine.execute", json!({"command": "document.node", "params": {"id": num, "summary": true}})) {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The item in a query command's array whose `field` equals `value`.
|
||||
@@ -261,5 +256,11 @@ mod tests {
|
||||
let v = read(b.as_mut(), &format!("vectorcraft://object/{id}")).unwrap();
|
||||
assert_eq!(v["id"].to_string(), id);
|
||||
assert!(v["bounds"].is_object(), "{v}");
|
||||
// Layers resolve too, not just drawn objects.
|
||||
let inspect = b.call("document.inspect", json!({})).unwrap();
|
||||
let layer = inspect["layers"].as_array().and_then(|l| l.first()).expect("a layer");
|
||||
let lid = layer["id"].to_string();
|
||||
let v = read(b.as_mut(), &format!("vectorcraft://object/{lid}")).unwrap();
|
||||
assert_eq!(v["id"].to_string(), lid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,8 +233,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"open_panel",
|
||||
"Open panel",
|
||||
"Open a panel in the desktop app's dock (e.g. Layers, Swatches, Stroke, Align, Pathfinder, Transform). Desktop app only.",
|
||||
obj(json!({"panel": string("Panel name")}), &["panel"]),
|
||||
"Open a panel in the desktop app's dock by id (layers, swatches, stroke, align, pathfinder, transform, …; case-insensitive, display labels like \"Layers\" work too). Desktop app only.",
|
||||
obj(json!({"panel": string("Panel id or display label")}), &["panel"]),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
|
||||
@@ -171,7 +171,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"),
|
||||
("window.taskBar", "Contextual Task Bar", "", "{}"),
|
||||
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
|
||||
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"),
|
||||
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"),
|
||||
(
|
||||
"window.collapseDock",
|
||||
"Collapse Panels to Icons",
|
||||
@@ -711,6 +711,28 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
),
|
||||
];
|
||||
|
||||
/// Canonical panel id for `window.panel`: the dock tabs (`properties`, `layers`, `libraries`)
|
||||
/// and every `ICON_PANELS` id, matched case-insensitively. Each icon panel's display label
|
||||
/// is accepted too, so `"Layers"` and `"Swatches"` work the way agents write them.
|
||||
fn normalize_panel(input: &str) -> Option<&'static str> {
|
||||
let name = input.trim();
|
||||
if name.eq_ignore_ascii_case("properties") {
|
||||
return Some("properties");
|
||||
}
|
||||
if name.eq_ignore_ascii_case("layers") {
|
||||
return Some("layers");
|
||||
}
|
||||
if name.eq_ignore_ascii_case("libraries") {
|
||||
return Some("libraries");
|
||||
}
|
||||
for &(id, label, _) in ICON_PANELS.iter() {
|
||||
if name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if id == "app.language" {
|
||||
@@ -935,25 +957,29 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
Ok(json!(collapsed))
|
||||
}
|
||||
"window.panel" => {
|
||||
let panel = s("panel").unwrap_or_default();
|
||||
match (panel.as_str(), DockTab::from_id(&panel)) {
|
||||
let raw = s("panel").unwrap_or_default();
|
||||
// Canonical id (case-insensitive; display labels work too), then the
|
||||
// dock tab it names, if any.
|
||||
let canonical = normalize_panel(&raw);
|
||||
let tab = canonical.and_then(DockTab::from_id);
|
||||
match (canonical, tab) {
|
||||
// A collapsed dock pops the panel out of its icon, like the icon panels.
|
||||
(p, Some(tab)) if app.ui.dock_collapsed => {
|
||||
(Some(p), Some(tab)) if app.ui.dock_collapsed => {
|
||||
app.ui.dock_tab = tab;
|
||||
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
|
||||
app.ui.dock = true;
|
||||
Ok(json!({"open": app.ui.open_panel}))
|
||||
}
|
||||
(_, Some(tab)) => {
|
||||
(Some(_), Some(tab)) => {
|
||||
app.ui.dock_tab = tab;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
(p, None) if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => {
|
||||
(Some(p), None) => {
|
||||
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
|
||||
app.ui.dock = true;
|
||||
Ok(json!({"open": app.ui.open_panel}))
|
||||
}
|
||||
(other, None) => Err(format!("unknown panel `{other}`")),
|
||||
(None, _) => Err(format!("unknown panel `{raw}`")),
|
||||
}
|
||||
}
|
||||
"window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) {
|
||||
@@ -1237,9 +1263,10 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
||||
"window.taskBar" => app.ui.task_bar,
|
||||
"window.panel" => {
|
||||
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
|
||||
match DockTab::from_id(panel) {
|
||||
let canonical = normalize_panel(panel);
|
||||
match canonical.and_then(DockTab::from_id) {
|
||||
Some(tab) if !app.ui.dock_collapsed => app.ui.dock_tab == tab,
|
||||
_ => app.ui.open_panel.as_deref() == Some(panel),
|
||||
_ => canonical.is_some_and(|id| app.ui.open_panel.as_deref() == Some(id)),
|
||||
}
|
||||
}
|
||||
"window.collapseDock" => app.ui.dock_collapsed,
|
||||
@@ -3167,6 +3194,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_ids_are_case_insensitive_and_labels_work() {
|
||||
assert_eq!(normalize_panel("layers"), Some("layers"));
|
||||
assert_eq!(normalize_panel("Layers"), Some("layers"));
|
||||
assert_eq!(normalize_panel("LAYERS"), Some("layers"));
|
||||
assert_eq!(normalize_panel("Swatches"), Some("swatches"));
|
||||
assert_eq!(normalize_panel("swatches"), Some("swatches"));
|
||||
assert_eq!(normalize_panel("Color Guide"), Some("colorGuide"));
|
||||
assert_eq!(normalize_panel("colorguide"), Some("colorGuide"));
|
||||
assert_eq!(normalize_panel(" Stroke "), Some("stroke"));
|
||||
assert_eq!(normalize_panel("nope"), None);
|
||||
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("window.panel", json!({"panel": "Layers"})).unwrap();
|
||||
assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers);
|
||||
app.run("window.panel", json!({"panel": "Swatches"})).unwrap();
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("swatches"));
|
||||
assert!(checked(&app, "window.panel", &json!({"panel": "Swatches"})).unwrap());
|
||||
assert!(app.run("window.panel", json!({"panel": "nope"})).is_err());
|
||||
// A collapsed dock pops the panel out of its icon, by canonical id.
|
||||
app.ui.dock_collapsed = true;
|
||||
app.run("window.panel", json!({"panel": "Layers"})).unwrap();
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("layers"));
|
||||
assert!(checked(&app, "window.panel", &json!({"panel": "Layers"})).unwrap());
|
||||
app.ui.dock_collapsed = false;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_views_store_and_restore_the_view() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
Reference in New Issue
Block a user