M3.8: split the paint/panel command modules and the dialogs into per-area files

No behaviour change: every command keeps its id, label, menu, shortcut and
params doc.

- Engine: swatch.* -> cmd/swatch.rs, graphicStyle.* -> cmd/style.rs,
  appearance.* (incl. copyFrom) -> cmd/appearance.rs, stroke.set/setAdvanced
  -> cmd/stroke.rs, paint.editGradient/setGradientGeom -> cmd/gradient.rs.
  Shared helpers in cmd/mod.rs: leaf_targets/paint_targets (replacing three
  copies) and unique_name (swatch and graphic style naming).
- UI: dialogs.rs -> dialogs/ with one file per dialog and a DialogSpec
  registry (one DialogKind row per dialog); shared field widgets in
  dialogs/form.rs. Headless frame tests draw and confirm every dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 11:22:23 +07:00
parent d99853ed2a
commit b7969a6a5d
30 changed files with 2159 additions and 1546 deletions

View File

@ -0,0 +1,244 @@
//! The Appearance panel: fill and stroke items, live effects, Clear / Reduce to Basic, and the
//! Eyedropper's appearance copy.
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeKind, StrokeLayer};
use super::edit::selected_roots;
use super::paint::paint_from;
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("appearance.addFill", "Add New Fill", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, true)),
cmd!("appearance.addStroke", "Add New Stroke", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, false)),
cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{}", has_selection, clear_appearance),
cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{}", has_selection, reduce_basic),
cmd!(
"appearance.setItem",
"Appearance Item",
[],
None,
"{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack",
has_selection,
set_item
),
cmd!("appearance.removeItem", "Remove Item", [], None, "{index}", has_selection, remove_item),
cmd!("appearance.addEffect", "Add Effect", ["Effect"], None, "{effect: id, params?: {…}} append a live effect", has_selection, add_effect),
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!(
"appearance.copyFrom",
"Eyedropper",
[],
None,
"{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults",
has_doc,
copy_from
),
]
}
fn add_item(s: &mut Session, fill: bool) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?;
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
let item = if fill {
AppearanceItem::Fill(FillLayer::new(n.appearance.fill_paint()))
} else {
AppearanceItem::Stroke(StrokeLayer::new(n.appearance.stroke_paint(), n.appearance.stroke_width().max(1.0)))
};
n.appearance.items.push(item);
}
}
Ok(())
})?;
ok()
}
fn clear_appearance(s: &mut Session, _: &Value) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?;
s.edit("Clear Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.appearance = Appearance::basic(Paint::None, Paint::None, 1.0);
}
}
Ok(())
})?;
ok()
}
fn reduce_basic(s: &mut Session, _: &Value) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?;
s.edit("Reduce to Basic Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
let f = n.appearance.fill_paint();
let st = n.appearance.stroke().cloned();
n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(f))], effects: vec![] };
if let Some(mut st) = st {
st.effects.clear();
st.opacity = 1.0;
st.blend = BlendMode::Normal;
n.appearance.items.push(AppearanceItem::Stroke(st));
}
}
}
Ok(())
})?;
ok()
}
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.setItem", "missing index"))? as usize;
let paint = paint_from(s, p)?;
let ids = paint_targets(s, p)?;
let blend = str_param(p, "blend").and_then(BlendMode::parse);
s.edit("Appearance", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
let Some(item) = n.appearance.items.get_mut(idx) else { continue };
let (pp, op, bl, vis) = match item {
AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible),
AppearanceItem::Stroke(st) => {
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
st.width = w.max(0.0);
}
(&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible)
}
};
if let Some(pa) = &paint {
*pp = pa.clone();
}
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
*op = if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32;
}
if let Some(b) = blend {
*bl = b;
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
*vis = v;
}
}
Ok(())
})?;
ok()
}
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.removeItem", "missing index"))? as usize;
let ids = paint_targets(s, p)?;
s.edit("Remove Item", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id)
&& idx < n.appearance.items.len()
{
n.appearance.items.remove(idx);
}
}
Ok(())
})?;
ok()
}
fn add_effect(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "effect").ok_or_else(|| bad("appearance.addEffect", "missing effect id"))?.to_string();
let params = p.get("params").cloned().unwrap_or(json!({}));
let ids = targets(s, p)?;
s.edit("Add Effect", |d, _| {
for nid in &ids {
if let Some(n) = d.node_mut(*nid) {
n.appearance.effects.push(vectorcraft_doc::Effect { id: id.clone(), params: params.clone(), visible: true });
}
}
Ok(())
})?;
ok()
}
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 = paint_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 = paint_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()
}
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?;
let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?;
let appearance = match &src.kind {
NodeKind::Text(t) => match t.runs.first() {
Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width),
None => src.appearance.clone(),
},
_ => src.appearance.clone(),
};
s.paint.fill = appearance.fill_paint();
s.paint.stroke = appearance.stroke_paint();
if appearance.stroke().is_some() {
s.paint.stroke_width = appearance.stroke_width();
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let mut targets = leaf_targets(s, &ids)?;
targets.retain(|id| *id != src_id);
if targets.is_empty() {
return Ok(json!({ "ids": [] }));
}
let (opacity, blend) = (src.opacity, src.blend);
s.edit("Eyedropper", |d, _| {
for id in &targets {
let Some(n) = d.node_mut(*id) else { continue };
n.opacity = opacity;
n.blend = blend;
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
r.style.fill = appearance.fill_paint();
r.style.stroke = appearance.stroke_paint();
r.style.stroke_width = appearance.stroke_width();
}
continue;
}
n.appearance = appearance.clone();
}
Ok(())
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
}

View File

@ -0,0 +1,205 @@
//! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector.
use serde_json::{Value, json};
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::NodeKind;
use super::edit::selected_roots;
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!(
"paint.setGradientGeom",
"Gradient Vector",
[],
None,
"{start: [x,y], end: [x,y], ids?, stroke?: bool} set the gradient vector (solid paints become the default gradient)",
has_doc,
set_gradient_geom
),
]
}
/// 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 = paint_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<_>>()}))
}
fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
let start = point_param(p, "start").ok_or_else(|| bad("paint.setGradientGeom", "missing start [x,y]"))?;
let end = point_param(p, "end").ok_or_else(|| bad("paint.setGradientGeom", "missing end [x,y]"))?;
let stroke = bool_or(p, "stroke", false);
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let targets = leaf_targets(s, &ids)?;
if targets.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
}
s.edit("Gradient", |d, _| {
for id in &targets {
let Some(n) = d.node_mut(*id) else { continue };
if matches!(n.kind, NodeKind::Text(_)) {
continue;
}
let cur = if stroke { n.appearance.stroke_paint() } else { n.appearance.fill_paint() };
let mut gp = match cur {
Paint::Gradient(g) => *g,
_ => GradientPaint::new(Gradient::default()),
};
let aspect = gp.geom.map(|g| g.aspect).unwrap_or(1.0);
gp.geom = Some(GradientGeom { start, end, aspect });
gp.angle = GradientGeom { start, end, aspect }.angle_deg();
let paint = Paint::Gradient(Box::new(gp));
if stroke {
n.appearance.set_stroke(paint);
} else {
n.appearance.set_fill(paint);
}
}
Ok(())
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
}

View File

@ -1,5 +1,6 @@
//! The command registry. Ids follow Illustrator's menu structure.
mod appearance;
mod brushsym;
mod buildcmds;
pub mod clipboard;
@ -14,6 +15,7 @@ mod edit;
mod effectcmd;
mod file;
mod fonts;
mod gradient;
pub(crate) mod graph;
pub mod help;
mod layer;
@ -31,6 +33,9 @@ pub mod prefscmds;
pub mod rasterfx;
mod recolor;
mod select;
mod stroke;
mod style;
mod swatch;
pub(crate) mod tabs;
mod textedit;
pub mod textstyles;
@ -179,6 +184,11 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(patterncmds::specs());
v.extend(prefscmds::specs());
v.extend(distortcmds::specs());
v.extend(appearance::specs());
v.extend(gradient::specs());
v.extend(stroke::specs());
v.extend(style::specs());
v.extend(swatch::specs());
v
})
}
@ -260,3 +270,38 @@ pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
pub(crate) fn ok() -> Result<Value> {
Ok(Value::Null)
}
/// Leaves whose appearance a paint command changes: groups and layers expand to their contents,
/// and compound paths own their children's appearance.
pub(crate) fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result<Vec<NodeId>> {
use vectorcraft_doc::NodeKind;
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<NodeId> = 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)
}
/// [`leaf_targets`] of a command's [`targets`] (`ids`, `id` or the selection).
pub(crate) fn paint_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
leaf_targets(s, &targets(s, p)?)
}
/// `base` if `taken` doesn't claim it, else the first free "base 2", "base 3", …
pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
if !taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}

View File

@ -1,8 +1,8 @@
//! Fill/stroke, Stroke panel, Appearance, Transparency, Swatches, Graphic Styles.
//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel.
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Arrowhead, Dash, FillLayer, LineCap, LineJoin, NodeKind, StrokeAlign, StrokeLayer};
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, NodeKind};
use super::*;
use crate::EngineError;
@ -29,30 +29,6 @@ pub fn specs() -> Vec<CommandSpec> {
let f = s.fill_active;
set_paint(s, &json!({"none": true}), f)
}),
cmd!(
"stroke.set",
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}",
has_doc,
stroke_set
),
cmd!("appearance.addFill", "Add New Fill", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, true)),
cmd!("appearance.addStroke", "Add New Stroke", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, false)),
cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{}", has_selection, clear_appearance),
cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{}", has_selection, reduce_basic),
cmd!(
"appearance.setItem",
"Appearance Item",
[],
None,
"{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack",
has_selection,
set_item
),
cmd!("appearance.removeItem", "Remove Item", [], None, "{index}", has_selection, remove_item),
cmd!("appearance.addEffect", "Add Effect", ["Effect"], None, "{effect: id, params?: {…}} append a live effect", has_selection, add_effect),
cmd!(
"transparency.set",
"Transparency",
@ -62,10 +38,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
transparency
),
cmd!("graphicStyle.apply", "Apply Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_selection, style_apply),
cmd!("graphicStyle.new", "New Graphic Style", ["Window", "Graphic Styles"], None, "{name?} from the selection", has_selection, style_new),
cmd!("swatch.new", "New Swatch", ["Window", "Swatches"], None, "{name?, color?, global?} (default: current fill)", has_doc, swatch_new),
cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete),
]
}
@ -120,28 +92,6 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
Ok(None)
}
/// Leaf objects whose appearance should change for `ids` (groups apply to children).
fn paint_targets(s: &Session, p: &Value) -> Result<Vec<vectorcraft_doc::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),
}
}
// Don't recurse into compound children (the compound owns the appearance).
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)
}
fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
let paint = paint_from(s, p)?.ok_or_else(|| bad("paint.setFill", "give color, none, swatch or gradient"))?;
if fill {
@ -220,206 +170,6 @@ fn default_paint(s: &mut Session, _: &Value) -> Result<Value> {
ok()
}
fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
s.paint.stroke_width = w.max(0.0);
}
let ids = paint_targets(s, p)?;
if ids.is_empty() {
return ok();
}
let arrow = |k: &str| -> Result<Option<Option<Arrowhead>>> {
match p.get(k) {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(n)) if n == "none" => Ok(Some(None)),
Some(v) => {
serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad("stroke.set", format!("unknown arrowhead {v}")))
}
}
};
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
s.edit("Stroke", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
if n.appearance.stroke().is_none() {
n.appearance.set_stroke(Paint::solid(Color::BLACK));
}
let st: &mut StrokeLayer = n.appearance.stroke_mut().unwrap();
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
st.width = w.max(0.0);
}
match str_param(p, "cap") {
Some("round") => st.cap = LineCap::Round,
Some("square") | Some("projecting") => st.cap = LineCap::Square,
Some("butt") => st.cap = LineCap::Butt,
_ => {}
}
match str_param(p, "join") {
Some("round") => st.join = LineJoin::Round,
Some("bevel") => st.join = LineJoin::Bevel,
Some("miter") => st.join = LineJoin::Miter,
_ => {}
}
if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) {
st.miter_limit = m.clamp(1.0, 500.0);
}
match str_param(p, "align") {
Some("inside") => st.align = StrokeAlign::Inside,
Some("outside") => st.align = StrokeAlign::Outside,
Some("center") => st.align = StrokeAlign::Center,
_ => {}
}
match p.get("dash") {
Some(Value::Null) => st.dash = None,
Some(Value::Array(a)) => {
let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
st.dash = if pattern.is_empty() {
None
} else {
Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) })
};
}
_ => {}
}
if let Some(a) = sa {
st.start_arrow = a;
}
if let Some(a) = ea {
st.end_arrow = a;
}
match str_param(p, "profile") {
Some("uniform") => st.profile = None,
Some("lens") => st.profile = Some(vectorcraft_doc::WidthProfile::lens()),
Some("taperStart") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_start()),
Some("taperEnd") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_end()),
_ => {}
}
}
Ok(())
})?;
ok()
}
fn add_item(s: &mut Session, fill: bool) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?;
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
let item = if fill {
AppearanceItem::Fill(FillLayer::new(n.appearance.fill_paint()))
} else {
AppearanceItem::Stroke(StrokeLayer::new(n.appearance.stroke_paint(), n.appearance.stroke_width().max(1.0)))
};
n.appearance.items.push(item);
}
}
Ok(())
})?;
ok()
}
fn clear_appearance(s: &mut Session, _: &Value) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?;
s.edit("Clear Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.appearance = Appearance::basic(Paint::None, Paint::None, 1.0);
}
}
Ok(())
})?;
ok()
}
fn reduce_basic(s: &mut Session, _: &Value) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?;
s.edit("Reduce to Basic Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
let f = n.appearance.fill_paint();
let st = n.appearance.stroke().cloned();
n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(f))], effects: vec![] };
if let Some(mut st) = st {
st.effects.clear();
st.opacity = 1.0;
st.blend = BlendMode::Normal;
n.appearance.items.push(AppearanceItem::Stroke(st));
}
}
}
Ok(())
})?;
ok()
}
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.setItem", "missing index"))? as usize;
let paint = paint_from(s, p)?;
let ids = paint_targets(s, p)?;
let blend = str_param(p, "blend").and_then(BlendMode::parse);
s.edit("Appearance", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
let Some(item) = n.appearance.items.get_mut(idx) else { continue };
let (pp, op, bl, vis) = match item {
AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible),
AppearanceItem::Stroke(st) => {
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
st.width = w.max(0.0);
}
(&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible)
}
};
if let Some(pa) = &paint {
*pp = pa.clone();
}
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
*op = if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32;
}
if let Some(b) = blend {
*bl = b;
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
*vis = v;
}
}
Ok(())
})?;
ok()
}
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.removeItem", "missing index"))? as usize;
let ids = paint_targets(s, p)?;
s.edit("Remove Item", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id)
&& idx < n.appearance.items.len()
{
n.appearance.items.remove(idx);
}
}
Ok(())
})?;
ok()
}
fn add_effect(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "effect").ok_or_else(|| bad("appearance.addEffect", "missing effect id"))?.to_string();
let params = p.get("params").cloned().unwrap_or(json!({}));
let ids = targets(s, p)?;
s.edit("Add Effect", |d, _| {
for nid in &ids {
if let Some(n) = d.node_mut(*nid) {
n.appearance.effects.push(vectorcraft_doc::Effect { id: id.clone(), params: params.clone(), visible: true });
}
}
Ok(())
})?;
ok()
}
fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
let mut q = p.clone();
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
@ -427,72 +177,3 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
}
s.execute("object.setProps", &q)
}
fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?;
let ap = s
.doc()?
.doc
.graphic_styles
.iter()
.find(|g| g.name == name)
.map(|g| g.appearance.clone())
.ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?;
let ids = paint_targets(s, p)?;
s.edit("Apply Graphic Style", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.appearance = ap.clone();
}
}
Ok(())
})?;
ok()
}
fn style_new(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = *st.selection.objects.first().unwrap();
let ap = st.doc.node(id).map(|n| n.appearance.clone()).unwrap_or_default();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Graphic Style {}", st.doc.graphic_styles.len() + 1));
s.edit("New Graphic Style", |d, _| {
d.graphic_styles.push(vectorcraft_doc::GraphicStyle { name: name.clone(), appearance: ap });
Ok(())
})?;
Ok(json!({ "name": name }))
}
fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
let paint = match paint_from(s, p)? {
Some(x) => x,
None => s.paint.fill.clone(),
};
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
None => match &paint {
Paint::Solid { color, .. } => {
let [r, g, b] = color.to_rgb();
format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round())
}
_ => format!("Swatch {}", s.doc()?.doc.swatches.len() + 1),
},
};
let global = bool_or(p, "global", false);
s.edit("New Swatch", |d, _| {
d.swatches.push(vectorcraft_color::Swatch { name: name.clone(), paint, global, spot: false });
Ok(())
})?;
Ok(json!({ "name": name }))
}
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("swatch.delete", "missing name"))?.to_string();
s.edit("Delete Swatch", |d, _| {
d.swatches.retain(|sw| sw.name != name);
for g in &mut d.swatch_groups {
g.swatches.retain(|sw| sw.name != name);
}
Ok(())
})?;
ok()
}

View File

@ -1,36 +1,14 @@
//! 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.
//! Commands the panels need beyond the core set: artboard reorder/duplicate 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 vectorcraft_doc::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",
@ -41,19 +19,6 @@ pub fn specs() -> Vec<CommandSpec> {
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",
@ -66,204 +31,6 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
// ---------- 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> {
@ -298,102 +65,6 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
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> {
@ -487,66 +158,3 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
})?;
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()
}

View File

@ -0,0 +1,172 @@
//! The Stroke panel: weight, cap, join, alignment, dashes, arrowheads, width profiles and brushes.
use serde_json::Value;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"stroke.set",
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}",
has_doc,
stroke_set
),
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
),
]
}
fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
s.paint.stroke_width = w.max(0.0);
}
let ids = paint_targets(s, p)?;
if ids.is_empty() {
return ok();
}
let arrow = |k: &str| -> Result<Option<Option<Arrowhead>>> {
match p.get(k) {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(n)) if n == "none" => Ok(Some(None)),
Some(v) => {
serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad("stroke.set", format!("unknown arrowhead {v}")))
}
}
};
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
s.edit("Stroke", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
if n.appearance.stroke().is_none() {
n.appearance.set_stroke(Paint::solid(Color::BLACK));
}
let st: &mut StrokeLayer = n.appearance.stroke_mut().unwrap();
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
st.width = w.max(0.0);
}
match str_param(p, "cap") {
Some("round") => st.cap = LineCap::Round,
Some("square") | Some("projecting") => st.cap = LineCap::Square,
Some("butt") => st.cap = LineCap::Butt,
_ => {}
}
match str_param(p, "join") {
Some("round") => st.join = LineJoin::Round,
Some("bevel") => st.join = LineJoin::Bevel,
Some("miter") => st.join = LineJoin::Miter,
_ => {}
}
if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) {
st.miter_limit = m.clamp(1.0, 500.0);
}
match str_param(p, "align") {
Some("inside") => st.align = StrokeAlign::Inside,
Some("outside") => st.align = StrokeAlign::Outside,
Some("center") => st.align = StrokeAlign::Center,
_ => {}
}
match p.get("dash") {
Some(Value::Null) => st.dash = None,
Some(Value::Array(a)) => {
let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
st.dash = if pattern.is_empty() {
None
} else {
Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) })
};
}
_ => {}
}
if let Some(a) = sa {
st.start_arrow = a;
}
if let Some(a) = ea {
st.end_arrow = a;
}
match str_param(p, "profile") {
Some("uniform") => st.profile = None,
Some("lens") => st.profile = Some(vectorcraft_doc::WidthProfile::lens()),
Some("taperStart") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_start()),
Some("taperEnd") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_end()),
_ => {}
}
}
Ok(())
})?;
ok()
}
/// 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 = paint_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()
}

View File

@ -0,0 +1,72 @@
//! The Graphic Styles panel: apply, new, delete and duplicate.
use serde_json::{Value, json};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("graphicStyle.apply", "Apply Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_selection, style_apply),
cmd!("graphicStyle.new", "New Graphic Style", ["Window", "Graphic Styles"], None, "{name?} from the selection", has_selection, style_new),
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),
]
}
fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?;
let ap = s
.doc()?
.doc
.graphic_styles
.iter()
.find(|g| g.name == name)
.map(|g| g.appearance.clone())
.ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?;
let ids = paint_targets(s, p)?;
s.edit("Apply Graphic Style", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.appearance = ap.clone();
}
}
Ok(())
})?;
ok()
}
fn style_new(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = *st.selection.objects.first().unwrap();
let ap = st.doc.node(id).map(|n| n.appearance.clone()).unwrap_or_default();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Graphic Style {}", st.doc.graphic_styles.len() + 1));
s.edit("New Graphic Style", |d, _| {
d.graphic_styles.push(vectorcraft_doc::GraphicStyle { name: name.clone(), appearance: ap });
Ok(())
})?;
Ok(json!({ "name": name }))
}
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 nm = unique_name(&format!("{name} copy"), |n| d.graphic_styles.iter().any(|x| x.name == n));
g.name = nm.clone();
d.graphic_styles.insert(pos + 1, g);
Ok(nm)
})?;
Ok(json!({"name": new}))
}

View File

@ -0,0 +1,122 @@
//! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup};
use vectorcraft_doc::Document;
use super::paint::paint_from;
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("swatch.new", "New Swatch", ["Window", "Swatches"], None, "{name?, color?, global?} (default: current fill)", has_doc, swatch_new),
cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete),
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),
]
}
fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
let paint = match paint_from(s, p)? {
Some(x) => x,
None => s.paint.fill.clone(),
};
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
None => match &paint {
Paint::Solid { color, .. } => rgb_name(*color),
_ => format!("Swatch {}", s.doc()?.doc.swatches.len() + 1),
},
};
let global = bool_or(p, "global", false);
s.edit("New Swatch", |d, _| {
d.swatches.push(Swatch { name: name.clone(), paint, global, spot: false });
Ok(())
})?;
Ok(json!({ "name": name }))
}
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("swatch.delete", "missing name"))?.to_string();
s.edit("Delete Swatch", |d, _| {
d.swatches.retain(|sw| sw.name != name);
for g in &mut d.swatch_groups {
g.swatches.retain(|sw| sw.name != name);
}
Ok(())
})?;
ok()
}
/// The default name of a new solid swatch: its 8-bit RGB values.
fn rgb_name(c: Color) -> String {
let [r, g, b] = c.to_rgb();
format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round())
}
/// A swatch or colour-group name not used yet in `d`.
fn free_name(d: &Document, base: &str) -> String {
unique_name(base, |n| d.swatch(n).is_some() || d.swatch_groups.iter().any(|g| g.name == n))
}
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<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 = free_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 {
group.swatches.push(Swatch { name: rgb_name(*c), 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 = free_name(d, &format!("{name} copy"));
let copy = 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 the reference app.
let key = |sw: &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()
}

View File

@ -1,9 +1,9 @@
//! Commands backing transform/utility tools: free distort, eyedropper, gradient vector, artboard move.
//! Commands backing transform/utility tools: free distort, eyedropper colour sampling, artboard move.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Gradient, GradientGeom, GradientPaint, Paint};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
@ -22,15 +22,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
distort
),
cmd!(
"appearance.copyFrom",
"Eyedropper",
[],
None,
"{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults",
has_doc,
copy_from
),
cmd!(
"paint.sampleColor",
"Sample Color",
@ -40,15 +31,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
sample_color
),
cmd!(
"paint.setGradientGeom",
"Gradient Vector",
[],
None,
"{start: [x,y], end: [x,y], ids?, stroke?: bool} set the gradient vector (solid paints become the default gradient)",
has_doc,
set_gradient_geom
),
cmd!(
"artboard.move",
"Move Artboard",
@ -190,72 +172,6 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- eyedropper ----------
/// Leaves whose appearance a paint command changes (groups expand to their contents; compound
/// paths own their children's appearance).
fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result<Vec<NodeId>> {
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<NodeId> = 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)
}
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?;
let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?;
let appearance = match &src.kind {
NodeKind::Text(t) => match t.runs.first() {
Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width),
None => src.appearance.clone(),
},
_ => src.appearance.clone(),
};
s.paint.fill = appearance.fill_paint();
s.paint.stroke = appearance.stroke_paint();
if appearance.stroke().is_some() {
s.paint.stroke_width = appearance.stroke_width();
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let mut targets = leaf_targets(s, &ids)?;
targets.retain(|id| *id != src_id);
if targets.is_empty() {
return Ok(json!({ "ids": [] }));
}
let (opacity, blend) = (src.opacity, src.blend);
s.edit("Eyedropper", |d, _| {
for id in &targets {
let Some(n) = d.node_mut(*id) else { continue };
n.opacity = opacity;
n.blend = blend;
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
r.style.fill = appearance.fill_paint();
r.style.stroke = appearance.stroke_paint();
r.style.stroke_width = appearance.stroke_width();
}
continue;
}
n.appearance = appearance.clone();
}
Ok(())
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
let c = p.get("color").ok_or_else(|| bad("paint.sampleColor", "missing color"))?;
@ -268,46 +184,6 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
(spec.run)(s, &q)
}
// ---------- gradient tool ----------
fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
let start = point_param(p, "start").ok_or_else(|| bad("paint.setGradientGeom", "missing start [x,y]"))?;
let end = point_param(p, "end").ok_or_else(|| bad("paint.setGradientGeom", "missing end [x,y]"))?;
let stroke = bool_or(p, "stroke", false);
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let targets = leaf_targets(s, &ids)?;
if targets.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
}
s.edit("Gradient", |d, _| {
for id in &targets {
let Some(n) = d.node_mut(*id) else { continue };
if matches!(n.kind, NodeKind::Text(_)) {
continue;
}
let cur = if stroke { n.appearance.stroke_paint() } else { n.appearance.fill_paint() };
let mut gp = match cur {
Paint::Gradient(g) => *g,
_ => GradientPaint::new(Gradient::default()),
};
let aspect = gp.geom.map(|g| g.aspect).unwrap_or(1.0);
gp.geom = Some(GradientGeom { start, end, aspect });
gp.angle = GradientGeom { start, end, aspect }.angle_deg();
let paint = Paint::Gradient(Box::new(gp));
if stroke {
n.appearance.set_stroke(paint);
} else {
n.appearance.set_fill(paint);
}
}
Ok(())
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- artboards ----------
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {

View File

@ -711,6 +711,8 @@ mod tests_brushsym;
#[cfg(test)]
mod tests_build;
#[cfg(test)]
mod tests_cmdsplit;
#[cfg(test)]
mod tests_colormgmt;
#[cfg(test)]
mod tests_distort;

View File

@ -0,0 +1,101 @@
//! The split command modules (`cmd/{appearance,gradient,stroke,style,swatch}.rs`) and the shared
//! helpers in `cmd/mod.rs` (`leaf_targets`, `paint_targets`, `unique_name`).
use serde_json::json;
use super::*;
use crate::cmd::{leaf_targets, paint_targets, unique_name};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn id_of(v: &serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
#[test]
fn moved_commands_keep_their_ids() {
let ids = [
"appearance.addFill",
"appearance.addStroke",
"appearance.clear",
"appearance.reduceToBasic",
"appearance.setItem",
"appearance.removeItem",
"appearance.addEffect",
"appearance.duplicateItem",
"appearance.moveItem",
"appearance.copyFrom",
"paint.editGradient",
"paint.setGradientGeom",
"stroke.set",
"stroke.setAdvanced",
"graphicStyle.apply",
"graphicStyle.new",
"graphicStyle.delete",
"graphicStyle.duplicate",
"swatch.new",
"swatch.delete",
"swatch.newGroup",
"swatch.duplicate",
"swatch.sortByName",
];
for id in ids {
assert_eq!(command_specs().iter().filter(|c| c.id == id).count(), 1, "{id}");
}
}
#[test]
fn unique_name_appends_the_first_free_number() {
let taken = ["A", "A 2", "A 3"];
assert_eq!(unique_name("B", |n| taken.contains(&n)), "B");
assert_eq!(unique_name("A", |n| taken.contains(&n)), "A 4");
assert_eq!(unique_name("A 2", |n| taken.contains(&n)), "A 2 2");
}
#[test]
fn leaf_targets_expand_groups_and_stop_at_compound_paths() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 30.0));
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
assert_eq!(leaf_targets(&s, &[g]).unwrap(), vec![a, b]);
let (c, d) = (rect(&mut s, 60.0), rect(&mut s, 90.0));
s.execute("select.set", &json!({"ids": [c.0, d.0]})).unwrap();
let comp = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
assert_eq!(leaf_targets(&s, &[comp]).unwrap(), vec![comp]);
// `paint_targets` reads `ids`, then `id`, then the selection.
assert_eq!(paint_targets(&s, &json!({"ids": [g.0]})).unwrap(), vec![a, b]);
assert_eq!(paint_targets(&s, &json!({"id": comp.0})).unwrap(), vec![comp]);
s.execute("select.set", &json!({"ids": [g.0, comp.0]})).unwrap();
assert_eq!(paint_targets(&s, &json!({})).unwrap(), vec![a, b, comp]);
}
#[test]
fn duplicate_names_stay_unique() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Sky", "color": "#3399ff"})).unwrap();
assert_eq!(s.execute("swatch.duplicate", &json!({"name": "Sky"})).unwrap()["name"], "Sky copy");
assert_eq!(s.execute("swatch.duplicate", &json!({"name": "Sky"})).unwrap()["name"], "Sky copy 2");
// Colour-group names share the swatch namespace.
assert_eq!(s.execute("swatch.newGroup", &json!({"name": "Sky"})).unwrap()["name"], "Sky 2");
// New swatches from a colour are named by their RGB values (also inside new groups).
assert_eq!(s.execute("swatch.new", &json!({"color": "#ff8000"})).unwrap()["name"], "R=255 G=128 B=0");
s.execute("swatch.newGroup", &json!({"name": "G", "colors": ["#00ff00"]})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.swatch_groups.iter().find(|g| g.name == "G").unwrap().swatches[0].name, "R=0 G=255 B=0");
let id = rect(&mut s, 0.0);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graphicStyle.new", &json!({"name": "Look"})).unwrap();
assert_eq!(s.execute("graphicStyle.duplicate", &json!({"name": "Look"})).unwrap()["name"], "Look copy");
assert_eq!(s.execute("graphicStyle.duplicate", &json!({"name": "Look"})).unwrap()["name"], "Look copy 2");
}

View File

@ -1,4 +1,4 @@
//! Panel-support commands (`cmd/panelcmds.rs`).
//! Panel-support commands (`cmd/{panelcmds,gradient,appearance,stroke,swatch,style}.rs`).
use serde_json::json;
use vectorcraft_color::{GradientKind, Paint};

View File

@ -1,702 +0,0 @@
//! Modal dialogs. Fields live in `UiState::dialog` (string/number JSON) so agents can fill them
//! through `ui.dialog.set` and press OK with `ui.dialog.confirm`.
use serde_json::{Value, json};
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
/// A click with a shape tool opens its size dialog.
pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
let x = p.get("x").and_then(Value::as_f64).unwrap_or(0.0);
let y = p.get("y").and_then(Value::as_f64).unwrap_or(0.0);
let d = match kind {
"rectangle" | "ellipse" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt"})),
"roundedRectangle" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "radius": "12 pt"})),
"polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})),
"star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})),
"lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})),
// Graph tool click: the graph's size.
"graph" => Dialog::new(
"command",
json!({"__command": "graph.create", "__label": "Graph", "type": p.get("type").cloned().unwrap_or(json!("column")), "x": x, "y": y, "width": 200, "height": 150}),
),
// After drawing a graph: Graph Data (CSV: header row of series, then category, values…).
"graphData" => match app.session.execute("graph.setData", &json!({})) {
Ok(v) => Dialog::new("command", json!({"__command": "graph.setData", "__label": "Graph Data", "csv": v["csv"]})),
Err(_) => return,
},
// Flare Tool Options (Center / Halo / Rays / Rings), applied through the generic command dialog.
"flare" => Dialog::new(
"command",
json!({"__command": "shape.flare", "__label": "Flare Tool Options", "cx": x, "cy": y, "diameter": 100, "opacity": 50, "brightness": 30,
"growth": 20, "fuzziness": 50, "rays": 15, "longest": 300, "rayFuzziness": 100, "pathLength": 300, "rings": 10, "largest": 50, "direction": 45}),
),
"rotate" | "reflect" | "scale" | "shear" | "artboardOptions" => {
let mut base = match kind {
"rotate" => json!({"angle": 0}),
"reflect" => json!({"axis": "vertical"}),
"scale" => json!({"sx": 100, "sy": 100, "uniform": true}),
"shear" => json!({"angle": 0, "axis": "horizontal"}),
_ => json!({}),
};
if let (Some(b), Some(o)) = (base.as_object_mut(), p.as_object()) {
for (k, v) in o {
b.insert(k.clone(), v.clone());
}
}
Dialog::new(kind, base)
}
_ => return,
};
app.ui.dialog = Some(d);
}
/// Pass a tool-chosen reference point (origin) through to transform commands.
fn origin_params(d: &Dialog, mut p: Value) -> Value {
if let Some(o) = d.fields.get("origin") {
p["origin"] = o.clone();
}
p
}
/// Effect parameters from the dialog fields (drop UI-only keys).
fn effect_params(d: &Dialog) -> Value {
Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect())
}
/// Generic editor for an effect dialog: numbers, booleans, strings and colours.
fn effect_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let mut changed = false;
egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
let keys: Vec<(String, Value)> =
d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in keys {
ui.label(egui::RichText::new(humanize(&k)).color(t.text));
match v {
Value::Number(n) => {
let mut x = n.as_f64().unwrap_or(0.0);
let speed = if x.abs() > 20.0 { 1.0 } else { 0.1 };
if ui.add(egui::DragValue::new(&mut x).speed(speed).max_decimals(2)).changed() {
d.fields.insert(k, json!(x));
changed = true;
}
}
Value::Bool(mut b) => {
if ui.checkbox(&mut b, "").changed() {
d.fields.insert(k, json!(b));
changed = true;
}
}
Value::String(mut s) if s.contains('\n') => {
// Multi-line values (Graph Data CSV) get a text area.
if ui.add(egui::TextEdit::multiline(&mut s).desired_width(260.0).desired_rows(8).font(egui::TextStyle::Monospace)).changed() {
d.fields.insert(k, json!(s));
changed = true;
}
}
Value::String(mut s) => {
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() {
d.fields.insert(k, json!(s));
changed = true;
}
}
other => {
ui.label(egui::RichText::new(other.to_string()).color(t.text_dim).size(11.0));
}
}
ui.end_row();
}
});
changed
}
fn title(kind: &str) -> &'static str {
match kind {
"newDocument" => "New Document",
"rectangle" => "Rectangle",
"roundedRectangle" => "Rounded Rectangle",
"ellipse" => "Ellipse",
"polygon" => "Polygon",
"star" => "Star",
"lineSegment" => "Line Segment Tool Options",
"move" => "Move",
"rotate" => "Rotate",
"scale" => "Scale",
"reflect" => "Reflect",
"shear" => "Shear",
"average" => "Average",
"offsetPath" => "Offset Path",
"simplify" => "Simplify",
"splitIntoGrid" => "Split Into Grid",
"documentSetup" => "Document Setup",
"preferences" => "Preferences",
"shortcuts" => "Keyboard Shortcuts",
"artboardOptions" => "Artboard Options",
"allTools" => "All Tools",
"exportForScreens" => "Export for Screens",
"recolor" => "Recolor Artwork",
crate::unsaved::KIND => "Save Changes",
_ => "Dialog",
}
}
/// Apply the open dialog (OK).
pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
match d.kind.as_str() {
"preferences" => return crate::prefs_dialog::confirm(app),
"shortcuts" => return crate::shortcut_editor::confirm(app),
"newWorkspace" | "manageWorkspaces" => return crate::workspaces::confirm(app),
"findFont" => return crate::find_font::confirm(app),
crate::unsaved::KIND => return crate::unsaved::confirm(app),
_ => {}
}
let copy = d.bool("copy");
if d.kind == "recolor" {
let map = recolor_map(&d);
app.ui.dialog = None;
if app.session.in_interaction() {
let _ = app.session.preview("recolor.apply", &json!({"map": map}));
return app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string());
}
return app.run("recolor.apply", json!({"map": map}));
}
if d.kind == "exportForScreens" {
let boards: Vec<usize> = d
.fields
.get("boards")
.and_then(Value::as_array)
.map(|a| a.iter().enumerate().filter(|(_, b)| b.as_bool() == Some(true)).map(|(i, _)| i).collect())
.unwrap_or_default();
let params = json!({"folder": d.str("folder"), "artboards": boards, "formats": d.fields.get("formats").cloned().unwrap_or(json!([])), "prefix": d.str("prefix")});
app.ui.dialog = None;
let r = app.run("document.exportForScreens", params);
if let Ok(v) = &r {
let n = v["files"].as_array().map(|a| a.len()).unwrap_or(0);
app.status(format!("Exported {n} file(s) to {}", d.str("folder")));
}
return r;
}
if d.kind == "command" {
let cmd = d.str("__command");
let params = effect_params(&d);
app.ui.dialog = None;
return app.run(&cmd, params);
}
if d.kind == "effect" {
let effect = d.str("__effect");
let params = effect_params(&d);
let r = if app.session.in_interaction() {
// Live preview already applied: keep it (the interaction commits as one undo step).
let _ = app.session.preview("effect.apply", &json!({"effect": effect, "params": params}));
app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string())
} else {
app.run("effect.apply", json!({"effect": effect, "params": params}))
};
app.last_effect = Some((effect, params));
app.ui.dialog = None;
return r;
}
let r = match d.kind.as_str() {
"newDocument" => {
let r = app.run("file.new", json!({"width": d.f64("width", 612.0), "height": d.f64("height", 792.0), "units": d.str("units"), "title": d.str("name"), "artboards": d.f64("artboards", 1.0), "colorMode": d.str("colorMode").to_lowercase()}));
if r.is_ok() {
app.ui.dialog = None;
}
return r;
}
"rectangle" | "roundedRectangle" => app.run("shape.rectangle", json!({"x": d.f64("x", 0.0), "y": d.f64("y", 0.0), "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)})),
"ellipse" => app.run("shape.ellipse", json!({"x": d.f64("x", 0.0), "y": d.f64("y", 0.0), "width": d.f64("width", 100.0), "height": d.f64("height", 100.0)})),
"polygon" => app.run("shape.polygon", json!({"cx": d.f64("x", 0.0), "cy": d.f64("y", 0.0), "radius": d.f64("radius", 50.0), "sides": d.f64("sides", 6.0) as u64})),
"star" => app.run("shape.star", json!({"cx": d.f64("x", 0.0), "cy": d.f64("y", 0.0), "radius1": d.f64("radius1", 50.0), "radius2": d.f64("radius2", 25.0), "points": d.f64("points", 5.0) as u64})),
"lineSegment" => {
let (x, y, l, a) = (d.f64("x", 0.0), d.f64("y", 0.0), d.f64("length", 100.0), d.f64("angle", 0.0).to_radians());
app.run("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()}))
}
"move" => app.run("object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy})),
"rotate" => app.run("object.rotate", origin_params(&d, json!({"angle": d.f64("angle", 0.0), "copy": copy}))),
"scale" => {
let sx = d.f64("sx", 100.0);
let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) };
app.run("object.scale", origin_params(&d, json!({"sx": sx, "sy": sy, "copy": copy})))
}
"reflect" => app.run("object.reflect", origin_params(&d, json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy}))),
"shear" => app.run("object.shear", origin_params(&d, json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy}))),
"artboardOptions" => {
let mut p = serde_json::Value::Object(d.fields.clone());
for k in ["x", "y", "width", "height"] {
if d.fields.contains_key(k) {
p[k] = json!(d.f64(k, 0.0));
}
}
app.run("artboard.setProps", p)
}
"average" => app.run("path.average", json!({"axis": d.str("axis")})),
"offsetPath" => app.run("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)})),
"simplify" => app.run("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})),
"splitIntoGrid" => app.run("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})),
"documentSetup" => app.run("document.setUnits", json!({"units": d.str("units")})),
_ => Ok(Value::Null),
};
app.ui.dialog = None;
r
}
fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(label).color(t.text_dim));
let mut s = d.str(key);
let r = egui::Frame::NONE
.fill(t.input)
.stroke(egui::Stroke::new(1.0, t.input_border))
.corner_radius(egui::CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(6, 3))
.show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(120.0)));
if r.inner.changed() {
d.fields.insert(key.into(), Value::String(s));
}
ui.end_row();
}
fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let mut b = d.bool(key);
if ui.checkbox(&mut b, label).changed() {
d.fields.insert(key.into(), Value::Bool(b));
}
}
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.ui.about {
let mut open = true;
egui::Window::new("About VectorCraft")
.collapsible(false)
.resizable(false)
.open(&mut open)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18)))
.show(ctx, |ui| {
ui.set_width(380.0);
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::hover());
crate::brand::paint_mark(ui.painter(), r);
ui.vertical(|ui| {
ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0)));
ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION")));
});
});
ui.add_space(12.0);
crate::community::links(app, ui);
ui.add_space(12.0);
ui.label(
egui::RichText::new(
"Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft.",
)
.size(11.0),
);
});
app.ui.about = open;
}
let Some(mut d) = app.ui.dialog.clone() else { return };
match d.kind.as_str() {
"preferences" => return crate::prefs_dialog::show(app, ctx),
"shortcuts" => return crate::shortcut_editor::show(app, ctx),
"newWorkspace" | "manageWorkspaces" => return crate::workspaces::show(app, ctx),
"findFont" => return crate::find_font::show(app, ctx),
_ => {}
}
let t = Tokens::get(ctx);
let mut ok = false;
let mut cancel = false;
let mut discard = false;
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
// Illustrator's dialogs are modal but don't dim the canvas (previews stay readable).
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
egui::Window::new(title(&d.kind))
.id(egui::Id::new("dialog"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
.show(ctx, |ui| {
ui.set_min_width(if d.kind == "newDocument" { 560.0 } else { 320.0 });
if d.kind == crate::unsaved::KIND {
// Long document names wrap instead of widening the dialog.
ui.set_max_width(420.0);
}
let heading = match d.kind.as_str() {
"effect" | "command" => d.str("__label"),
crate::unsaved::KIND => format!("Do you want to save the changes you made to “{}”?", d.str("name")),
k => title(k).to_string(),
};
ui.label(egui::RichText::new(heading).font(theme::semibold(16.0)).color(t.text));
ui.add_space(12.0);
match d.kind.as_str() {
crate::unsaved::KIND => {
ui.label(egui::RichText::new("Your changes will be lost if you don't save them.").color(t.text_dim));
}
"exportForScreens" => export_for_screens_ui(app, ui, &mut d),
"recolor" => {
if recolor_ui(ui, &mut d) || !app.session.in_interaction() {
let _ = app.session.begin_interaction("Recolor Artwork");
let _ = app.session.preview("recolor.apply", &json!({"map": recolor_map(&d)}));
}
}
"command" => {
effect_fields(ui, &mut d);
}
"effect" => {
let changed = effect_fields(ui, &mut d);
ui.add_space(6.0);
let mut pv = d.bool("preview");
let pv_changed = ui.checkbox(&mut pv, "Preview").changed();
d.fields.insert("preview".into(), json!(pv));
if pv && (changed || pv_changed || !app.session.in_interaction()) {
let label = d.str("__label");
let _ = app.session.begin_interaction(&label);
let _ = app.session.preview("effect.apply", &json!({"effect": d.str("__effect"), "params": effect_params(&d)}));
} else if !pv && pv_changed {
let _ = app.session.cancel_interaction();
}
}
"newDocument" => new_document(ui, &mut d),
"allTools" => {
for g in vectorcraft_tools::TOOL_GROUPS {
ui.horizontal_wrapped(|ui| {
for tool in g.iter() {
if ui.button(tool.label.trim_end_matches(" Tool")).clicked() {
app.select_tool(tool.id);
cancel = true;
}
}
});
}
}
_ => {
egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in keys {
if k == "x" || k == "y" || k == "origin" || k == "index" || v.is_boolean() {
continue;
}
field(ui, &mut d, &k, &humanize(&k));
}
});
if matches!(d.kind.as_str(), "move" | "rotate" | "scale" | "reflect" | "shear") {
ui.add_space(6.0);
if d.kind == "scale" {
check(ui, &mut d, "uniform", "Uniform");
}
check(ui, &mut d, "copy", "Copy (make a transformed copy)");
}
}
}
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let label = match d.kind.as_str() {
"newDocument" => "Create",
"exportForScreens" => "Export Artboard",
crate::unsaved::KIND => "Save",
_ => "OK",
};
if d.kind != "allTools" && widgets::primary_button(ui, label).clicked() {
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, if d.kind == "allTools" { "Close" } else { "Cancel" }).clicked() {
cancel = true;
}
if d.kind == crate::unsaved::KIND {
ui.add_space(28.0);
discard = widgets::secondary_button(ui, "Don't Save").clicked();
}
});
});
if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && d.kind != "allTools" {
ok = true;
}
if discard {
d.fields.insert("discard".into(), json!(true));
ok = true;
}
let is_effect = d.kind == "effect" || d.kind == "recolor";
app.ui.dialog = Some(d);
if cancel {
if is_effect {
let _ = app.session.cancel_interaction();
}
app.ui.dialog = None;
} else if ok && let Err(e) = confirm(app) {
app.status(e);
}
}
fn humanize(k: &str) -> String {
let mut s = String::new();
for (i, c) in k.chars().enumerate() {
if i == 0 {
s.extend(c.to_uppercase());
} else if c.is_uppercase() {
s.push(' ');
s.push(c);
} else {
s.push(c);
}
}
match s.as_str() {
"Dx" => "Horizontal".into(),
"Dy" => "Vertical".into(),
"Sx" => "Horizontal %".into(),
"Sy" => "Vertical %".into(),
"Radius1" => "Radius 1".into(),
"Radius2" => "Radius 2".into(),
_ => format!("{s}:"),
}
}
fn new_document(ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let presets: [(&str, &str, &str, &str); 8] = [
("Letter", "612 pt", "792 pt", "Points"),
("Legal", "612 pt", "1008 pt", "Points"),
("Tabloid", "792 pt", "1224 pt", "Points"),
("A4", "595.28 pt", "841.89 pt", "Points"),
("A3", "841.89 pt", "1190.55 pt", "Points"),
("Web 1920", "1920 px", "1080 px", "Pixels"),
("Phone 390×844", "390 px", "844 px", "Pixels"),
("Square Post", "1080 px", "1080 px", "Pixels"),
];
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(300.0);
ui.label(egui::RichText::new("Presets").color(t.text_dim));
ui.horizontal_wrapped(|ui| {
for (name, w, h, u) in presets {
let sel = d.str("preset") == name;
if ui.selectable_label(sel, name).clicked() {
d.fields.insert("preset".into(), json!(name));
d.fields.insert("width".into(), json!(w));
d.fields.insert("height".into(), json!(h));
d.fields.insert("units".into(), json!(u));
}
}
});
});
ui.separator();
ui.vertical(|ui| {
ui.label(egui::RichText::new("Preset Details").font(theme::semibold(12.5)));
egui::Grid::new("newdoc").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
field(ui, d, "name", "Name:");
field(ui, d, "width", "Width:");
field(ui, d, "height", "Height:");
field(ui, d, "artboards", "Artboards:");
field(ui, d, "colorMode", "Color Mode:");
});
});
});
}
/// Export for Screens: artboard picker (thumbnails + checkboxes), format/scale rows, destination.
fn export_for_screens_ui(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let names: Vec<String> = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
let mut boards: Vec<bool> =
d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default();
boards.resize(names.len(), true);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(260.0);
ui.label(egui::RichText::new("Artboards").color(t.text));
ui.horizontal(|ui| {
if ui.small_button("Select All").clicked() {
boards.iter_mut().for_each(|b| *b = true);
}
if ui.small_button("Clear").clicked() {
boards.iter_mut().for_each(|b| *b = false);
}
});
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (i, name) in names.iter().enumerate() {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
match artboard_thumb(app, ui.ctx(), i) {
Some(tex) => {
let sz = tex.size_vec2();
let s = (46.0 / sz.x.max(sz.y)).min(1.0);
let ir = egui::Rect::from_center_size(r.center(), sz * s);
ui.painter().image(
tex.id(),
ir,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
None => {
ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);
}
}
ui.checkbox(&mut boards[i], name);
});
}
});
});
let (sep, _) = ui.allocate_exact_size(egui::vec2(9.0, 360.0), egui::Sense::hover());
ui.painter().line_segment([sep.center_top(), sep.center_bottom()], egui::Stroke::new(1.0, t.divider));
ui.vertical(|ui| {
ui.set_width(330.0);
ui.label(egui::RichText::new("Export to").color(t.text));
let mut folder = d.str("folder");
if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0)).changed() {
d.fields.insert("folder".into(), json!(folder));
}
ui.add_space(8.0);
ui.label(egui::RichText::new("Formats").color(t.text));
let mut formats: Vec<Value> = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default();
let mut remove = None;
egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label(egui::RichText::new("Scale").color(t.text_dim));
ui.label(egui::RichText::new("Suffix").color(t.text_dim));
ui.label(egui::RichText::new("Format").color(t.text_dim));
ui.label("");
ui.end_row();
for (i, f) in formats.iter_mut().enumerate() {
let mut sc = f["scale"].as_f64().unwrap_or(1.0);
if ui.add(egui::DragValue::new(&mut sc).range(0.1..=10.0).speed(0.5).suffix("x")).changed() {
f["scale"] = json!(sc);
}
let mut suffix = f["suffix"].as_str().unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut suffix).desired_width(60.0)).changed() {
f["suffix"] = json!(suffix);
}
let cur = f["format"].as_str().unwrap_or("png").to_string();
egui::ComboBox::from_id_salt(("efs-fmt", i)).selected_text(cur.to_uppercase()).width(70.0).show_ui(ui, |ui| {
for fm in ["png", "jpg", "webp", "svg", "pdf"] {
if ui.selectable_label(cur == fm, fm.to_uppercase()).clicked() {
f["format"] = json!(fm);
}
}
});
if ui.small_button("×").clicked() {
remove = Some(i);
}
ui.end_row();
}
});
if let Some(i) = remove {
formats.remove(i);
}
if ui.button("+ Add Scale").clicked() {
let next = formats.len() as f64 + 1.0;
formats.push(json!({"format": "png", "scale": next, "suffix": format!("@{next}x")}));
}
d.fields.insert("formats".into(), Value::Array(formats));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Prefix").color(t.text_dim));
let mut prefix = d.str("prefix");
if ui.add(egui::TextEdit::singleline(&mut prefix).desired_width(120.0)).changed() {
d.fields.insert("prefix".into(), json!(prefix));
}
});
});
});
d.fields.insert("boards".into(), json!(boards));
}
/// A small cached rendering of artboard `i` (keyed by document revision).
fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option<egui::TextureHandle> {
let st = app.session.active()?;
let key = egui::Id::new(("ab-thumb", i, st.revision));
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
return Some(t);
}
let doc = st.doc.clone();
let r = doc.artboards.get(i)?.rect;
let scale = 92.0 / r.width().max(r.height()).max(1.0);
let img = app.canvas.renderer.render_region(&doc, r, scale, true);
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let tex = ctx.load_texture(format!("ab-thumb-{i}"), color, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(key, tex.clone()));
Some(tex)
}
fn recolor_map(d: &Dialog) -> Value {
let mut m = serde_json::Map::new();
for p in d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default() {
if let (Some(a), Some(b)) = (p.get(0).and_then(Value::as_str), p.get(1).and_then(Value::as_str)) {
m.insert(a.to_string(), json!(b));
}
}
Value::Object(m)
}
/// Recolor Artwork: current → new colour rows, harmony rules, randomize. Returns true when changed.
fn recolor_ui(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
use vectorcraft_color::Color;
let t = Tokens::get(ui.ctx());
let mut pairs: Vec<Value> = d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default();
let mut changed = false;
let chip = |ui: &mut egui::Ui, hex: &str| {
let (r, _) = ui.allocate_exact_size(egui::vec2(34.0, 22.0), egui::Sense::hover());
let c = Color::from_hex(hex).unwrap_or(Color::BLACK).to_rgba8(1.0);
ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c[0], c[1], c[2]));
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
};
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Current Colors").color(t.text_dim));
ui.add_space(70.0);
ui.label(egui::RichText::new("New").color(t.text_dim));
});
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for p in pairs.iter_mut() {
let from = p[0].as_str().unwrap_or("#000000").to_string();
let mut to = p[1].as_str().unwrap_or("#000000").to_string();
ui.horizontal(|ui| {
chip(ui, &from);
ui.label(egui::RichText::new("→").color(t.text_dim));
chip(ui, &to);
if ui.add(egui::TextEdit::singleline(&mut to).desired_width(80.0)).changed() && Color::from_hex(&to).is_some() {
p[1] = json!(to);
changed = true;
}
});
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Randomize").clicked() {
// Deterministic scramble of hues (seeded by the colour itself), keeping brightness.
for (i, p) in pairs.iter_mut().enumerate() {
let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK);
let [h, s, v] = c.to_hsb();
let nh = (h + 97.0 * (i as f32 + 1.0) + 41.0) % 360.0;
p[1] = json!(Color::from_hsb(nh, s.max(0.35), v.max(0.3)).to_hex());
}
changed = true;
}
for (label, deg) in [("Complement", 180.0), ("Triad", 120.0), ("Analogous", 30.0)] {
if ui.button(label).clicked() {
for (i, p) in pairs.iter_mut().enumerate() {
let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK);
let [h, s, v] = c.to_hsb();
let shift = if label == "Complement" { deg } else { deg * (i as f32 + 1.0) };
p[1] = json!(Color::from_hsb(h + shift, s, v).to_hex());
}
changed = true;
}
}
if ui.button("Reset").clicked() {
for p in pairs.iter_mut() {
p[1] = p[0].clone();
}
changed = true;
}
});
d.fields.insert("pairs".into(), Value::Array(pairs));
changed
}

View File

@ -0,0 +1,39 @@
//! Help → About VectorCraft.
use crate::VectorcraftApp;
use crate::theme;
/// The About window, while `UiState::about` is set.
pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if !app.ui.about {
return;
}
let mut open = true;
egui::Window::new("About VectorCraft")
.collapsible(false)
.resizable(false)
.open(&mut open)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18)))
.show(ctx, |ui| {
ui.set_width(380.0);
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::hover());
crate::brand::paint_mark(ui.painter(), r);
ui.vertical(|ui| {
ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0)));
ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION")));
});
});
ui.add_space(12.0);
crate::community::links(app, ui);
ui.add_space(12.0);
ui.label(
egui::RichText::new(
"Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft.",
)
.size(11.0),
);
});
app.ui.about = open;
}

View File

@ -0,0 +1,22 @@
//! The All Tools drawer: every tool as a button; picking one selects it and closes the drawer.
use super::DialogSpec;
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "All Tools".into(), body, ok: None, ..DialogSpec::FORM };
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> bool {
let mut picked = false;
for g in vectorcraft_tools::TOOL_GROUPS {
ui.horizontal_wrapped(|ui| {
for tool in g.iter() {
if ui.button(tool.label.trim_end_matches(" Tool")).clicked() {
app.select_tool(tool.id);
picked = true;
}
}
});
}
picked
}

View File

@ -0,0 +1,20 @@
//! Artboard Options (double-click with the Artboard tool): the artboard's fields, sizes parsed
//! with units.
use serde_json::{Value, json};
use super::{DialogSpec, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Artboard Options".into(), confirm, ..DialogSpec::FORM };
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = Value::Object(d.fields.clone());
for k in ["x", "y", "width", "height"] {
if d.fields.contains_key(k) {
p[k] = json!(d.f64(k, 0.0));
}
}
run_and_close(app, "artboard.setProps", p)
}

View File

@ -0,0 +1,26 @@
//! The generic parameter dialog (`ui.paramDialog`): edits a command's parameters (`__command`,
//! headed `__label`) and runs it on OK.
use serde_json::Value;
use super::{DialogSpec, form};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| d.str("__label"),
body: |_, ui, d| {
form::param_fields(ui, d);
false
},
confirm,
..DialogSpec::FORM
};
/// Closes before running, so a dialog the command opens stays open.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let cmd = d.str("__command");
let params = form::params(d);
app.ui.dialog = None;
app.run(&cmd, params)
}

View File

@ -0,0 +1,11 @@
//! File → Document Setup: the document's ruler units.
use serde_json::json;
use super::{DialogSpec, run_and_close};
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Document Setup".into(),
confirm: |app, d| run_and_close(app, "document.setUnits", json!({"units": d.str("units")})),
..DialogSpec::FORM
};

View File

@ -0,0 +1,41 @@
//! Live effect dialogs (Effect menu): the effect's parameters (`__effect`, headed `__label`) with a
//! live Preview that OK keeps as one undo step and Cancel rolls back.
use serde_json::{Value, json};
use super::{DialogSpec, form};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM };
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let changed = form::param_fields(ui, d);
ui.add_space(6.0);
let mut pv = d.bool("preview");
let pv_changed = ui.checkbox(&mut pv, "Preview").changed();
d.fields.insert("preview".into(), json!(pv));
if pv && (changed || pv_changed || !app.session.in_interaction()) {
let label = d.str("__label");
let _ = app.session.begin_interaction(&label);
let _ = app.session.preview("effect.apply", &json!({"effect": d.str("__effect"), "params": form::params(d)}));
} else if !pv && pv_changed {
let _ = app.session.cancel_interaction();
}
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let effect = d.str("__effect");
let params = form::params(d);
let r = if app.session.in_interaction() {
// Live preview already applied: keep it (the interaction commits as one undo step).
let _ = app.session.preview("effect.apply", &json!({"effect": effect, "params": params}));
app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string())
} else {
app.run("effect.apply", json!({"effect": effect, "params": params}))
};
app.last_effect = Some((effect, params));
app.ui.dialog = None;
r
}

View File

@ -0,0 +1,154 @@
//! File → Export → Export for Screens: artboards (thumbnails and checkboxes), scale/format rows,
//! destination folder and file-name prefix.
use serde_json::{Value, json};
use super::DialogSpec;
use crate::VectorcraftApp;
use crate::state::Dialog;
use crate::theme::Tokens;
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Export for Screens".into(), body, confirm, ok: Some("Export Artboard"), ..DialogSpec::FORM };
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let boards: Vec<usize> = d
.fields
.get("boards")
.and_then(Value::as_array)
.map(|a| a.iter().enumerate().filter(|(_, b)| b.as_bool() == Some(true)).map(|(i, _)| i).collect())
.unwrap_or_default();
let params = json!({"folder": d.str("folder"), "artboards": boards, "formats": d.fields.get("formats").cloned().unwrap_or(json!([])), "prefix": d.str("prefix")});
app.ui.dialog = None;
let r = app.run("document.exportForScreens", params);
if let Ok(v) = &r {
let n = v["files"].as_array().map(|a| a.len()).unwrap_or(0);
app.status(format!("Exported {n} file(s) to {}", d.str("folder")));
}
r
}
/// Export for Screens: artboard picker (thumbnails + checkboxes), format/scale rows, destination.
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let names: Vec<String> = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
let mut boards: Vec<bool> =
d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default();
boards.resize(names.len(), true);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(260.0);
ui.label(egui::RichText::new("Artboards").color(t.text));
ui.horizontal(|ui| {
if ui.small_button("Select All").clicked() {
boards.iter_mut().for_each(|b| *b = true);
}
if ui.small_button("Clear").clicked() {
boards.iter_mut().for_each(|b| *b = false);
}
});
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (i, name) in names.iter().enumerate() {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
match artboard_thumb(app, ui.ctx(), i) {
Some(tex) => {
let sz = tex.size_vec2();
let s = (46.0 / sz.x.max(sz.y)).min(1.0);
let ir = egui::Rect::from_center_size(r.center(), sz * s);
ui.painter().image(
tex.id(),
ir,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
None => {
ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);
}
}
ui.checkbox(&mut boards[i], name);
});
}
});
});
let (sep, _) = ui.allocate_exact_size(egui::vec2(9.0, 360.0), egui::Sense::hover());
ui.painter().line_segment([sep.center_top(), sep.center_bottom()], egui::Stroke::new(1.0, t.divider));
ui.vertical(|ui| {
ui.set_width(330.0);
ui.label(egui::RichText::new("Export to").color(t.text));
let mut folder = d.str("folder");
if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0)).changed() {
d.fields.insert("folder".into(), json!(folder));
}
ui.add_space(8.0);
ui.label(egui::RichText::new("Formats").color(t.text));
let mut formats: Vec<Value> = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default();
let mut remove = None;
egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label(egui::RichText::new("Scale").color(t.text_dim));
ui.label(egui::RichText::new("Suffix").color(t.text_dim));
ui.label(egui::RichText::new("Format").color(t.text_dim));
ui.label("");
ui.end_row();
for (i, f) in formats.iter_mut().enumerate() {
let mut sc = f["scale"].as_f64().unwrap_or(1.0);
if ui.add(egui::DragValue::new(&mut sc).range(0.1..=10.0).speed(0.5).suffix("x")).changed() {
f["scale"] = json!(sc);
}
let mut suffix = f["suffix"].as_str().unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut suffix).desired_width(60.0)).changed() {
f["suffix"] = json!(suffix);
}
let cur = f["format"].as_str().unwrap_or("png").to_string();
egui::ComboBox::from_id_salt(("efs-fmt", i)).selected_text(cur.to_uppercase()).width(70.0).show_ui(ui, |ui| {
for fm in ["png", "jpg", "webp", "svg", "pdf"] {
if ui.selectable_label(cur == fm, fm.to_uppercase()).clicked() {
f["format"] = json!(fm);
}
}
});
if ui.small_button("×").clicked() {
remove = Some(i);
}
ui.end_row();
}
});
if let Some(i) = remove {
formats.remove(i);
}
if ui.button("+ Add Scale").clicked() {
let next = formats.len() as f64 + 1.0;
formats.push(json!({"format": "png", "scale": next, "suffix": format!("@{next}x")}));
}
d.fields.insert("formats".into(), Value::Array(formats));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Prefix").color(t.text_dim));
let mut prefix = d.str("prefix");
if ui.add(egui::TextEdit::singleline(&mut prefix).desired_width(120.0)).changed() {
d.fields.insert("prefix".into(), json!(prefix));
}
});
});
});
d.fields.insert("boards".into(), json!(boards));
false
}
/// A small cached rendering of artboard `i` (keyed by document revision).
fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option<egui::TextureHandle> {
let st = app.session.active()?;
let key = egui::Id::new(("ab-thumb", i, st.revision));
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
return Some(t);
}
let doc = st.doc.clone();
let r = doc.artboards.get(i)?.rect;
let scale = 92.0 / r.width().max(r.height()).max(1.0);
let img = app.canvas.renderer.render_region(&doc, r, scale, true);
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let tex = ctx.load_texture(format!("ab-thumb-{i}"), color, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(key, tex.clone()));
Some(tex)
}

View File

@ -0,0 +1,122 @@
//! Field widgets shared by the dialogs: labelled text fields, checkboxes, the generic field grid
//! and the typed parameter editor of the command and effect dialogs.
use serde_json::{Value, json};
use crate::state::Dialog;
use crate::theme::Tokens;
/// A labelled text field bound to `d.fields[key]` (one grid row).
pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(label).color(t.text_dim));
let mut s = d.str(key);
let r = egui::Frame::NONE
.fill(t.input)
.stroke(egui::Stroke::new(1.0, t.input_border))
.corner_radius(egui::CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(6, 3))
.show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(120.0)));
if r.inner.changed() {
d.fields.insert(key.into(), Value::String(s));
}
ui.end_row();
}
/// A checkbox bound to `d.fields[key]`.
pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let mut b = d.bool(key);
if ui.checkbox(&mut b, label).changed() {
d.fields.insert(key.into(), Value::Bool(b));
}
}
/// The generic dialog body: a text field per non-boolean value (positions and indices hidden).
pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog) {
egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in keys {
if k == "x" || k == "y" || k == "origin" || k == "index" || v.is_boolean() {
continue;
}
field(ui, d, &k, &humanize(&k));
}
});
}
/// Command/effect parameters from the dialog fields (drop UI-only keys).
pub(super) fn params(d: &Dialog) -> Value {
Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect())
}
/// Generic editor for command/effect parameters: numbers, booleans, strings and colours.
/// Returns true when a value changed.
pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let mut changed = false;
egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
let keys: Vec<(String, Value)> =
d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in keys {
ui.label(egui::RichText::new(humanize(&k)).color(t.text));
match v {
Value::Number(n) => {
let mut x = n.as_f64().unwrap_or(0.0);
let speed = if x.abs() > 20.0 { 1.0 } else { 0.1 };
if ui.add(egui::DragValue::new(&mut x).speed(speed).max_decimals(2)).changed() {
d.fields.insert(k, json!(x));
changed = true;
}
}
Value::Bool(mut b) => {
if ui.checkbox(&mut b, "").changed() {
d.fields.insert(k, json!(b));
changed = true;
}
}
Value::String(mut s) if s.contains('\n') => {
// Multi-line values (Graph Data CSV) get a text area.
if ui.add(egui::TextEdit::multiline(&mut s).desired_width(260.0).desired_rows(8).font(egui::TextStyle::Monospace)).changed() {
d.fields.insert(k, json!(s));
changed = true;
}
}
Value::String(mut s) => {
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() {
d.fields.insert(k, json!(s));
changed = true;
}
}
other => {
ui.label(egui::RichText::new(other.to_string()).color(t.text_dim).size(11.0));
}
}
ui.end_row();
}
});
changed
}
/// A field label from its camelCase key (`miterLimit` → "Miter Limit:").
pub(super) fn humanize(k: &str) -> String {
let mut s = String::new();
for (i, c) in k.chars().enumerate() {
if i == 0 {
s.extend(c.to_uppercase());
} else if c.is_uppercase() {
s.push(' ');
s.push(c);
} else {
s.push(c);
}
}
match s.as_str() {
"Dx" => "Horizontal".into(),
"Dy" => "Vertical".into(),
"Sx" => "Horizontal %".into(),
"Sy" => "Vertical %".into(),
"Radius1" => "Radius 1".into(),
"Radius2" => "Radius 2".into(),
_ => format!("{s}:"),
}
}

View File

@ -0,0 +1,219 @@
//! Modal dialogs. Fields live in `UiState::dialog` (string/number JSON) so agents can fill them
//! through `ui.dialog.set` and press OK with `ui.dialog.confirm`.
//!
//! Each dialog is a file here exporting a [`DialogSpec`] (heading, body, OK action, buttons) plus
//! a row in the `registry!` below that maps its `Dialog::kind` strings to it. Dialogs inside the
//! shared modal frame only supply a body and a confirm action; a few (Preferences, Keyboard
//! Shortcuts, Workspaces, Find Font) draw their own window.
mod about;
mod all_tools;
mod artboard_options;
mod command;
mod document_setup;
mod effect;
mod export_for_screens;
mod form;
mod new_document;
mod path_ops;
mod recolor;
mod save_changes;
mod shapes;
mod tools;
mod transform;
use serde_json::{Value, json};
pub use tools::open_tool_dialog;
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
type DialogResult = Result<Value, String>;
/// How a dialog draws and applies itself. Specs start from [`DialogSpec::FORM`] and override what
/// differs.
pub(crate) struct DialogSpec {
/// Draws its own window instead of the shared frame (the frame fields below are then unused).
pub window: Option<fn(&mut VectorcraftApp, &egui::Context)>,
/// The heading (and window title).
pub heading: fn(&Dialog) -> String,
/// Draws the fields. Returns true to close the dialog as Cancel would.
pub body: fn(&mut VectorcraftApp, &mut egui::Ui, &mut Dialog) -> bool,
/// OK, Enter or `ui.dialog.confirm`: applies the dialog and closes it when done.
pub confirm: fn(&mut VectorcraftApp, &Dialog) -> DialogResult,
/// The OK button's label. None: no OK button, Enter does nothing and Cancel reads "Close".
pub ok: Option<&'static str>,
/// An extra button left of Cancel that sets `discard: true` and confirms ("Don't Save").
pub discard: Option<&'static str>,
pub min_width: f32,
pub max_width: Option<f32>,
/// The body runs a live preview interaction that Cancel rolls back.
pub preview: bool,
}
impl DialogSpec {
/// A text field per value; OK just closes. Also the fallback for unregistered kinds.
pub const FORM: Self = Self {
window: None,
heading: |_| "Dialog".into(),
body: |_, ui, d| {
form::grid(ui, d);
false
},
confirm: |app, _| {
app.ui.dialog = None;
Ok(Value::Null)
},
ok: Some("OK"),
discard: None,
min_width: 320.0,
max_width: None,
preview: false,
};
/// A dialog that draws its own window and confirms through its module.
const fn window(show: fn(&mut VectorcraftApp, &egui::Context), confirm: fn(&mut VectorcraftApp, &Dialog) -> DialogResult) -> Self {
Self { window: Some(show), confirm, ..Self::FORM }
}
}
/// The dialog registry: one row per dialog with its [`DialogKind`] variant, the `Dialog::kind`
/// strings it handles and its [`DialogSpec`]. Adding a dialog is a new file plus one row.
macro_rules! registry {
($($variant:ident: [$($kind:pat),+] => $spec:expr,)+) => {
/// Every registered dialog.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DialogKind {
$($variant,)+
}
impl DialogKind {
/// The dialog handling a `Dialog::kind` string.
pub fn of(kind: &str) -> Option<Self> {
match kind {
$($($kind)|+ => Some(Self::$variant),)+
_ => None,
}
}
fn spec(self) -> &'static DialogSpec {
match self {
$(Self::$variant => {
static SPEC: DialogSpec = $spec;
&SPEC
})+
}
}
}
};
}
registry! {
NewDocument: ["newDocument"] => new_document::SPEC,
Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment"] => shapes::SPEC,
Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC,
PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC,
DocumentSetup: ["documentSetup"] => document_setup::SPEC,
ArtboardOptions: ["artboardOptions"] => artboard_options::SPEC,
AllTools: ["allTools"] => all_tools::SPEC,
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
Recolor: ["recolor"] => recolor::SPEC,
Command: ["command"] => command::SPEC,
Effect: ["effect"] => effect::SPEC,
SaveChanges: [crate::unsaved::KIND] => save_changes::SPEC,
Preferences: ["preferences"] => DialogSpec::window(crate::prefs_dialog::show, |app, _| crate::prefs_dialog::confirm(app)),
Shortcuts: ["shortcuts"] => DialogSpec::window(crate::shortcut_editor::show, |app, _| crate::shortcut_editor::confirm(app)),
Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)),
FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)),
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
fn spec(kind: &str) -> &'static DialogSpec {
static FALLBACK: DialogSpec = DialogSpec::FORM;
DialogKind::of(kind).map_or(&FALLBACK, DialogKind::spec)
}
/// Run a command and close the dialog (whether or not the command succeeded).
fn run_and_close(app: &mut VectorcraftApp, id: &str, params: Value) -> DialogResult {
let r = app.run(id, params);
app.ui.dialog = None;
r
}
/// Apply the open dialog (OK).
pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
(spec(&d.kind).confirm)(app, &d)
}
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
about::show(app, ctx);
let Some(mut d) = app.ui.dialog.clone() else { return };
let spec = spec(&d.kind);
if let Some(window) = spec.window {
return window(app, ctx);
}
let t = Tokens::get(ctx);
let mut ok = false;
let mut cancel = false;
let mut discard = false;
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
// Modal, but the canvas isn't dimmed so previews stay readable (as in the reference app).
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
let heading = (spec.heading)(&d);
egui::Window::new(heading.as_str())
.id(egui::Id::new("dialog"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
.show(ctx, |ui| {
ui.set_min_width(spec.min_width);
if let Some(w) = spec.max_width {
ui.set_max_width(w);
}
ui.label(egui::RichText::new(heading.as_str()).font(theme::semibold(16.0)).color(t.text));
ui.add_space(12.0);
cancel = (spec.body)(app, ui, &mut d);
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(label) = spec.ok
&& widgets::primary_button(ui, label).clicked()
{
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() {
cancel = true;
}
if let Some(label) = spec.discard {
ui.add_space(28.0);
discard = widgets::secondary_button(ui, label).clicked();
}
});
});
if spec.ok.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
ok = true;
}
if discard {
d.fields.insert("discard".into(), json!(true));
ok = true;
}
app.ui.dialog = Some(d);
if cancel {
if spec.preview {
let _ = app.session.cancel_interaction();
}
app.ui.dialog = None;
} else if ok && let Err(e) = confirm(app) {
app.status(e);
}
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,66 @@
//! File → New: presets and the document details (name, size, artboards, colour mode).
use serde_json::{Value, json};
use super::{DialogSpec, form};
use crate::VectorcraftApp;
use crate::state::Dialog;
use crate::theme::{self, Tokens};
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "New Document".into(), body, confirm, ok: Some("Create"), min_width: 560.0, ..DialogSpec::FORM };
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let presets: [(&str, &str, &str, &str); 8] = [
("Letter", "612 pt", "792 pt", "Points"),
("Legal", "612 pt", "1008 pt", "Points"),
("Tabloid", "792 pt", "1224 pt", "Points"),
("A4", "595.28 pt", "841.89 pt", "Points"),
("A3", "841.89 pt", "1190.55 pt", "Points"),
("Web 1920", "1920 px", "1080 px", "Pixels"),
("Phone 390×844", "390 px", "844 px", "Pixels"),
("Square Post", "1080 px", "1080 px", "Pixels"),
];
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(300.0);
ui.label(egui::RichText::new("Presets").color(t.text_dim));
ui.horizontal_wrapped(|ui| {
for (name, w, h, u) in presets {
let sel = d.str("preset") == name;
if ui.selectable_label(sel, name).clicked() {
d.fields.insert("preset".into(), json!(name));
d.fields.insert("width".into(), json!(w));
d.fields.insert("height".into(), json!(h));
d.fields.insert("units".into(), json!(u));
}
}
});
});
ui.separator();
ui.vertical(|ui| {
ui.label(egui::RichText::new("Preset Details").font(theme::semibold(12.5)));
egui::Grid::new("newdoc").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
form::field(ui, d, "name", "Name:");
form::field(ui, d, "width", "Width:");
form::field(ui, d, "height", "Height:");
form::field(ui, d, "artboards", "Artboards:");
form::field(ui, d, "colorMode", "Color Mode:");
});
});
});
false
}
/// Create the document; the dialog stays open when that fails (bad size, …).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let r = app.run(
"file.new",
json!({"width": d.f64("width", 612.0), "height": d.f64("height", 792.0), "units": d.str("units"), "title": d.str("name"), "artboards": d.f64("artboards", 1.0), "colorMode": d.str("colorMode").to_lowercase()}),
);
if r.is_ok() {
app.ui.dialog = None;
}
r
}

View File

@ -0,0 +1,30 @@
//! Path dialogs: Average, Offset Path, Simplify and Split Into Grid.
use serde_json::{Value, json};
use super::{DialogSpec, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM };
fn title(kind: &str) -> &'static str {
match kind {
"average" => "Average",
"offsetPath" => "Offset Path",
"simplify" => "Simplify",
_ => "Split Into Grid",
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (id, params) = match d.kind.as_str() {
"average" => ("path.average", json!({"axis": d.str("axis")})),
"offsetPath" => {
("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)}))
}
"simplify" => ("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})),
_ => ("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})),
};
run_and_close(app, id, params)
}

View File

@ -0,0 +1,105 @@
//! Edit → Edit Colors → Recolor Artwork: current → new colour pairs, previewed live.
use serde_json::{Value, json};
use super::DialogSpec;
use crate::VectorcraftApp;
use crate::state::Dialog;
use crate::theme::Tokens;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Recolor Artwork".into(), body, confirm, preview: true, ..DialogSpec::FORM };
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if editor(ui, d) || !app.session.in_interaction() {
let _ = app.session.begin_interaction("Recolor Artwork");
let _ = app.session.preview("recolor.apply", &json!({"map": recolor_map(d)}));
}
false
}
/// Keep the previewed recolour as one undo step (or apply it when no preview is running).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let map = recolor_map(d);
app.ui.dialog = None;
if app.session.in_interaction() {
let _ = app.session.preview("recolor.apply", &json!({"map": map}));
return app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string());
}
app.run("recolor.apply", json!({"map": map}))
}
fn recolor_map(d: &Dialog) -> Value {
let mut m = serde_json::Map::new();
for p in d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default() {
if let (Some(a), Some(b)) = (p.get(0).and_then(Value::as_str), p.get(1).and_then(Value::as_str)) {
m.insert(a.to_string(), json!(b));
}
}
Value::Object(m)
}
/// Current → new colour rows, harmony rules, randomize. Returns true when changed.
fn editor(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
use vectorcraft_color::Color;
let t = Tokens::get(ui.ctx());
let mut pairs: Vec<Value> = d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default();
let mut changed = false;
let chip = |ui: &mut egui::Ui, hex: &str| {
let (r, _) = ui.allocate_exact_size(egui::vec2(34.0, 22.0), egui::Sense::hover());
let c = Color::from_hex(hex).unwrap_or(Color::BLACK).to_rgba8(1.0);
ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c[0], c[1], c[2]));
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
};
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Current Colors").color(t.text_dim));
ui.add_space(70.0);
ui.label(egui::RichText::new("New").color(t.text_dim));
});
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for p in pairs.iter_mut() {
let from = p[0].as_str().unwrap_or("#000000").to_string();
let mut to = p[1].as_str().unwrap_or("#000000").to_string();
ui.horizontal(|ui| {
chip(ui, &from);
ui.label(egui::RichText::new("→").color(t.text_dim));
chip(ui, &to);
if ui.add(egui::TextEdit::singleline(&mut to).desired_width(80.0)).changed() && Color::from_hex(&to).is_some() {
p[1] = json!(to);
changed = true;
}
});
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Randomize").clicked() {
// Deterministic scramble of hues (seeded by the colour itself), keeping brightness.
for (i, p) in pairs.iter_mut().enumerate() {
let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK);
let [h, s, v] = c.to_hsb();
let nh = (h + 97.0 * (i as f32 + 1.0) + 41.0) % 360.0;
p[1] = json!(Color::from_hsb(nh, s.max(0.35), v.max(0.3)).to_hex());
}
changed = true;
}
for (label, deg) in [("Complement", 180.0), ("Triad", 120.0), ("Analogous", 30.0)] {
if ui.button(label).clicked() {
for (i, p) in pairs.iter_mut().enumerate() {
let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK);
let [h, s, v] = c.to_hsb();
let shift = if label == "Complement" { deg } else { deg * (i as f32 + 1.0) };
p[1] = json!(Color::from_hsb(h + shift, s, v).to_hex());
}
changed = true;
}
}
if ui.button("Reset").clicked() {
for p in pairs.iter_mut() {
p[1] = p[0].clone();
}
changed = true;
}
});
d.fields.insert("pairs".into(), Value::Array(pairs));
changed
}

View File

@ -0,0 +1,19 @@
//! Save Changes (closing a modified document): Save / Don't Save / Cancel. The flow lives in
//! [`crate::unsaved`]; this is its face in the shared dialog frame.
use super::DialogSpec;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| format!("Do you want to save the changes you made to “{}”?", d.str("name")),
body: |_, ui, _| {
let t = crate::theme::Tokens::get(ui.ctx());
ui.label(egui::RichText::new("Your changes will be lost if you don't save them.").color(t.text_dim));
false
},
confirm: |app, _| crate::unsaved::confirm(app),
ok: Some("Save"),
discard: Some("Don't Save"),
// Long document names wrap instead of widening the dialog.
max_width: Some(420.0),
..DialogSpec::FORM
};

View File

@ -0,0 +1,42 @@
//! Shape size dialogs (a click with a shape tool): Rectangle, Rounded Rectangle, Ellipse, Polygon,
//! Star and Line Segment Tool Options.
use serde_json::{Value, json};
use super::{DialogSpec, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM };
fn title(kind: &str) -> &'static str {
match kind {
"rectangle" => "Rectangle",
"roundedRectangle" => "Rounded Rectangle",
"ellipse" => "Ellipse",
"polygon" => "Polygon",
"star" => "Star",
_ => "Line Segment Tool Options",
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0));
let (id, params) = match d.kind.as_str() {
"ellipse" => ("shape.ellipse", json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0)})),
"polygon" => ("shape.polygon", json!({"cx": x, "cy": y, "radius": d.f64("radius", 50.0), "sides": d.f64("sides", 6.0) as u64})),
"star" => (
"shape.star",
json!({"cx": x, "cy": y, "radius1": d.f64("radius1", 50.0), "radius2": d.f64("radius2", 25.0), "points": d.f64("points", 5.0) as u64}),
),
"lineSegment" => {
let (l, a) = (d.f64("length", 100.0), d.f64("angle", 0.0).to_radians());
("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()}))
}
_ => (
"shape.rectangle",
json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)}),
),
};
run_and_close(app, id, params)
}

View File

@ -0,0 +1,165 @@
//! The dialog registry and the shared frame, drawn headlessly.
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app
}
/// One headless frame of the dialog layer.
fn frame(app: &mut VectorcraftApp, input: egui::RawInput) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut out = ctx.run_ui(input, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
}
fn enter() -> egui::RawInput {
let key = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
egui::RawInput { events: vec![key], ..Default::default() }
}
fn open(app: &mut VectorcraftApp, kind: &str, fields: serde_json::Value) {
app.ui.dialog = Some(Dialog::new(kind, fields));
}
fn objects(app: &VectorcraftApp) -> usize {
app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum()
}
#[test]
fn kinds_map_to_their_dialogs() {
for (kind, want) in [
("newDocument", DialogKind::NewDocument),
("roundedRectangle", DialogKind::Shape),
("lineSegment", DialogKind::Shape),
("shear", DialogKind::Transform),
("splitIntoGrid", DialogKind::PathOp),
("artboardOptions", DialogKind::ArtboardOptions),
("command", DialogKind::Command),
("effect", DialogKind::Effect),
(crate::unsaved::KIND, DialogKind::SaveChanges),
("manageWorkspaces", DialogKind::Workspaces),
("findFont", DialogKind::FindFont),
] {
assert_eq!(DialogKind::of(kind), Some(want), "{kind}");
}
assert_eq!(DialogKind::of("nope"), None);
assert!(spec("allTools").ok.is_none());
assert!(spec("effect").preview && spec("recolor").preview && !spec("move").preview);
assert!(spec("preferences").window.is_some() && spec("rectangle").window.is_none());
}
#[test]
fn every_dialog_draws() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
let openers: [(&str, serde_json::Value); 8] = [
("file.newDialog", json!({})),
("file.documentSetup", json!({})),
("file.exportForScreens", json!({})),
("ui.recolorDialog", json!({})),
("ui.paramDialog", json!({"command": "object.path.simplify", "label": "Simplify", "params": {"tolerance": 2, "csv": "a\nb"}})),
("effect.dialog", json!({"effect": "distort.roughen"})),
("edit.preferences", json!({})),
("type.findFont", json!({})),
];
for (id, p) in openers {
app.run(id, p).unwrap();
assert!(app.ui.dialog.is_some(), "{id} opened nothing");
frame(&mut app, Default::default());
app.ui.dialog = None;
let _ = app.session.cancel_interaction();
}
for kind in
["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment", "rotate", "reflect", "scale", "shear", "artboardOptions"]
{
open_tool_dialog(&mut app, kind, json!({"x": 5, "y": 5, "index": 0}));
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == kind), "{kind}");
frame(&mut app, Default::default());
}
for (kind, fields) in [
("move", json!({"dx": "0 pt", "dy": "0 pt"})),
("average", json!({"axis": "both"})),
("allTools", json!({})),
(crate::unsaved::KIND, json!({"index": 0, "name": "Untitled-1", "then": "close"})),
("newWorkspace", json!({"name": "Mine"})),
("shortcuts", json!({})),
("unregistered", json!({"value": "1"})),
] {
open(&mut app, kind, fields);
frame(&mut app, Default::default());
}
}
#[test]
fn enter_confirms_through_the_dialog_spec() {
let mut app = app();
open_tool_dialog(&mut app, "rectangle", json!({"x": 10, "y": 20}));
frame(&mut app, enter());
assert!(app.ui.dialog.is_none());
assert_eq!(objects(&app), 1);
// All Tools has no OK: Enter leaves it open.
open(&mut app, "allTools", json!({}));
frame(&mut app, enter());
assert!(app.ui.dialog.is_some());
// An unregistered kind still closes on OK without running anything.
open(&mut app, "unregistered", json!({}));
assert_eq!(confirm(&mut app), Ok(serde_json::Value::Null));
assert!(app.ui.dialog.is_none());
assert_eq!(confirm(&mut app), Err("no dialog open".into()));
}
#[test]
fn confirm_runs_each_dialogs_command() {
let mut app = app();
open(&mut app, "star", json!({"x": 50, "y": 50, "radius1": "40 pt", "radius2": "20 pt", "points": 6}));
confirm(&mut app).unwrap();
open(&mut app, "lineSegment", json!({"x": 0, "y": 0, "length": "30 pt", "angle": 90}));
confirm(&mut app).unwrap();
assert_eq!(objects(&app), 2);
app.run("select.all", json!({})).unwrap();
// Scale with Uniform uses the horizontal value for both axes; Copy keeps the original.
open(&mut app, "scale", json!({"sx": 200, "sy": 50, "uniform": true, "copy": true}));
confirm(&mut app).unwrap();
assert_eq!(objects(&app), 4);
// The parameter dialog closes before its command runs, then runs it with the edited values.
app.run("ui.paramDialog", json!({"command": "view.saved.new", "label": "New View", "params": {"name": "Close-up"}})).unwrap();
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.doc().unwrap().doc.views.last().map(|v| v.name.as_str()), Some("Close-up"));
// New Document creates the document and closes.
let docs = app.session.documents().len();
open(
&mut app,
"newDocument",
json!({"width": "300 pt", "height": "200 pt", "units": "Points", "name": "Card", "artboards": 1, "colorMode": "RGB"}),
);
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.documents().len(), docs + 1);
}
#[test]
fn effect_preview_is_kept_as_one_undo_step() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true));
frame(&mut app, Default::default());
assert!(app.session.in_interaction(), "the preview runs as an interaction");
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
assert_eq!(app.last_effect.as_ref().map(|e| e.0.as_str()), Some("distort.roughen"));
assert_ne!(app.session.doc().unwrap().doc, before);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}

View File

@ -0,0 +1,53 @@
//! Dialogs a tool click opens: shape sizes, transform values, graph size and data, flare options
//! and artboard options.
use serde_json::{Value, json};
use crate::VectorcraftApp;
use crate::state::Dialog;
/// A click with a shape tool opens its size dialog.
pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
let x = p.get("x").and_then(Value::as_f64).unwrap_or(0.0);
let y = p.get("y").and_then(Value::as_f64).unwrap_or(0.0);
let d = match kind {
"rectangle" | "ellipse" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt"})),
"roundedRectangle" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "radius": "12 pt"})),
"polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})),
"star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})),
"lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})),
// Graph tool click: the graph's size.
"graph" => Dialog::new(
"command",
json!({"__command": "graph.create", "__label": "Graph", "type": p.get("type").cloned().unwrap_or(json!("column")), "x": x, "y": y, "width": 200, "height": 150}),
),
// After drawing a graph: Graph Data (CSV: header row of series, then category, values…).
"graphData" => match app.session.execute("graph.setData", &json!({})) {
Ok(v) => Dialog::new("command", json!({"__command": "graph.setData", "__label": "Graph Data", "csv": v["csv"]})),
Err(_) => return,
},
// Flare Tool Options (Center / Halo / Rays / Rings), applied through the generic command dialog.
"flare" => Dialog::new(
"command",
json!({"__command": "shape.flare", "__label": "Flare Tool Options", "cx": x, "cy": y, "diameter": 100, "opacity": 50, "brightness": 30,
"growth": 20, "fuzziness": 50, "rays": 15, "longest": 300, "rayFuzziness": 100, "pathLength": 300, "rings": 10, "largest": 50, "direction": 45}),
),
"rotate" | "reflect" | "scale" | "shear" | "artboardOptions" => {
let mut base = match kind {
"rotate" => json!({"angle": 0}),
"reflect" => json!({"axis": "vertical"}),
"scale" => json!({"sx": 100, "sy": 100, "uniform": true}),
"shear" => json!({"angle": 0, "axis": "horizontal"}),
_ => json!({}),
};
if let (Some(b), Some(o)) = (base.as_object_mut(), p.as_object()) {
for (k, v) in o {
b.insert(k.clone(), v.clone());
}
}
Dialog::new(kind, base)
}
_ => return,
};
app.ui.dialog = Some(d);
}

View File

@ -0,0 +1,53 @@
//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Copy, and Uniform for Scale).
use serde_json::{Value, json};
use super::{DialogSpec, form, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, ..DialogSpec::FORM };
fn title(kind: &str) -> &'static str {
match kind {
"move" => "Move",
"rotate" => "Rotate",
"scale" => "Scale",
"reflect" => "Reflect",
_ => "Shear",
}
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::grid(ui, d);
ui.add_space(6.0);
if d.kind == "scale" {
form::check(ui, d, "uniform", "Uniform");
}
form::check(ui, d, "copy", "Copy (make a transformed copy)");
false
}
/// Pass a tool-chosen reference point (origin) through to transform commands.
fn origin_params(d: &Dialog, mut p: Value) -> Value {
if let Some(o) = d.fields.get("origin") {
p["origin"] = o.clone();
}
p
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let copy = d.bool("copy");
let (id, params) = match d.kind.as_str() {
"move" => return run_and_close(app, "object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy})),
"rotate" => ("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy})),
"scale" => {
let sx = d.f64("sx", 100.0);
let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) };
("object.scale", json!({"sx": sx, "sy": sy, "copy": copy}))
}
"reflect" => ("object.reflect", json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy})),
_ => ("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy})),
};
run_and_close(app, id, origin_params(d, params))
}