From b7969a6a5d32b2957973dbb2cbaadf966dfd73ab Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 11:22:23 +0700 Subject: [PATCH] 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 --- crates/engine/src/cmd/appearance.rs | 244 ++++++ crates/engine/src/cmd/gradient.rs | 205 +++++ crates/engine/src/cmd/mod.rs | 45 ++ crates/engine/src/cmd/paint.rs | 325 +------- crates/engine/src/cmd/panelcmds.rs | 398 +--------- crates/engine/src/cmd/stroke.rs | 172 +++++ crates/engine/src/cmd/style.rs | 72 ++ crates/engine/src/cmd/swatch.rs | 122 +++ crates/engine/src/cmd/xform.rs | 128 +--- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_cmdsplit.rs | 101 +++ crates/engine/src/tests_panelcmds.rs | 2 +- crates/ui-egui/src/dialogs.rs | 702 ------------------ crates/ui-egui/src/dialogs/about.rs | 39 + crates/ui-egui/src/dialogs/all_tools.rs | 22 + .../ui-egui/src/dialogs/artboard_options.rs | 20 + crates/ui-egui/src/dialogs/command.rs | 26 + crates/ui-egui/src/dialogs/document_setup.rs | 11 + crates/ui-egui/src/dialogs/effect.rs | 41 + .../ui-egui/src/dialogs/export_for_screens.rs | 154 ++++ crates/ui-egui/src/dialogs/form.rs | 122 +++ crates/ui-egui/src/dialogs/mod.rs | 219 ++++++ crates/ui-egui/src/dialogs/new_document.rs | 66 ++ crates/ui-egui/src/dialogs/path_ops.rs | 30 + crates/ui-egui/src/dialogs/recolor.rs | 105 +++ crates/ui-egui/src/dialogs/save_changes.rs | 19 + crates/ui-egui/src/dialogs/shapes.rs | 42 ++ crates/ui-egui/src/dialogs/tests.rs | 165 ++++ crates/ui-egui/src/dialogs/tools.rs | 53 ++ crates/ui-egui/src/dialogs/transform.rs | 53 ++ 30 files changed, 2159 insertions(+), 1546 deletions(-) create mode 100644 crates/engine/src/cmd/appearance.rs create mode 100644 crates/engine/src/cmd/gradient.rs create mode 100644 crates/engine/src/cmd/stroke.rs create mode 100644 crates/engine/src/cmd/style.rs create mode 100644 crates/engine/src/cmd/swatch.rs create mode 100644 crates/engine/src/tests_cmdsplit.rs delete mode 100644 crates/ui-egui/src/dialogs.rs create mode 100644 crates/ui-egui/src/dialogs/about.rs create mode 100644 crates/ui-egui/src/dialogs/all_tools.rs create mode 100644 crates/ui-egui/src/dialogs/artboard_options.rs create mode 100644 crates/ui-egui/src/dialogs/command.rs create mode 100644 crates/ui-egui/src/dialogs/document_setup.rs create mode 100644 crates/ui-egui/src/dialogs/effect.rs create mode 100644 crates/ui-egui/src/dialogs/export_for_screens.rs create mode 100644 crates/ui-egui/src/dialogs/form.rs create mode 100644 crates/ui-egui/src/dialogs/mod.rs create mode 100644 crates/ui-egui/src/dialogs/new_document.rs create mode 100644 crates/ui-egui/src/dialogs/path_ops.rs create mode 100644 crates/ui-egui/src/dialogs/recolor.rs create mode 100644 crates/ui-egui/src/dialogs/save_changes.rs create mode 100644 crates/ui-egui/src/dialogs/shapes.rs create mode 100644 crates/ui-egui/src/dialogs/tests.rs create mode 100644 crates/ui-egui/src/dialogs/tools.rs create mode 100644 crates/ui-egui/src/dialogs/transform.rs diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs new file mode 100644 index 00000000..10f24bad --- /dev/null +++ b/crates/engine/src/cmd/appearance.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::>() })) +} diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs new file mode 100644 index 00000000..01b0d0e7 --- /dev/null +++ b/crates/engine/src/cmd/gradient.rs @@ -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 { + 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) -> std::result::Result { + 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 = 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 { + 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::>()})) +} + +fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { + 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::>() })) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 375fb7da..26dd788f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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> { pub(crate) fn ok() -> Result { 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> { + 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 = 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> { + 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()) +} diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 4aecee2d..712d0af5 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -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 { 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 { 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> { Ok(None) } -/// Leaf objects whose appearance should change for `ids` (groups apply to children). -fn paint_targets(s: &Session, p: &Value) -> Result> { - 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 { 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 { ok() } -fn stroke_set(s: &mut Session, p: &Value) -> Result { - 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>> { - 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::(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 = 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { 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 { } s.execute("object.setProps", &q) } - -fn style_apply(s: &mut Session, p: &Value) -> Result { - 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 { - 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 { - 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 { - 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() -} diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index f2e1c90b..14d02b9a 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -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 { 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 { 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 { ] } -// ---------- gradient ---------- - -/// Leaf paint targets (groups apply to their children), like the Swatches/Color panels. -fn leaf_targets(s: &Session, p: &Value) -> Result> { - 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) -> std::result::Result { - 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 = 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 { - 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::>()})) -} - -// ---------- appearance ---------- - -fn duplicate_item(s: &mut Session, p: &Value) -> Result { - 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 { - 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 { @@ -298,102 +65,6 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { 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 { - let names: Vec = - 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 = - 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 { - 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 { - 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 { - 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 { - 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 { @@ -487,66 +158,3 @@ fn set_format(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } - -// ---------- 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 { - 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() -} diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs new file mode 100644 index 00000000..c97ace47 --- /dev/null +++ b/crates/engine/src/cmd/stroke.rs @@ -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 { + 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 { + 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>> { + 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::(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 = 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 { + 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() +} diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs new file mode 100644 index 00000000..6e8d80b0 --- /dev/null +++ b/crates/engine/src/cmd/style.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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})) +} diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs new file mode 100644 index 00000000..7b5793d9 --- /dev/null +++ b/crates/engine/src/cmd/swatch.rs @@ -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 { + 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 { + 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 { + 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 { + let names: Vec = + 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 = 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 { + 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 { + 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() +} diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 99419723..a0d45261 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -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 { 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 { 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 { // ---------- 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> { - 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) -} - -fn copy_from(s: &mut Session, p: &Value) -> Result { - 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::>() })) -} - fn sample_color(s: &mut Session, p: &Value) -> Result { 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 { (spec.run)(s, &q) } -// ---------- gradient tool ---------- - -fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { - 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::>() })) -} - // ---------- artboards ---------- fn artboard_move(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f62d5973..020464db 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_cmdsplit.rs b/crates/engine/src/tests_cmdsplit.rs new file mode 100644 index 00000000..a14eab53 --- /dev/null +++ b/crates/engine/src/tests_cmdsplit.rs @@ -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"); +} diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index 2f44d0f1..6b573ac5 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -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}; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs deleted file mode 100644 index ad1d3487..00000000 --- a/crates/ui-egui/src/dialogs.rs +++ /dev/null @@ -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 { - 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 = 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 = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); - let mut boards: Vec = - 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 = 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 { - 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::(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 = 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 -} diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs new file mode 100644 index 00000000..4fc1392e --- /dev/null +++ b/crates/ui-egui/src/dialogs/about.rs @@ -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; +} diff --git a/crates/ui-egui/src/dialogs/all_tools.rs b/crates/ui-egui/src/dialogs/all_tools.rs new file mode 100644 index 00000000..a9f6dd76 --- /dev/null +++ b/crates/ui-egui/src/dialogs/all_tools.rs @@ -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 +} diff --git a/crates/ui-egui/src/dialogs/artboard_options.rs b/crates/ui-egui/src/dialogs/artboard_options.rs new file mode 100644 index 00000000..b47c92b7 --- /dev/null +++ b/crates/ui-egui/src/dialogs/artboard_options.rs @@ -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 { + 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) +} diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs new file mode 100644 index 00000000..d732cda7 --- /dev/null +++ b/crates/ui-egui/src/dialogs/command.rs @@ -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 { + let cmd = d.str("__command"); + let params = form::params(d); + app.ui.dialog = None; + app.run(&cmd, params) +} diff --git a/crates/ui-egui/src/dialogs/document_setup.rs b/crates/ui-egui/src/dialogs/document_setup.rs new file mode 100644 index 00000000..273a5bb7 --- /dev/null +++ b/crates/ui-egui/src/dialogs/document_setup.rs @@ -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 +}; diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs new file mode 100644 index 00000000..99fe895d --- /dev/null +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -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 { + 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 +} diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs new file mode 100644 index 00000000..02104954 --- /dev/null +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -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 { + let boards: Vec = 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 = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); + let mut boards: Vec = + 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 = 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 { + 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::(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) +} diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs new file mode 100644 index 00000000..d30b461a --- /dev/null +++ b/crates/ui-egui/src/dialogs/form.rs @@ -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}:"), + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs new file mode 100644 index 00000000..9532e989 --- /dev/null +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -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; + +/// 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, + /// 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, + /// 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 { + 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; diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs new file mode 100644 index 00000000..ef4e549f --- /dev/null +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -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 { + 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 +} diff --git a/crates/ui-egui/src/dialogs/path_ops.rs b/crates/ui-egui/src/dialogs/path_ops.rs new file mode 100644 index 00000000..24b600f5 --- /dev/null +++ b/crates/ui-egui/src/dialogs/path_ops.rs @@ -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 { + 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) +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs new file mode 100644 index 00000000..35c22b5a --- /dev/null +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -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 { + 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 = 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 +} diff --git a/crates/ui-egui/src/dialogs/save_changes.rs b/crates/ui-egui/src/dialogs/save_changes.rs new file mode 100644 index 00000000..7f50e027 --- /dev/null +++ b/crates/ui-egui/src/dialogs/save_changes.rs @@ -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 +}; diff --git a/crates/ui-egui/src/dialogs/shapes.rs b/crates/ui-egui/src/dialogs/shapes.rs new file mode 100644 index 00000000..c1ca0c7e --- /dev/null +++ b/crates/ui-egui/src/dialogs/shapes.rs @@ -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 { + 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) +} diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs new file mode 100644 index 00000000..5c42eb2a --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/dialogs/tools.rs b/crates/ui-egui/src/dialogs/tools.rs new file mode 100644 index 00000000..0016f60a --- /dev/null +++ b/crates/ui-egui/src/dialogs/tools.rs @@ -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); +} diff --git a/crates/ui-egui/src/dialogs/transform.rs b/crates/ui-egui/src/dialogs/transform.rs new file mode 100644 index 00000000..40986887 --- /dev/null +++ b/crates/ui-egui/src/dialogs/transform.rs @@ -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 { + 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)) +}