Files
vectorcraft/crates/engine/src/cmd/docmenu.rs
T
bflatasticandClaude Opus 5.5 249b34fc7c Select: saved selections checked when a file opens, names capped, docs (review of #318)
A file's saved selections are untrusted: on load at most 25 are kept, names
are trimmed, capped at 255 characters and unique, and ids the document
doesn't have are dropped (after fix_next_id such an id would select whatever
object is drawn next). Save and Edit Selection cap names the same way. The
limit, the name cleanup and the default "Selection N" live on
SavedSelection, shared by the engine, the Save Selection dialog and the
Select menu's slot table. docs/mcp.md documents the select.save /
savedList / recall / editSaved commands; ROADMAP counts 13 stubbed menu items
and names what's still missing (Update Selection, the Layers panel menu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:24:44 +07:00

497 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Select menu long tail (Same → text/symbol attributes, Object → Direction Handles / text kinds /
//! brush strokes, Save/recall selections), View → Guides, and File → Close All / Document Color
//! Mode / File Info.
use std::collections::BTreeSet;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection, TextKind};
use super::edit::selected_roots;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("select.same.symbolInstance", "Symbol Instance", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same(
s,
"select.same.symbolInstance",
|a, b| match (&a.kind, &b.kind) {
(NodeKind::SymbolInstance { symbol: x, .. }, NodeKind::SymbolInstance { symbol: y, .. }) => x == y,
_ => false,
}
)),
cmd!("select.same.fontFamily", "Font Family", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
s,
"select.same.fontFamily",
|a, b| a.font_family == b.font_family
)),
cmd!("select.same.fontFamilyStyle", "Font Family & Style", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
s,
"select.same.fontFamilyStyle",
|a, b| a.font_family == b.font_family && a.font_style == b.font_style
)),
cmd!("select.same.fontFamilyStyleSize", "Font Family, Style & Size", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| {
same_text(s, "select.same.fontFamilyStyleSize", |a, b| {
a.font_family == b.font_family && a.font_style == b.font_style && (a.size - b.size).abs() < 1e-6
})
}),
cmd!("select.same.fontSize", "Font Size", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
s,
"select.same.fontSize",
|a, b| (a.size - b.size).abs() < 1e-6
)),
cmd!("select.same.textFillColor", "Text Fill Color", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
s,
"select.same.textFillColor",
|a, b| a.fill == b.fill
)),
cmd!("select.same.textStrokeColor", "Text Stroke Color", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
s,
"select.same.textStrokeColor",
|a, b| a.stroke == b.stroke
)),
cmd!(
"select.object.directionHandles",
"Direction Handles",
["Select", "Object"],
None,
"{} direct-select every anchor (showing all handles) of the selected paths → {anchors}",
has_selection,
direction_handles
),
cmd!("select.object.pointText", "Point Text Objects", ["Select", "Object"], None, "{} → {count}", has_doc, |s, _| by_kind(
s,
|n| matches!(
&n.kind,
NodeKind::Text(t) if matches!(t.kind, TextKind::Point)
)
)),
cmd!("select.object.areaText", "Area Text Objects", ["Select", "Object"], None, "{} → {count}", has_doc, |s, _| by_kind(s, |n| matches!(
&n.kind,
NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. })
))),
cmd!("select.object.brushStrokes", "Brush Strokes", ["Select", "Object"], None, "{} → {count}", has_doc, |s, _| by_kind(s, |n| n
.appearance
.stroke()
.is_some_and(|st| st.brush.is_some()))),
cmd!(
"select.object.bristleBrushStrokes",
"Bristle Brush Strokes",
["Select", "Object"],
None,
"{} objects whose brush name contains \"bristle\" → {count}",
has_doc,
|s, _| by_kind(s, |n| n
.appearance
.stroke()
.and_then(|st| st.brush.as_deref())
.is_some_and(|b| b.to_ascii_lowercase().contains("bristle")))
),
cmd!(
"select.save",
"Save Selection…",
["Select"],
None,
"{name?} save the current selection under a name, in the document (default \"Selection N\"; an existing name is replaced; at most 25, names up to 255 characters) → {name}",
has_selection,
save_selection
),
cmd!(
"select.recall",
"Recall Selection",
["Select"],
None,
"{name} select the objects of a saved selection (those since deleted are left out) → {count}",
has_saved_selection,
recall_selection
),
cmd!(
"select.editSaved",
"Edit Selection…",
["Select"],
None,
"{name, newName?: rename, delete?: bool} | {edits: [{name, newName?, delete?}…]} rename or delete saved selections, all in one undo step; names are those before the edit and must stay unique",
has_saved_selection,
edit_saved
),
cmd!(query "select.savedList", "Saved Selections", [], None, "{} → [name…] for the active document", has_doc, |s, _| {
Ok(json!(s.doc()?.doc.saved_selections.iter().map(|x| x.name.clone()).collect::<Vec<_>>()))
}),
cmd!(
"view.guides.make",
"Make Guides",
["View", "Guides"],
Some("Cmd+5"),
"{} turn the selected paths into guides → {count}",
has_selection,
make_guides
),
cmd!(
"view.guides.release",
"Release Guides",
["View", "Guides"],
Some("Cmd+Alt+5"),
"{} turn the selected guides (or, with none selected, all guide paths) back into paths → {count}",
has_doc,
release_guides
),
cmd!(
query "view.guides.lock",
"Lock Guides",
["View", "Guides"],
Some("Cmd+Alt+;"),
"{locked?: bool} toggle (or set) the session's guide lock → {locked}",
has_doc,
lock_guides
),
cmd!(
"view.guides.clear",
"Clear Guides",
["View", "Guides"],
None,
"{} delete all ruler guides and guide paths → {count}",
has_doc,
clear_guides
),
cmd!("guide.add", "Add Guide", [], None, "{vertical: bool, pos: pt (x for vertical, y for horizontal)} → {index}", has_doc, guide_add),
cmd!("guide.remove", "Remove Guide", [], None, "{index}", guides_unlocked, guide_remove),
cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", guides_unlocked, guide_move),
cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all),
cmd!(
"file.documentColorMode",
"Document Color Mode",
["File", "Document Color Mode"],
None,
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols and swatches through the colour settings; swatch links kept; greys stay greys), intent?} → {changed}",
has_doc,
super::colormgmt::convert_mode
),
cmd!(
"file.info",
"File Info…",
["File"],
Some("Cmd+Alt+Shift+I"),
"{title?, author?, authorTitle?, description?, keywords?: [string…]|\"a, b\" (each once), rating?: 0–5, copyrightStatus?: \"unknown\"|\"copyrighted\"|\"publicDomain\", copyrightNotice?, copyrightUrl?} set the File Info in one undo step (SVG with metadata, PDF and PNG exports carry it); no params → {title, author, authorTitle, description, keywords, rating, copyrightStatus, copyrightNotice, copyrightUrl, created, modified (ISO 8601 UTC or null; read-only), colorMode, units, artboards, objects}",
has_doc,
super::fileinfo::file_info
),
]
}
impl Session {
/// View → Guides → Lock Guides state (canvas guide dragging should honour it).
pub fn guides_locked(&self) -> bool {
self.menu.guides_locked
}
}
// ---------- Select ----------
fn candidates(s: &Session, f: impl Fn(&Node) -> bool) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let mut ids = vec![];
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
l.walk(&mut |n| {
if !n.is_container() && n.visible && !n.locked && f(n) {
ids.push(n.id);
}
});
}
Ok(ids)
}
fn finish_same(s: &mut Session, cmd: &str, ids: Vec<NodeId>) -> Result<Value> {
let n = ids.len();
s.select(|_, sel| sel.set(ids))?;
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({})));
Ok(json!({ "count": n }))
}
fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Value> {
let st = s.doc()?;
let r = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
let ids = candidates(s, |n| eq(n, &r))?;
finish_same(s, cmd, ids)
}
fn first_text_style(s: &Session) -> Option<CharStyle> {
let st = s.active()?;
let mut out = None;
for id in &st.selection.objects {
st.doc.node(*id)?.walk(&mut |n| {
if out.is_none()
&& let NodeKind::Text(t) = &n.kind
{
out = Some(t.first_style());
}
});
if out.is_some() {
break;
}
}
out
}
fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool) -> Result<Value> {
let r = first_text_style(s).ok_or_else(|| bad(cmd, "select a text object"))?;
let ids = candidates(s, |n| match &n.kind {
NodeKind::Text(t) => t.runs.iter().any(|run| eq(&run.style, &r)),
_ => false,
})?;
finish_same(s, cmd, ids)
}
fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result<Value> {
let ids = candidates(s, f)?;
let n = ids.len();
s.select(|_, sel| sel.set(ids))?;
Ok(json!({ "count": n }))
}
fn direction_handles(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let d = &s.doc()?.doc;
let mut items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = vec![];
for r in &roots {
if let Some(n) = d.node(*r) {
n.walk(&mut |c| {
if let Some(p) = c.path_data() {
items.push((c.id, p.anchors().map(|(si, ai, _)| (si, ai)).collect()));
}
});
}
}
let total: usize = items.iter().map(|x| x.1.len()).sum();
s.select(|_, sel| {
sel.clear();
for (id, refs) in items {
sel.add(id);
sel.anchors.insert(id, refs);
}
})?;
Ok(json!({ "anchors": total }))
}
fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.save";
let st = s.doc()?;
let ids = st.selection.objects.clone();
let saved = &st.doc.saved_selections;
let name = str_param(p, "name").and_then(SavedSelection::clean_name).unwrap_or_else(|| SavedSelection::default_name(saved));
let existing = saved.iter().position(|x| x.name == name);
if existing.is_none() && saved.len() >= SavedSelection::MAX {
return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX)));
}
s.edit("Save Selection", |d, _| {
let entry = SavedSelection { name: name.clone(), objects: ids };
match existing.and_then(|i| d.saved_selections.get_mut(i)) {
Some(x) => *x = entry,
None => d.saved_selections.push(entry),
}
Ok(())
})?;
Ok(json!({ "name": name }))
}
fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.recall";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?;
let ids = s.doc()?.doc.saved_selections.iter().find(|x| x.name == name).map(|x| x.objects.clone());
let ids = ids.ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
s.select(|d, sel| sel.set(ids.into_iter().filter(|id| d.node(*id).is_some())))?;
Ok(json!({ "count": s.doc()?.selection.len() }))
}
/// Recall and Edit Selection… need a document with at least one saved selection.
fn has_saved_selection(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.doc.saved_selections.is_empty() { Err("no saved selections".into()) } else { Ok(()) }
}
fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.editSaved";
let edits = match p.get("edits").and_then(Value::as_array) {
Some(list) => list.as_slice(),
None => std::slice::from_ref(p),
};
let before = s.doc()?.doc.saved_selections.clone();
// Edits name the selections as they are now, so they don't depend on each other's order.
let mut after: Vec<Option<SavedSelection>> = before.iter().cloned().map(Some).collect();
// Files may hold any number of saved selections: keep this linear.
let mut index = std::collections::HashMap::with_capacity(before.len());
for (i, x) in before.iter().enumerate() {
index.entry(x.name.as_str()).or_insert(i);
}
for e in edits {
let name = str_param(e, "name").ok_or_else(|| bad(C, "missing name"))?;
let i = *index.get(name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
let Some(slot) = after.get_mut(i) else { continue };
if bool_or(e, "delete", false) {
*slot = None;
} else if let Some(n) = str_param(e, "newName").and_then(SavedSelection::clean_name)
&& let Some(x) = slot
{
x.name = n;
}
}
let after: Vec<SavedSelection> = after.into_iter().flatten().collect();
let mut names = std::collections::HashSet::with_capacity(after.len());
for x in &after {
if !names.insert(x.name.as_str()) {
return Err(bad(C, format!("two saved selections would be named `{}`", x.name)));
}
}
if after != before {
s.edit("Edit Selection", |d, _| {
d.saved_selections = after;
Ok(())
})?;
}
ok()
}
// ---------- Guides ----------
fn guides_unlocked(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.menu.guides_locked { Err("guides are locked".into()) } else { Ok(()) }
}
fn set_guide_flag(n: &mut Node, on: bool, count: &mut usize) {
match &mut n.kind {
NodeKind::Path { guide, clipping: false, .. } => {
if *guide != on {
*guide = on;
*count += 1;
}
}
_ => {
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
set_guide_flag(Arc::make_mut(c), on, count);
}
}
}
}
}
fn make_guides(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let n = s.edit("Make Guides", |d, sel| {
let mut n = 0;
for r in &roots {
if let Some(node) = d.node_mut(*r) {
set_guide_flag(node, true, &mut n);
}
}
if n == 0 {
return Err(EngineError::Other("Make Guides: select paths".into()));
}
sel.clear();
Ok(n)
})?;
Ok(json!({ "count": n }))
}
fn guide_paths(d: &vectorcraft_doc::Document) -> Vec<NodeId> {
let mut v = vec![];
d.walk(|n| {
if matches!(n.kind, NodeKind::Path { guide: true, .. }) {
v.push(n.id);
}
});
v
}
fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let all = guide_paths(&st.doc);
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
let targets: Vec<NodeId> = if sel.is_empty() { all.into_iter().filter(|id| st.doc.is_editable(*id)).collect() } else { sel };
if targets.is_empty() {
return Err(EngineError::Other("Release Guides: no guides".into()));
}
let n = targets.len();
s.edit("Release Guides", |d, sel| {
for id in &targets {
if let Some(NodeKind::Path { guide, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
*guide = false;
}
}
sel.set(targets.iter().copied());
Ok(())
})?;
Ok(json!({ "count": n }))
}
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
s.menu.guides_locked = v;
Ok(json!({ "locked": v }))
}
fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let paths = guide_paths(&st.doc);
let n = paths.len() + st.doc.guides.len();
if n == 0 {
return Ok(json!({ "count": 0 }));
}
s.edit("Clear Guides", |d, _| {
d.guides.clear();
for id in &paths {
let _ = d.remove(*id);
}
Ok(())
})?;
Ok(json!({ "count": n }))
}
fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
let pos = f64_req(p, "pos", "guide.add")?;
let vertical = bool_or(p, "vertical", false);
let i = s.edit("New Guide", |d, _| {
d.guides.push(Guide { vertical, pos });
Ok(d.guides.len() - 1)
})?;
Ok(json!({ "index": i }))
}
fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing index"))? as usize;
if i >= s.doc()?.doc.guides.len() {
return Err(bad(cmd, "no such guide"));
}
Ok(i)
}
fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
let i = guide_index(s, p, "guide.remove")?;
s.edit("Delete Guide", |d, _| {
d.guides.remove(i);
Ok(())
})?;
ok()
}
fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
let i = guide_index(s, p, "guide.move")?;
let pos = f64_req(p, "pos", "guide.move")?;
s.edit("Move Guide", |d, _| {
d.guides[i].pos = pos;
Ok(())
})?;
ok()
}
// ---------- File ----------
fn close_all(s: &mut Session, _: &Value) -> Result<Value> {
let n = s.documents().len();
while !s.documents().is_empty() {
s.close_document(s.documents().len() - 1);
}
Ok(json!({ "closed": n }))
}