Merge pull request #1222 from dotCooCoo/feat/select-key-checks-ids
MCP: select.key checks its id, and agents can read the key object
This commit is contained in:
commit
0e6fa89ad6
|
|
@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Set Key Object",
|
||||
[],
|
||||
None,
|
||||
"{id?: a selected object, one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members)",
|
||||
"{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object), one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: no id)} the key object Align aligns to and Distribute Spacing spaces from; without `id` it clears the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members); any other id is an error that leaves the key as it was → {key} (the resulting key object, or null)",
|
||||
has_doc,
|
||||
key
|
||||
),
|
||||
|
|
@ -297,9 +297,18 @@ fn selection_result(s: &Session) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id");
|
||||
s.select(|_, sel| sel.key = id.filter(|i| sel.contains(*i)))?;
|
||||
ok()
|
||||
let id = p.get("id").map(|value| checked_id(s, value, "select.key")).transpose()?;
|
||||
if let Some(id) = id {
|
||||
let st = s.doc()?;
|
||||
// A selected top-level object is the only top-level object among the selected ones of
|
||||
// itself and its ancestors.
|
||||
let selected = st.doc.ancestry(id).unwrap_or_default().into_iter().filter(|a| st.selection.contains(*a)).collect();
|
||||
if super::edit::roots_of(&st.doc, selected) != [id] {
|
||||
return Err(bad("select.key", format!("object {id} is not a selected top-level object")));
|
||||
}
|
||||
}
|
||||
s.select(|_, sel| sel.key = id)?;
|
||||
Ok(json!({ "key": s.doc()?.selection.key }))
|
||||
}
|
||||
|
||||
/// `[[subpath, anchor]…]` anchor references of `cmd`: a malformed entry fails rather than being
|
||||
|
|
|
|||
|
|
@ -138,6 +138,8 @@ pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value {
|
|||
"selectionRotation": d.bbox_angle(&st.selection.objects),
|
||||
// The layer, group or object targeted through the Layers panel (`layer.target`).
|
||||
"target": st.selection.target.map(|t| t.0),
|
||||
// The key object Align aligns to and Distribute Spacing spaces from (`select.key`).
|
||||
"key": st.selection.key.map(|k| k.0),
|
||||
"history": st.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
|
||||
"redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::<Vec<_>>(),
|
||||
"objects": d.node_count(),
|
||||
|
|
|
|||
|
|
@ -73,3 +73,63 @@ fn single_id_toggle_validates_before_mutating() {
|
|||
// A malformed array must not silently fall back to a valid single id.
|
||||
assert!(s.execute("select.toggle", &json!({"ids": "bad", "id": a.0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_key_rejects_ids_it_cannot_use_and_keeps_the_key() {
|
||||
let (mut s, a, b) = session();
|
||||
let rectangle = |s: &mut Session, x: u64| {
|
||||
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let [c, d, e, f, unselected] = [40, 60, 80, 100, 120].map(|x| rectangle(&mut s, x));
|
||||
s.execute("select.set", &json!({"ids": [c.0, d.0]})).unwrap();
|
||||
s.execute("object.compoundPath.make", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [e.0]})).unwrap();
|
||||
let inner = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [inner.0, f.0]})).unwrap();
|
||||
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
// c is a member of a compound path, and e is in a group inside the selected group.
|
||||
s.execute("select.set", &json!({"ids": [b.0, a.0, c.0, group.0, e.0]})).unwrap();
|
||||
s.execute("select.key", &json!({"id": b.0})).unwrap();
|
||||
let invalid_ids =
|
||||
[json!(999), json!("x"), json!(-1), json!(2.5), Value::Null, json!(true), json!(u64::MAX), json!(unselected.0), json!(c.0), json!(e.0)];
|
||||
for invalid in invalid_ids {
|
||||
let selection = s.active().unwrap().selection.clone();
|
||||
let revision = s.active().unwrap().revision;
|
||||
let journal = s.journal.clone();
|
||||
let error = s.execute("select.key", &json!({"id": invalid})).unwrap_err();
|
||||
assert!(matches!(error, EngineError::BadParams { .. }), "{invalid}: {error}");
|
||||
assert!(error.to_string().contains(&invalid.to_string()), "the error must identify {invalid}: {error}");
|
||||
assert_eq!(s.active().unwrap().selection, selection, "{invalid}");
|
||||
assert_eq!(s.active().unwrap().selection.key, Some(b), "{invalid}");
|
||||
assert_eq!(s.active().unwrap().revision, revision);
|
||||
assert_eq!(s.journal, journal);
|
||||
}
|
||||
assert_eq!(s.execute("select.key", &json!({"id": group.0})).unwrap(), json!({"key": group.0}), "the group is top-level");
|
||||
// A selected layer is not a top-level object.
|
||||
let layer = s.active().unwrap().doc.layers[0].id;
|
||||
s.execute("select.add", &json!({"ids": [layer.0]})).unwrap();
|
||||
assert!(s.active().unwrap().selection.contains(layer));
|
||||
let error = s.execute("select.key", &json!({"id": layer.0})).unwrap_err();
|
||||
assert!(matches!(error, EngineError::BadParams { .. }), "{error}");
|
||||
assert_eq!(s.active().unwrap().selection.key, Some(group));
|
||||
// Inside the isolated group, f is top-level: the Selection tool's clicks there send its id.
|
||||
s.execute("object.isolate", &json!({"id": group.0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [inner.0, f.0]})).unwrap();
|
||||
assert_eq!(s.execute("select.key", &json!({"id": f.0})).unwrap(), json!({"key": f.0}), "an object in an unselected group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_key_and_document_inspect_report_the_key_object() {
|
||||
let (mut s, a, b) = session();
|
||||
let inspected = |s: &mut Session| s.execute("document.inspect", &json!({})).unwrap().get("key").cloned();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
assert_eq!(inspected(&mut s), Some(Value::Null), "no key yet");
|
||||
assert_eq!(s.execute("select.key", &json!({"id": b.0})).unwrap(), json!({"key": b.0}));
|
||||
assert_eq!(inspected(&mut s), Some(json!(b.0)));
|
||||
assert_eq!(s.execute("select.key", &json!({})).unwrap(), json!({"key": null}));
|
||||
assert_eq!(inspected(&mut s), Some(Value::Null));
|
||||
// A single selected object is not a key object.
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
assert_eq!(s.execute("select.key", &json!({"id": b.0})).unwrap(), json!({"key": null}));
|
||||
assert_eq!(inspected(&mut s), Some(Value::Null));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,7 +190,8 @@ impl Headless {
|
|||
}
|
||||
}
|
||||
let sel = self.session.active().map(|d| d.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>()).unwrap_or_default();
|
||||
Ok(json!({"selection": sel, "tool": self.session.tool_id(), "requests": requests}))
|
||||
let key = self.session.active().and_then(|d| d.selection.key).map(|k| k.0);
|
||||
Ok(json!({"selection": sel, "key": key, "tool": self.session.tool_id(), "requests": requests}))
|
||||
}
|
||||
|
||||
fn key(&mut self, p: &Value) -> Result<Value, String> {
|
||||
|
|
|
|||
|
|
@ -579,6 +579,30 @@ fn shift_marquee_gesture_toggles_the_selection() {
|
|||
}
|
||||
}
|
||||
|
||||
/// A click on one of two selected objects makes it the key object: `pointer_gesture` replies
|
||||
/// with it as `key` and `inspect_document` lists it, and a click on the key again lets it go.
|
||||
#[test]
|
||||
fn pointer_gestures_report_the_key_object() {
|
||||
let mut s = server();
|
||||
let square = |s: &mut Server, id: u64, x: u64| {
|
||||
let r = call(s, id, "draw_shape", json!({"shape": "rectangle", "x": x, "y": 100, "width": 50, "height": 50, "fill": "#ff0000"}));
|
||||
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let (a, b) = (square(&mut s, 1, 100), square(&mut s, 2, 200));
|
||||
let r = call(&mut s, 3, "run_command", json!({"command": "select.set", "params": {"ids": [a, b]}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let click = |s: &mut Server, id: u64| {
|
||||
let events = json!([{"kind": "down", "x": 225, "y": 125}, {"kind": "up", "x": 225, "y": 125}]);
|
||||
let r = call(s, id, "pointer_gesture", json!({"tool": "selection", "events": events}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
serde_json::from_str::<Value>(&text_of(&r)).unwrap().get("key").cloned()
|
||||
};
|
||||
assert_eq!(click(&mut s, 4), Some(json!(b)), "a click on a selected object makes it the key");
|
||||
let doc: Value = serde_json::from_str(&text_of(&call(&mut s, 5, "inspect_document", json!({})))).unwrap();
|
||||
assert_eq!(doc.get("key"), Some(&json!(b)));
|
||||
assert_eq!(click(&mut s, 6), Some(Value::Null), "a click on the key again lets it go");
|
||||
}
|
||||
|
||||
/// Preferences › Type › Show Font Names in English (#394): agents set it with `prefs.set`;
|
||||
/// `text.fontList` stays the English (canonical) family names used by `text.setStyle`, not the
|
||||
/// native UI labels.
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ enum State {
|
|||
/// selection.
|
||||
deselect: Option<NodeId>,
|
||||
/// Pressed (no modifier) on this object of a selection of several: released without a
|
||||
/// drag, it becomes the key object that Align aligns to (the key again: no key).
|
||||
/// drag, it becomes the key object that Align aligns to if `can_be_key` allows it (the key
|
||||
/// again: no key).
|
||||
key: Option<NodeId>,
|
||||
},
|
||||
Scaling {
|
||||
|
|
@ -409,8 +410,8 @@ impl Tool for SelectionTool {
|
|||
(false, Some(id), _) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
|
||||
// A click on the key object again: no key.
|
||||
(false, None, Some(id)) if cx.selection.key == Some(id) => vec![Action::Exec("select.key".into(), json!({}))],
|
||||
(false, None, Some(id)) => vec![Action::Exec("select.key".into(), json!({ "id": id.0 }))],
|
||||
(false, None, None) => vec![],
|
||||
(false, None, Some(id)) if can_be_key(cx, id) => vec![Action::Exec("select.key".into(), json!({ "id": id.0 }))],
|
||||
(false, None, _) => vec![],
|
||||
}
|
||||
}
|
||||
(PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => {
|
||||
|
|
@ -512,6 +513,17 @@ fn object_behind(cx: &ToolContext, p: Point) -> Option<NodeId> {
|
|||
stack.get(next).or(stack.first()).copied()
|
||||
}
|
||||
|
||||
/// Can a click make `id` the key object? `select.key` takes only a selected top-level object. In
|
||||
/// isolation mode a click can pick a sublayer (inside an isolated layer), a compound-path member
|
||||
/// (inside an isolated compound path) or an object inside another selected object (inside an
|
||||
/// isolated group that is also selected), and `select.key` returns an error for all three.
|
||||
fn can_be_key(cx: &ToolContext, id: NodeId) -> bool {
|
||||
let inside_selected = cx.doc.ancestry(id).unwrap_or_default().iter().any(|a| *a != id && cx.selection.contains(*a));
|
||||
// `paint_owner` returns a compound-path member's compound path.
|
||||
let member = crate::xform::paint_owner(cx.doc, id) != id;
|
||||
cx.doc.node(id).is_some_and(|n| !n.is_layer()) && !inside_selected && !member
|
||||
}
|
||||
|
||||
/// Is `n` area type (text in a frame) whose frame the tools reshape? Type in perspective isn't:
|
||||
/// it transforms whole.
|
||||
pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool {
|
||||
|
|
@ -797,6 +809,68 @@ mod tests {
|
|||
assert!(click(&one, 150.0, 150.0).is_empty());
|
||||
}
|
||||
|
||||
/// In isolation mode a click makes only a selected top-level object the key, as `select.key`
|
||||
/// takes no other: a click on a sublayer's art inside an isolated layer picks the sublayer,
|
||||
/// a click inside an isolated group that is also selected picks an object inside it, and a
|
||||
/// click inside an isolated compound path picks a member.
|
||||
#[test]
|
||||
fn a_click_makes_only_a_selected_top_level_object_the_key() {
|
||||
use vectorcraft_doc::{Appearance, Document, LayerColor, Node, NodeKind};
|
||||
use vectorcraft_geom::shapes;
|
||||
let p = paint();
|
||||
let click = |d: &Document, s: &Selection, isolation: NodeId| {
|
||||
let c = ToolContext { isolation: Some(isolation), ..cx(d, s, &p) };
|
||||
let mut t = SelectionTool::default();
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0)).is_empty());
|
||||
t.pointer(&c, &ev(PointerKind::Up, 150.0, 150.0))
|
||||
};
|
||||
let path = |id: NodeId, r: Rect| Node::path(id, shapes::rectangle(r), Appearance::default_art());
|
||||
let (under, beside) = (Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(300.0, 300.0, 340.0, 340.0));
|
||||
// An isolated layer whose art under the click is in a sublayer.
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let (sub, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap();
|
||||
d.insert(Some(sub), 0, path(a, under)).unwrap();
|
||||
d.insert(Some(l), 1, path(b, beside)).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(sub);
|
||||
s.add(b);
|
||||
assert!(click(&d, &s, l).is_empty(), "a sublayer is not a key object");
|
||||
// An isolated group that is also selected.
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let (g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::group(g, vec![])).unwrap();
|
||||
d.insert(Some(g), 0, path(a, under)).unwrap();
|
||||
d.insert(Some(g), 1, path(b, beside)).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(a);
|
||||
s.add(g);
|
||||
assert!(click(&d, &s, g).is_empty(), "an object inside the selected group is not a key object");
|
||||
s.key = Some(a);
|
||||
assert_eq!(click(&d, &s, g), vec![Action::Exec("select.key".into(), json!({}))], "the key again: no key");
|
||||
// With the group not selected, the object becomes the key.
|
||||
let mut s = Selection::default();
|
||||
s.add(a);
|
||||
s.add(b);
|
||||
assert_eq!(click(&d, &s, g), vec![Action::Exec("select.key".into(), json!({"id": a.0}))]);
|
||||
// An isolated compound path. Make Compound Path clears its members' appearance, so these
|
||||
// members have none, and the click is on a's outline.
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let (c, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
let member = |id: NodeId, r: Rect| std::sync::Arc::new(Node::path(id, shapes::rectangle(r), Appearance::default()));
|
||||
let children = vec![member(a, Rect::new(150.0, 100.0, 250.0, 200.0)), member(b, beside)];
|
||||
let mut compound = Node::new(c, NodeKind::Compound { children, rule: Default::default() });
|
||||
compound.appearance = Appearance::default_art().into();
|
||||
d.insert(Some(l), 0, compound).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(a);
|
||||
s.add(b);
|
||||
assert!(click(&d, &s, c).is_empty(), "a compound-path member is not a key object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id()})))
|
||||
wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()})))
|
||||
}
|
||||
"ui.key" => {
|
||||
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
|
||||
|
|
@ -401,7 +401,7 @@ mod tests {
|
|||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::ControlRequest;
|
||||
use super::{ControlRequest, Outcome, handle};
|
||||
use crate::{Services, VectorcraftApp};
|
||||
|
||||
/// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked
|
||||
|
|
@ -433,4 +433,26 @@ mod tests {
|
|||
}
|
||||
assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string());
|
||||
}
|
||||
|
||||
/// `ui.pointer` replies with the key object: a click on one of two selected objects makes it
|
||||
/// the key, and a click on the key again lets it go.
|
||||
#[test]
|
||||
fn pointer_replies_name_the_key_object() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
|
||||
let b = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
app.run("tool.select", json!({"tool": "selection"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let click = |app: &mut VectorcraftApp| {
|
||||
let events = json!([{"kind": "down", "x": 110, "y": 20}, {"kind": "up", "x": 110, "y": 20}]);
|
||||
let (req, _) = ControlRequest::new("ui.pointer", json!({"events": events}));
|
||||
let Outcome::Done(r) = handle(app, &ctx, &req) else { panic!("not done") };
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
r["result"].get("key").cloned()
|
||||
};
|
||||
assert_eq!(click(&mut app), Some(b), "a click on a selected object makes it the key");
|
||||
assert_eq!(click(&mut app), Some(Value::Null), "a click on the key again lets it go");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3879,6 +3879,7 @@ effect Regular Pravidelný
|
|||
@msg nothing to wrap není co obtékat
|
||||
@msg object {0} cannot have children objekt {0} nemůže mít potomky
|
||||
@msg object {id} is not a path objekt {id} není cesta
|
||||
@msg object {id} is not a selected top-level object objekt {id} není vybraný objekt nejvyšší úrovně
|
||||
@msg page size {w} × {h} velikost stránky {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) stránka {0} neexistuje (PDF jich má {1})
|
||||
@msg paste unavailable vložení není k dispozici
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3583,6 +3583,7 @@ theme Appearance Erscheinungsbild
|
|||
@msg nothing to wrap nichts zu umfließen
|
||||
@msg object {0} cannot have children Objekt {0} kann keine untergeordneten Objekte haben
|
||||
@msg object {id} is not a path Objekt {id} ist kein Pfad
|
||||
@msg object {id} is not a selected top-level object Objekt {id} ist kein ausgewähltes Objekt der obersten Stufe
|
||||
@msg page size {w} × {h} Seitengröße {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) Seite {0} ist nicht vorhanden (die PDF-Datei hat {1})
|
||||
@msg paste unavailable Einfügen nicht verfügbar
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3682,6 +3682,7 @@ axis Both Ambos
|
|||
@msg nothing to wrap no hay nada que envolver
|
||||
@msg object {0} cannot have children el objeto {0} no puede tener hijos
|
||||
@msg object {id} is not a path el objeto {id} no es un trazado
|
||||
@msg object {id} is not a selected top-level object el objeto {id} no es un objeto seleccionado de nivel superior
|
||||
@msg page size {w} × {h} tamaño de página {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) la página {0} no existe (el PDF tiene {1})
|
||||
@msg paste unavailable pegar no está disponible
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3679,6 +3679,7 @@ axis Both Les deux
|
|||
@msg nothing to wrap rien à envelopper
|
||||
@msg object {0} cannot have children l’objet {0} ne peut pas avoir d’enfants
|
||||
@msg object {id} is not a path l’objet {id} n’est pas un tracé
|
||||
@msg object {id} is not a selected top-level object l’objet {id} n’est pas un objet sélectionné de premier niveau
|
||||
@msg page size {w} × {h} format de page {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) la page {0} n’existe pas (le PDF en a {1})
|
||||
@msg paste unavailable collage indisponible
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3682,6 +3682,7 @@ axis Both Entrambi
|
|||
@msg nothing to wrap niente da avvolgere
|
||||
@msg object {0} cannot have children l'oggetto {0} non può avere elementi figli
|
||||
@msg object {id} is not a path l'oggetto {id} non è un tracciato
|
||||
@msg object {id} is not a selected top-level object l'oggetto {id} non è un oggetto selezionato di primo livello
|
||||
@msg page size {w} × {h} dimensioni pagina {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) la pagina {0} non esiste (il PDF ne ha {1})
|
||||
@msg paste unavailable impossibile incollare
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3449,6 +3449,7 @@ theme Appearance 外観
|
|||
@msg no such segment 指定されたセグメントが見つかりません
|
||||
@msg no such subpath 指定されたサブパスが見つかりません
|
||||
@msg object {id} is not a path オブジェクト {id} はパスではありません
|
||||
@msg object {id} is not a selected top-level object オブジェクト {id} は選択されている最上位のオブジェクトではありません
|
||||
@msg nothing to redo やり直す操作がありません
|
||||
@msg nothing to undo 取り消す操作がありません
|
||||
@msg Expand Appearance: the selection has a basic appearance アピアランスを分割: 選択範囲は基本アピアランスです
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3678,6 +3678,7 @@ axis Both Оба
|
|||
@msg nothing to wrap нечего обтекать
|
||||
@msg object {0} cannot have children объект {0} не может иметь потомков
|
||||
@msg object {id} is not a path объект {id} не является контуром
|
||||
@msg object {id} is not a selected top-level object объект {id} не является выделенным объектом верхнего уровня
|
||||
@msg page size {w} × {h} размер страницы {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) страница {0} не существует (в PDF {1})
|
||||
@msg paste unavailable вставка недоступна
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -994,6 +994,27 @@ fn align_reflect_and_shear_param_errors_are_translated() {
|
|||
}
|
||||
}
|
||||
|
||||
/// The parameter error of `select.key` for an id that isn't a selected top-level object, whose
|
||||
/// text [`message_literals`] doesn't read, is translated whole in each language of
|
||||
/// [`COMPLETE_MESSAGES`].
|
||||
#[test]
|
||||
fn select_key_param_error_is_translated() {
|
||||
use serde_json::json;
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let rectangle = |s: &mut vectorcraft_engine::Session, x: u64| {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone()
|
||||
};
|
||||
let [a, b, unselected] = [0, 50, 100].map(|x| rectangle(&mut s, x));
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
let e = s.execute("select.key", &json!({"id": unselected})).unwrap_err();
|
||||
let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") };
|
||||
for code in COMPLETE_MESSAGES {
|
||||
let shown = message(Lang::from_code(code).unwrap(), &e.to_string());
|
||||
assert!(!shown.contains(msg.as_str()), "{code}: {shown}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Languages whose catalogs cover every status and error message.
|
||||
const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "es", "fr", "it", "ja", "ru", "uk"];
|
||||
|
||||
|
|
|
|||
|
|
@ -3666,6 +3666,7 @@ axis Both Обидві
|
|||
@msg nothing to wrap нічого обтікати
|
||||
@msg object {0} cannot have children об’єкт {0} не може мати нащадків
|
||||
@msg object {id} is not a path об’єкт {id} не є контуром
|
||||
@msg object {id} is not a selected top-level object об’єкт {id} не є виділеним об’єктом верхнього рівня
|
||||
@msg page size {w} × {h} розмір сторінки {w} × {h}
|
||||
@msg page {0} does not exist (the PDF has {1}) сторінка {0} не існує (в PDF{1})
|
||||
@msg paste unavailable вставка недоступна
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 13.
|
|
|
@ -55,12 +55,12 @@ the two can still win that race.
|
|||
|---|---|---|
|
||||
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
|
||||
| `engine.commands` | | every command with label, shortcut, params doc, enablement |
|
||||
| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does |
|
||||
| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, the key object (`key`, null without one), history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does |
|
||||
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) |
|
||||
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
|
||||
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected |
|
||||
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
||||
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was |
|
||||
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool` |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
|
||||
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it |
|
||||
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
|
||||
|
|
|
|||
11
docs/mcp.md
11
docs/mcp.md
|
|
@ -724,6 +724,17 @@ A malformed or unknown id is an error naming that value; the entire selection is
|
|||
All three commands return `{count, ids}` for the resulting selection, in selection order.
|
||||
An empty array clears the selection for `select.set` and leaves it alone for Add and Toggle.
|
||||
|
||||
`select.key {id}` makes `id` the key object that Align aligns to and Distribute Spacing spaces from (`object.align` without `to`, `object.distributeSpacing` with `spacing`), and `select.key {}` clears it.
|
||||
The id must name a selected top-level object.
|
||||
Layers, compound-path members and objects inside another selected object are not top-level objects.
|
||||
Any other id is an error naming that value, and the key is left unchanged.
|
||||
A null `id` is an error too.
|
||||
A click with the Selection tool on a selected top-level object, while two or more objects are selected, runs `select.key {id}`, and a click on the key object again runs `select.key {}`.
|
||||
On a single selected object, or on a group selected with only its own members, `select.key` sets no key.
|
||||
It returns `{key}`, the key object's id or null.
|
||||
`document.inspect` (the `inspect_document` tool) reports the key object as `key`, null without one.
|
||||
The `ui.pointer` and `pointer_gesture` replies list it as `key` beside `selection`.
|
||||
|
||||
`edit.clear {ids?}` deletes the exact selected objects, including individual compound-path members;
|
||||
their unselected siblings remain in the compound. Supplying `ids` works without a selection and
|
||||
overrides selected anchors or guides. Every explicit id is validated before any deletion; a malformed
|
||||
|
|
|
|||
|
|
@ -399,6 +399,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
|||
A key object needs another selected object outside it. A Shift-click or any other change that leaves one object selected clears the key, and `select.key` sets none on one object or on a group selected with only its own members. With one object selected, the Align panel, the Control bar and the Properties panel align it to the artboard whatever Align To is set to.
|
||||
With two or more objects selected and no key object, Align to Key Object makes the topmost of them the key, whatever order they were selected in, and the Align panel, the Control bar and the Properties panel align to it. Align To keeps its Selection or Artboard choice and shows it again once the key is cleared.
|
||||
Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784).
|
||||
Agents read the key object: `select.key` returns it, and `document.inspect` and the `ui.pointer` and `pointer_gesture` replies report it as `key`. `select.key` with an id that isn't a selected top-level object returns an error and leaves the key unchanged.
|
||||
Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself.
|
||||
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
|
||||
- **Compound paths:** Make keeps the backmost object's appearance and each contour; selecting a member in Direct Selection reads and changes the compound's fill rule in Attributes. Direction remains a per-member edit. Rendered holes, unchanged contour geometry, no-op history and Undo/Redo are covered by `engine/tests/compound_attributes.rs` (#1197). Behaviour reference: [public compound-path documentation](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-paths.html).
|
||||
|
|
|
|||
Loading…
Reference in New Issue