Files
vectorcraft/crates/engine/src/cmd/docmenu.rs
T
bflatasticandClaude Opus 5.5 6a446b4fa3 M4.55: File Info with author, description, keyword chips, rating and copyright, written to SVG, PDF and PNG
file.info takes and reports the title, author, author title, description,
keywords, rating, copyright status, notice and URL in one undo step, plus
the read-only created (file.new) and modified (every save to a file) dates;
junk values are refused. Document.metadata (DocMetadata) round-trips
natively. PDF export writes author, subject and keywords to the info
dictionary and XMP, PNG export writes tEXt/iTXt chunks, SVG export writes
<desc> and, with the metadata option, every field as Dublin Core. The File
Info dialog (fileInfo, ui.fileInfoDialog) edits it with keyword chips and
star ratings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:55:47 +07:00

487 lines
17 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, ColorMode, Guide, Node, NodeId, NodeKind, TextKind};
use super::colorcmds::{Recolor, Scope, to_cmyk};
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?} remember the current selection for this session (default name \"Selection N\") → {name}",
has_selection,
save_selection
),
cmd!("select.recall", "Recall Selection", ["Select"], None, "{name} → {count}", has_doc, recall_selection),
cmd!("select.editSaved", "Edit Selection…", ["Select"], None, "{name, newName?: rename, delete?: bool}", has_doc, edit_saved),
cmd!(query "select.savedList", "Saved Selections", [], None, "{} → [name…] for the active document", has_doc, |s, _| {
let t = s.doc()?.title();
Ok(json!(s.menu.saved_selections.iter().filter(|x| x.0 == t).map(|x| x.1.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 and swatch to the mode)}",
has_doc,
color_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> {
let st = s.doc()?;
let title = st.title();
let ids = st.selection.objects.clone();
let name = match str_param(p, "name").filter(|n| !n.trim().is_empty()) {
Some(n) => n.trim().to_string(),
None => {
let mut i = 1;
while s.menu.saved_selections.iter().any(|x| x.0 == title && x.1 == format!("Selection {i}")) {
i += 1;
}
format!("Selection {i}")
}
};
s.menu.saved_selections.retain(|x| !(x.0 == title && x.1 == name));
s.menu.saved_selections.push((title, name.clone(), ids));
Ok(json!({ "name": name }))
}
fn saved_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?;
let t = s.doc()?.title();
s.menu.saved_selections.iter().position(|x| x.0 == t && x.1 == name).ok_or_else(|| bad(cmd, format!("no saved selection `{name}`")))
}
fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
let i = saved_index(s, p, "select.recall")?;
let ids = s.menu.saved_selections[i].2.clone();
s.select(|d, sel| sel.set(ids.into_iter().filter(|id| d.node(*id).is_some())))?;
Ok(json!({ "count": s.doc()?.selection.len() }))
}
fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
let i = saved_index(s, p, "select.editSaved")?;
if bool_or(p, "delete", false) {
s.menu.saved_selections.remove(i);
} else if let Some(n) = str_param(p, "newName").filter(|n| !n.trim().is_empty()) {
s.menu.saved_selections[i].1 = n.trim().to_string();
}
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 }))
}
fn color_mode(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.documentColorMode";
let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
Some("cmyk") => ColorMode::Cmyk,
Some("rgb") => ColorMode::Rgb,
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
};
let convert = bool_or(p, "convert", true);
if s.doc()?.doc.color_mode == mode {
return ok();
}
s.edit("Document Color Mode", |d, _| {
set_color_mode(d, mode, convert);
Ok(())
})?;
ok()
}
/// Put `d` in colour `mode`; with `convert`, every colour of its art and swatches too.
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool) {
d.color_mode = mode;
if !convert {
return;
}
let f = move |c: vectorcraft_color::Color| match mode {
ColorMode::Cmyk => to_cmyk(c),
ColorMode::Rgb => {
let [r, g, b] = c.to_rgb();
vectorcraft_color::Color::rgb(r, g, b)
}
};
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers);
for sw in d.swatches_iter_mut() {
if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint {
*color = f(*color);
}
}
}