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:
parent
d99853ed2a
commit
b7969a6a5d
|
|
@ -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<_>>() }))
|
||||
}
|
||||
|
|
@ -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<_>>() }))
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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}))
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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}:"),
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
Loading…
Reference in New Issue