Files
vectorcraft/crates/engine/src/cmd/panelcmds.rs
T

553 lines
22 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.
//! Commands the panels need beyond the core set: in-place gradient editing (Gradient panel),
//! appearance item duplicate/reorder, artboard reorder/duplicate, swatch groups, graphic style
//! management and the advanced Character/Paragraph attributes.
use serde_json::{Value, json};
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop, Paint, SwatchGroup};
use vectorcraft_doc::{Document, NodeKind};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"paint.editGradient",
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
has_doc,
edit_gradient
),
cmd!(
"stroke.setAdvanced",
"Stroke Options",
[],
None,
"{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, ids?} Stroke panel extras",
has_doc,
stroke_advanced
),
cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index}", has_selection, duplicate_item),
cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to} (paint-order indices)", has_selection, move_item),
cmd!("artboard.reorder", "Move Artboard Up/Down", [], None, "{index, to} change an artboard's number", has_doc, artboard_reorder),
cmd!(
"artboard.duplicate",
"Duplicate Artboard",
["Window", "Artboards"],
None,
"{index} copy placed right of the last artboard",
has_doc,
artboard_duplicate
),
cmd!(
"swatch.newGroup",
"New Color Group",
["Window", "Swatches"],
None,
"{name?, swatches?: [names] (moved into the group), colors?: [colour] (added as new swatches)}",
has_doc,
swatch_new_group
),
cmd!("swatch.duplicate", "Duplicate Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_duplicate),
cmd!("swatch.sortByName", "Sort by Name", ["Window", "Swatches"], None, "{}", has_doc, swatch_sort),
cmd!("graphicStyle.delete", "Delete Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_delete),
cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_duplicate),
cmd!(
"text.setFormat",
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}",
has_doc,
set_format
),
]
}
// ---------- gradient ----------
/// Leaf paint targets (groups apply to their children), like the Swatches/Color panels.
fn leaf_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
let mut out = vec![];
for id in ids {
let Some(n) = d.node(id) else { continue };
match &n.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| {
if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) {
out.push(c.id)
}
}),
_ => out.push(id),
}
}
let comp: Vec<_> = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect();
out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c)));
Ok(out)
}
/// Apply the gradient edits in `p` to `paint` (pure; unit-tested).
pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<vectorcraft_geom::Rect>) -> std::result::Result<Paint, String> {
let mut gp = match paint {
Paint::Gradient(g) => (**g).clone(),
_ => GradientPaint::new(Gradient::default()),
};
if let Some(k) = str_param(p, "kind") {
let kind = match k.to_ascii_lowercase().as_str() {
"linear" => GradientKind::Linear,
"radial" => GradientKind::Radial,
"freeform" => GradientKind::Freeform,
_ => return Err(format!("unknown gradient kind `{k}`")),
};
if kind != gp.gradient.kind {
gp.gradient.kind = kind;
gp.geom = None;
}
}
if let Some(stops) = p.get("stops") {
let arr = stops.as_array().ok_or("`stops` must be an array")?;
let mut out = Vec::with_capacity(arr.len());
for st in arr {
let offset = st.get("offset").and_then(Value::as_f64).ok_or("stop needs `offset`")?;
let color = st.get("color").and_then(color_value).ok_or("stop needs a valid `color`")?;
let opacity = st.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0);
let midpoint = st.get("midpoint").and_then(Value::as_f64).unwrap_or(0.5);
out.push(GradientStop {
offset: offset.clamp(0.0, 1.0) as f32,
color,
opacity: opacity.clamp(0.0, 1.0) as f32,
midpoint: midpoint.clamp(0.13, 0.87) as f32,
});
}
if out.len() < 2 {
return Err("a gradient needs at least two stops".into());
}
gp.gradient.stops = out;
gp.gradient.sort();
gp.swatch = None;
}
if bool_or(p, "reverse", false) {
gp.gradient.reverse();
// Midpoints belong to the segment to the right; mirror them.
let n = gp.gradient.stops.len();
let mids: Vec<f32> = gp.gradient.stops.iter().map(|s| s.midpoint).collect();
for i in 0..n {
gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 };
}
}
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
let a = ((a + 180.0).rem_euclid(360.0)) - 180.0;
gp.angle = a;
if let Some(g) = &mut gp.geom {
let r = a.to_radians();
let dir = vectorcraft_geom::Vec2::new(r.cos(), -r.sin());
if gp.gradient.kind == GradientKind::Radial {
g.end = g.start + dir * g.length();
} else {
let c = g.start.midpoint(g.end);
let half = g.length() / 2.0;
g.start = c - dir * half;
g.end = c + dir * half;
}
}
}
if let Some(asp) = p.get("aspect").and_then(Value::as_f64) {
let asp = (asp / 100.0).clamp(0.005, 327.67);
if gp.geom.is_none()
&& let Some(b) = bounds
{
gp.geom = Some(gp.resolve(b));
}
if let Some(g) = &mut gp.geom {
g.aspect = asp;
}
}
Ok(Paint::Gradient(Box::new(gp)))
}
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.editGradient";
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
let ids = leaf_targets(s, p)?;
// Validate against the defaults first so bad params fail without touching the document.
let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() };
let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?;
if ids.is_empty() {
if stroke {
s.paint.stroke = new_default;
} else {
s.paint.fill = new_default;
}
return Ok(json!({"ids": []}));
}
let mut err = None;
s.edit("Gradient", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
let cur = if stroke { &mut r.style.stroke } else { &mut r.style.fill };
match apply_gradient_edit(cur, p, None) {
Ok(np) => *cur = np,
Err(e) => err = Some(e),
}
}
continue;
}
let b = n.geometric_bounds();
let ap = &mut n.appearance;
let cur = if stroke { ap.stroke_paint() } else { ap.fill_paint() };
match apply_gradient_edit(&cur, p, b) {
Ok(np) => {
if stroke {
ap.set_stroke(np)
} else {
ap.set_fill(np)
}
}
Err(e) => err = Some(e),
}
}
match err.take() {
Some(e) => Err(bad(C, e)),
None => Ok(()),
}
})?;
if stroke {
s.paint.stroke = new_default;
} else {
s.paint.fill = new_default;
}
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
// ---------- appearance ----------
fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.duplicateItem", "missing index"))? as usize;
let ids = leaf_targets(s, p)?;
s.edit("Duplicate Item", |d, _| {
let mut any = false;
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
if let Some(item) = n.appearance.items.get(idx).cloned() {
n.appearance.items.insert(idx + 1, item);
any = true;
}
}
if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) }
})?;
ok()
}
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.moveItem";
let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing from"))? as usize;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
let ids = leaf_targets(s, p)?;
s.edit("Reorder Appearance", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
let items = &mut n.appearance.items;
if from >= items.len() {
return Err(bad(C, format!("no item at index {from}")));
}
let it = items.remove(from);
let to = to.min(items.len());
items.insert(to, it);
}
Ok(())
})?;
ok()
}
// ---------- artboards ----------
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "artboard.reorder";
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
s.edit("Reorder Artboards", |d, _| {
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
}
let a = d.artboards.remove(i);
let to = to.min(d.artboards.len());
d.artboards.insert(to, a);
Ok(())
})?;
ok()
}
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let index = s.edit("Duplicate Artboard", |d, _| {
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max);
let mut a = src.clone();
a.id = d.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1;
a.name = format!("{} copy", src.name);
let dx = right + 20.0 - src.rect.x0;
a.rect = vectorcraft_geom::Rect::new(src.rect.x0 + dx, src.rect.y0, src.rect.x1 + dx, src.rect.y1);
d.artboards.push(a);
Ok(d.artboards.len() - 1)
})?;
Ok(json!({"index": index}))
}
// ---------- swatches ----------
fn unique_name(d: &Document, base: &str) -> String {
let exists = |n: &str| d.swatch(n).is_some() || d.swatch_groups.iter().any(|g| g.name == n);
if !exists(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !exists(n)).unwrap_or_else(|| base.to_string())
}
fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
let names: Vec<String> =
p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
let colors: Vec<vectorcraft_color::Color> =
p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default();
let requested = str_param(p, "name").map(str::to_string);
let name = s.edit("New Color Group", |d, _| {
let name = unique_name(d, requested.as_deref().unwrap_or("Color Group"));
let mut group = SwatchGroup { name: name.clone(), swatches: vec![] };
for n in &names {
if let Some(pos) = d.swatches.iter().position(|sw| &sw.name == n) {
group.swatches.push(d.swatches.remove(pos));
}
}
for c in &colors {
let [r, g, b] = c.to_rgb();
let nm = format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round());
group.swatches.push(vectorcraft_color::Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false });
}
d.swatch_groups.push(group);
Ok(name)
})?;
Ok(json!({"name": name}))
}
fn swatch_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string();
let new = s.edit("Duplicate Swatch", |d, _| {
let src = d.swatch(&name).cloned().ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
let nm = unique_name(d, &format!("{name} copy"));
let copy = vectorcraft_color::Swatch { name: nm.clone(), ..src };
if let Some(pos) = d.swatches.iter().position(|sw| sw.name == name) {
d.swatches.insert(pos + 1, copy);
} else if let Some(g) = d.swatch_groups.iter_mut().find(|g| g.swatches.iter().any(|sw| sw.name == name)) {
let pos = g.swatches.iter().position(|sw| sw.name == name).unwrap_or(0);
g.swatches.insert(pos + 1, copy);
}
Ok(nm)
})?;
Ok(json!({"name": new}))
}
fn swatch_sort(s: &mut Session, _: &Value) -> Result<Value> {
s.edit("Sort Swatches", |d, _| {
// [None] and other bracketed specials stay first, like Illustrator.
let key = |sw: &vectorcraft_color::Swatch| (!sw.name.starts_with('['), sw.name.to_lowercase());
d.swatches.sort_by_key(key);
for g in &mut d.swatch_groups {
g.swatches.sort_by_key(key);
}
Ok(())
})?;
ok()
}
// ---------- graphic styles ----------
fn style_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.delete", "missing name"))?.to_string();
s.edit("Delete Graphic Style", |d, _| {
let before = d.graphic_styles.len();
d.graphic_styles.retain(|g| g.name != name);
if d.graphic_styles.len() == before { Err(EngineError::Other(format!("no graphic style `{name}`"))) } else { Ok(()) }
})?;
ok()
}
fn style_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.duplicate", "missing name"))?.to_string();
let new = s.edit("Duplicate Graphic Style", |d, _| {
let pos = d.graphic_styles.iter().position(|g| g.name == name).ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?;
let mut g = d.graphic_styles[pos].clone();
let base = format!("{name} copy");
let mut nm = base.clone();
let mut i = 2;
while d.graphic_styles.iter().any(|x| x.name == nm) {
nm = format!("{base} {i}");
i += 1;
}
g.name = nm.clone();
d.graphic_styles.insert(pos + 1, g);
Ok(nm)
})?;
Ok(json!({"name": new}))
}
// ---------- text ----------
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setFormat";
let ids: Vec<NodeId> = {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect()
};
if ids.is_empty() {
return Err(bad(C, "no text objects selected"));
}
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let kerning = match p.get("kerning") {
None | Some(Value::Null) => None,
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(C, "kerning must be a number or \"auto\""))?.clamp(-1000.0, 10000.0))),
};
let keys = [
"kerning",
"baselineShift",
"hScale",
"vScale",
"rotation",
"underline",
"strikethrough",
"allCaps",
"leftIndent",
"rightIndent",
"firstLineIndent",
"spaceBefore",
"spaceAfter",
"hyphenate",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
return Err(bad(C, "nothing to change"));
}
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
for r in &mut t.runs {
let st = &mut r.style;
if let Some(k) = kerning {
st.kerning = k;
}
if let Some(v) = num("baselineShift") {
st.baseline_shift = v.clamp(-1296.0, 1296.0);
}
if let Some(v) = num("hScale") {
st.h_scale = v.clamp(1.0, 10000.0);
}
if let Some(v) = num("vScale") {
st.v_scale = v.clamp(1.0, 10000.0);
}
if let Some(v) = num("rotation") {
st.rotation = ((v + 180.0).rem_euclid(360.0)) - 180.0;
}
if let Some(v) = flag("underline") {
st.underline = v;
}
if let Some(v) = flag("strikethrough") {
st.strikethrough = v;
}
if let Some(v) = flag("allCaps") {
st.all_caps = v;
}
}
let para = &mut t.para;
if let Some(v) = num("leftIndent") {
para.left_indent = v;
}
if let Some(v) = num("rightIndent") {
para.right_indent = v;
}
if let Some(v) = num("firstLineIndent") {
para.first_line_indent = v;
}
if let Some(v) = num("spaceBefore") {
para.space_before = v;
}
if let Some(v) = num("spaceAfter") {
para.space_after = v;
}
if let Some(v) = flag("hyphenate") {
para.hyphenate = v;
}
super::typecmd::refresh_bounds(t);
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- stroke extras ----------
/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths).
pub(crate) fn flip_profile(p: &vectorcraft_doc::WidthProfile, along: bool) -> vectorcraft_doc::WidthProfile {
let mut pts: Vec<(f64, f64, f64)> =
if along { p.points.iter().map(|&(t, l, r)| (1.0 - t, l, r)).collect() } else { p.points.iter().map(|&(t, l, r)| (t, r, l)).collect() };
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
vectorcraft_doc::WidthProfile { points: pts }
}
fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.setAdvanced";
let scale = match p.get("arrowScale") {
None => None,
Some(v) => {
let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(C, "arrowScale must be [start, end]"))?;
let f = |i: usize| a[i].as_f64().map(|x| x.clamp(1.0, 1000.0));
Some((f(0).ok_or_else(|| bad(C, "bad arrowScale"))?, f(1).ok_or_else(|| bad(C, "bad arrowScale"))?))
}
};
let flip = match str_param(p, "flipProfile") {
None => None,
Some("along") => Some(true),
Some("across") => Some(false),
Some(o) => return Err(bad(C, format!("flipProfile must be along|across, got {o}"))),
};
let swap = bool_or(p, "swapArrows", false);
let brush = match p.get("brush") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(b)) => Some(Some(b.clone())),
Some(_) => return Err(bad(C, "brush must be a name or null")),
};
if scale.is_none() && flip.is_none() && !swap && brush.is_none() {
return Err(bad(C, "nothing to change"));
}
let ids = leaf_targets(s, p)?;
if ids.is_empty() {
return ok();
}
s.edit("Stroke", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
let Some(st) = n.appearance.stroke_mut() else { continue };
if let Some(sc) = scale {
st.arrow_scale = sc;
}
if swap {
std::mem::swap(&mut st.start_arrow, &mut st.end_arrow);
st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0);
}
if let (Some(along), Some(pr)) = (flip, &st.profile) {
st.profile = Some(flip_profile(pr, along));
}
if let Some(b) = &brush {
st.brush = b.clone();
}
}
Ok(())
})?;
ok()
}