//! 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 vectorcraft_geom::Vec2; use super::edit::selected_roots; use super::pathops::Beside; use super::select::{selectable_ids, walk_selectable}; use super::*; pub fn specs() -> Vec { 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 and the paths inside the selected objects, leaving out paths that are hidden or locked, inside a hidden or locked object or layer, on a template layer, or outside the isolated group or layer → {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, leaving out those deleted since and those that are hidden or locked, inside a hidden or locked object or layer, on a template layer, or outside the isolated group or layer → {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::>())) }), 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), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas), layer?: id (the layer it is on, shown, hidden, locked and deleted with it; default the current layer)} → {index}", has_doc, guide_add ), cmd!( query "guide.list", "Guides", [], None, "{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), shown, editable (its layer shown and unlocked)}…] the ruler guides", has_doc, guide_list ), cmd!( "guide.select", "Select Guides", [], None, "{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}", guides_unlocked, guide_select ), cmd!( "guide.remove", "Remove Guide", [], None, "{index?} delete ruler guide `index`, else the selected ones (as Delete / edit.clear does) → {count}", guides_unlocked, guide_remove ), cmd!( "guide.move", "Move Guide", [], None, "{index, pos: pt} | {dx?, dy?: pt, copy?: bool} put ruler guide `index` at `pos`, or move the selected guides (vertical ones by dx, horizontal ones by dy); copy leaves them and selects the moved copies", guides_unlocked, guide_move ), cmd!( "guide.setLayer", "Move Guide to Layer", [], None, "{index?, indexes?: [index…], layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does → {count}", guides_unlocked, guide_set_layer ), 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, pattern tiles and swatches through the colour settings; swatch links kept; Gray colours stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB greys separate through the CMYK profile like any colour, into four-colour greys and a rich black) | \"black\" (to CMYK, RGB greys with R = G = B go on the black plate only, K = their grey value)} → {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?, created?, modified?, colorMode?, units?, artboards?, objects? (the last six only as read back; ignored)} 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> { let st = s.doc()?; let mut ids = vec![]; walk_selectable(&st.doc, st.isolation, |n| { if !n.is_container() && f(n) { ids.push(n.id); } true }); Ok(ids) } fn finish_same(s: &mut Session, cmd: &str, ids: Vec) -> Result { let n = ids.len(); let from = s.doc()?.selection.objects.clone(); s.select(|_, sel| sel.set(ids))?; s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({}), from)); Ok(json!({ "count": n })) } fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result { 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 { 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 { 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 { 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 { let roots = selected_roots(s)?; let st = s.doc()?; let d = &st.doc; // Only paths that `walk_selectable` visits. let can = selectable_ids(d, st.isolation); 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 can.contains(&c.id) && 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 { 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 { 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}`")))?; let st = s.doc()?; let can = selectable_ids(&st.doc, st.isolation); s.select(|_, sel| sel.set(ids.into_iter().filter(|id| can.contains(id))))?; 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 { 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> = 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 = 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 *before != after { s.edit("Edit Selection", |d, _| { d.saved_selections = after.into(); 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 { 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 { 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 { let st = s.doc()?; let all = guide_paths(&st.doc); let sel: Vec = all.iter().copied().filter(|id| st.selection.contains(*id)).collect(); let targets: Vec = 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 { let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked); s.menu.guides_locked = v; // Locked guides can't stay selected, ruler guides or guide paths ([`Session::select`] lets // those go). let st = s.doc()?; if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) { s.select(|_, sel| sel.guides.clear())?; } Ok(json!({ "locked": v })) } fn clear_guides(s: &mut Session, _: &Value) -> Result { 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(); let mut gone = Beside::replacing(d, &paths); for id in &paths { if d.node(*id).is_some() { gone.put(d, *id, vec![])?; } } gone.finish(d) })?; Ok(json!({ "count": n })) } fn guide_add(s: &mut Session, p: &Value) -> Result { let pos = f64_req(p, "pos", "guide.add")?; let vertical = bool_or(p, "vertical", false); let artboard = match p.get("artboard").filter(|v| !v.is_null()) { Some(v) => { let boards = &s.doc()?.doc.artboards; let ab = v.as_u64().and_then(|i| boards.get(usize::try_from(i).ok()?)).ok_or_else(|| EngineError::Other("no such artboard".into()))?; Some(ab.id) } None => None, }; let st = s.doc()?; let layer = match id_param(p, "layer") { Some(l) if st.doc.node(l).is_some_and(Node::is_layer) => Some(l), Some(_) => return Err(bad("guide.add", "layer must name a layer or sublayer")), // Where new art goes (refused when no layer is shown and unlocked), or that group's layer. None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }), }; let i = s.edit("New Guide", |d, _| { d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) }); Ok(d.guides.len() - 1) })?; Ok(json!({ "index": i })) } fn guide_list(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let row = |(i, g): (usize, &Guide)| { let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": st.selection.guides.contains(&i) }); if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) { r["artboard"] = json!(ab); } if let Some(l) = g.layer { r["layer"] = json!(l.0); } r["shown"] = json!(st.doc.guide_shown(g)); r["editable"] = json!(st.doc.guide_editable(g)); r }; Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect())) } /// Ruler guide `index` of the active document (`index` validated). fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { let i = v.and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "an index must be a whole number"))?; if i >= s.doc()?.doc.guides.len() { return Err(bad(cmd, format!("no guide {i}"))); } Ok(i) } /// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer. fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { let i = guide_index(s, v, cmd)?; let d = &s.doc()?.doc; if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) { return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); } Ok(i) } fn guide_select(s: &mut Session, p: &Value) -> Result { let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?; let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::>>()?; if bool_or(p, "toggle", false) { // Added to (or taken from) what is selected, the art included. s.select(|_, sel| { for i in picked { sel.toggle_guide(i); } })?; } else { s.select(|_, sel| sel.set_guides(picked))?; } Ok(json!({ "selected": s.doc()?.selection.guides })) } /// Delete ruler guide `index`, else the selected guides, in one undo step → {count}. pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { let gone: Vec = match p.get("index") { Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?], None => s.doc()?.selection.guides.clone(), }; if gone.is_empty() { return Err(bad("guide.remove", "no guide selected")); } let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" }; s.edit(label, |d, sel| { d.retain_guides(sel, |i, _| !gone.contains(&i)); Ok(()) })?; Ok(json!({ "count": gone.len() })) } /// Put ruler guide `index`, else the selected guides, on layer `layer`, in one undo step. fn guide_set_layer(s: &mut Session, p: &Value) -> Result { const C: &str = "guide.setLayer"; let d = &s.doc()?.doc; let layer = id_param(p, "layer").filter(|l| d.node(*l).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "layer must name a layer or sublayer"))?; // As objects dragged in the Layers panel: never onto a locked layer. if super::layer::locked_within(d, layer) { return Err(EngineError::Other("the layer or group is locked".into())); } let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { (Some(v), _) => vec![editable_guide_index(s, Some(v), C)?], (None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::>>()?, (None, None) => s.doc()?.selection.guides.clone(), }; if moving.is_empty() { return Err(bad(C, "no guide selected (or give an index)")); } s.edit("Move Guide to Layer", |d, _| { for i in &moving { if let Some(g) = d.guides.get_mut(*i) { g.layer = Some(layer); } } Ok(()) })?; Ok(json!({ "count": moving.len() })) } /// Put ruler guide `index` at `pos`, else move (or copy) the selected guides, in one undo step. pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("index") { let i = editable_guide_index(s, Some(v), "guide.move")?; let pos = f64_req(p, "pos", "guide.move")?; s.edit("Move Guide", |d, _| { if let Some(g) = d.guides.get_mut(i) { g.pos = pos; } Ok(()) })?; return ok(); } let moving = s.doc()?.selection.guides.clone(); if moving.is_empty() { return Err(bad("guide.move", "no guide selected (or give an index)")); } let (dx, dy, copy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0), bool_or(p, "copy", false)); s.edit(if copy { "Copy Guide" } else { "Move Guide" }, |d, sel| { let mut copies = vec![]; for &i in &moving { let Some(g) = d.guides.get_mut(i) else { continue }; let moved = g.moved(Vec2::new(dx, dy)); if copy { copies.push(moved); } else { *g = moved; } } if copy { let first = d.guides.len(); d.guides.extend(copies); sel.set_guides(first..d.guides.len()); } Ok(()) })?; ok() } // ---------- File ---------- fn close_all(s: &mut Session, _: &Value) -> Result { let n = s.documents().len(); while !s.documents().is_empty() { s.close_document(s.documents().len() - 1); } Ok(json!({ "closed": n })) }