diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index af14a2df..d58a7698 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -4,6 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Appearance, CharStyle, LiveShape, Node, NodeKind, TextObject}; use vectorcraft_geom::{Affine, Anchor, AnchorKind, FillRule, PathData, Point, Rect, SubPath, shapes}; +use super::newart::NewArt; use super::*; pub fn specs() -> Vec { @@ -56,13 +57,20 @@ pub fn specs() -> Vec { ] } -/// Insert a new object at the top of the insertion parent with the current paint; select it. +/// Insert a new object at the top of the insertion parent with the current paint and new-art +/// template; select it. pub(crate) fn add_art(s: &mut Session, label: &str, kind: NodeKind, name: Option) -> Result { - let appearance = Appearance::basic(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); - add_node(s, label, kind, appearance, name) + let look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); + add_look(s, label, kind, look, name) } +/// [`add_look`] with just `appearance`. pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance: Appearance, name: Option) -> Result { + add_look(s, label, kind, NewArt::plain(appearance), name) +} + +/// Insert a new object looking like `look` (drawing modes apply); select it. +pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option) -> Result { let parent = s.doc()?.insertion_parent(); let mode = s.draw_mode; let inside = s.draw_inside; @@ -70,7 +78,7 @@ pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance: let id = s.edit(label, |d, sel| { let id = d.alloc_id(); let mut n = Node::new(id, kind); - n.appearance = appearance; + look.apply(d, &mut n); n.name = name; match (mode, inside) { (crate::DrawMode::Inside, Some(target)) if d.node(target).is_some() => { @@ -291,12 +299,12 @@ fn line(s: &mut Session, p: &Value) -> Result { let b = Point::new(f64_req(p, "x2", "shape.line")?, f64_req(p, "y2", "shape.line")?); let live = LiveShape::Line { a, b }; // Lines are stroked, never filled (Illustrator ignores the fill for new open lines). - let appearance = Appearance::basic( + let look = s.new_art_look( vectorcraft_color::Paint::None, if s.paint.stroke.is_none() { vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK) } else { s.paint.stroke.clone() }, s.paint.stroke_width.max(0.1), ); - add_node(s, "Line", path_kind(live.to_path(), Some(live)), appearance, None) + add_look(s, "Line", path_kind(live.to_path(), Some(live)), look, None) } fn spiral(s: &mut Session, p: &Value) -> Result { @@ -319,13 +327,14 @@ fn arc(s: &mut Session, p: &Value) -> Result { fn grid_group(s: &mut Session, label: &str, paths: Vec) -> Result { let parent = s.doc()?.insertion_parent(); - let ap = Appearance::basic(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width); + let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width); let id = s.edit(label, |d, sel| { let children = paths .into_iter() .map(|pd| { - let id = d.alloc_id(); - std::sync::Arc::new(Node::path(id, pd, ap.clone())) + let mut n = Node::path(d.alloc_id(), pd, Appearance::default()); + look.apply(d, &mut n); + std::sync::Arc::new(n) }) .collect(); let gid = d.alloc_id(); @@ -378,12 +387,13 @@ fn path_create(s: &mut Session, p: &Value) -> Result { } PathData::single(SubPath::new(anchors, bool_or(p, "closed", false))) }; - let mut ap = Appearance::basic(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); + let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); // Open paths drawn with a stroke of None would be invisible: mimic Illustrator and keep what the user set. + let ap = &mut look.appearance; if !path.is_closed() && ap.stroke_paint().is_none() && ap.fill_paint().is_none() { ap.set_stroke(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK)); } - add_node(s, "Pen", path_kind(path, None), ap, None) + add_look(s, "Pen", path_kind(path, None), look, None) } fn text_create(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index b9209bfb..fb12148c 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -10,7 +10,7 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, R use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions}; -use super::create::add_node; +use super::create::{add_look, add_node}; use super::*; use crate::EngineError; @@ -505,8 +505,8 @@ fn freehand(s: &mut Session, p: &Value) -> Result { } let sp = fit_freehand(&pts, tol, closed); let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None }; - let ap = Appearance::basic(fill, stroke_paint(s), s.paint.stroke_width.max(0.1)); - add_node(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), ap, None) + let look = s.new_art_look(fill, stroke_paint(s), s.paint.stroke_width.max(0.1)); + add_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None) } // ---------- Curvature ---------- @@ -536,11 +536,12 @@ fn curvature(s: &mut Session, p: &Value) -> Result { })?; return Ok(json!({ "id": id.0 })); } - let mut ap = Appearance::basic(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); + let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); + let ap = &mut look.appearance; if ap.stroke_paint().is_none() && ap.fill_paint().is_none() { ap.set_stroke(Paint::solid(Color::BLACK)); } - add_node(s, "Curvature", path_kind(PathData::single(sp)), ap, None) + add_look(s, "Curvature", path_kind(PathData::single(sp)), look, None) } // ---------- anchor tools ---------- diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 385f280c..eaa2896b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -26,6 +26,7 @@ mod layer; mod live; pub(crate) mod maskedit; pub(crate) mod menucmds; +pub(crate) mod newart; mod object; mod opacitymask; mod overprint; @@ -206,6 +207,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(stylelib::specs()); v.extend(expand::specs()); v.extend(attributes::specs()); + v.extend(newart::specs()); v }) } diff --git a/crates/engine/src/cmd/newart.rs b/crates/engine/src/cmd/newart.rs new file mode 100644 index 00000000..5b1cb68d --- /dev/null +++ b/crates/engine/src/cmd/newart.rs @@ -0,0 +1,234 @@ +//! The new-art template: what the next object drawn looks like. +//! +//! The fill and stroke proxies (`Session::paint`) give new art its paints and stroke weight; the +//! template ([`PaintDefaults::appearance`]) gives it the rest: the Stroke panel's options (set by +//! `stroke.set` whatever is selected), a graphic style clicked with nothing selected (more fills +//! and strokes, effects, opacity and blend mode, and the link to the style) or, with the Appearance +//! panel's New Art Has Basic Appearance off, the whole appearance of the last selection. With the +//! option on (the default) an inherited appearance gives new art its top stroke's options only. +//! `paint.default` resets it. + +use std::borrow::Cow; + +use serde_json::{Value, json}; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, GraphicStyle, Node, NodeKind, StrokeLayer}; +use vectorcraft_tools::PaintDefaults; + +use super::gradient::unplaced; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "appearance.setNewArtBasic", + "New Art Has Basic Appearance", + ["Window", "Appearance"], + None, + "{on?: bool (omitted: toggle)} the Appearance panel option (the preference newArtBasic, on by default): on, new art takes one fill and stroke (with the Stroke panel's options); off, the whole appearance of the last selected object (every fill, stroke and effect, opacity and blend mode) → {on}", + always, + set_basic + ), + cmd!( + query "appearance.newArt", + "New Art Appearance", + [], + None, + "{} what the next object drawn gets → {basic: New Art Has Basic Appearance, appearance: {items, effects} (the model's JSON; placed gradients relative to the unit box), opacity: 0..100, blend, graphicStyle: the style it is linked to or null, inherited: taken from the last selection}", + always, + describe + ), + ] +} + +/// `t` reduced to a basic appearance: an empty fill under its top stroke (options kept, without +/// its own transparency or effects). +fn basic_of(t: &Appearance) -> Appearance { + let mut items = vec![AppearanceItem::Fill(FillLayer::new(Paint::None))]; + if let Some(st) = t.stroke() { + items.push(AppearanceItem::Stroke(StrokeLayer { opacity: 1.0, blend: BlendMode::Normal, visible: true, effects: vec![], ..st.clone() })); + } + Appearance { items, ..Default::default() } +} + +/// Paint `p` on the top fill (`fill`) or stroke of `ap`, creating it unless `p` is None. A paint +/// that only differs in where its gradient is placed keeps its place. +fn repaint(ap: &mut Appearance, fill: bool, p: Paint) { + match ap.paint_at(None, fill) { + Some(cur) if unplaced(cur) == p => {} + Some(_) => { + ap.set_paint_at(None, fill, p); + } + None if p.is_none() => {} + None if fill => ap.set_fill(p), + None => ap.set_stroke(p), + } +} + +/// `ap` with every gradient fitted to the art it lands on. +fn unplace_all(mut ap: Appearance) -> Appearance { + for it in &mut ap.items { + let p = it.paint_mut(); + *p = unplaced(p); + } + ap +} + +/// How a new object looks ([`Session::new_art_look`]): its appearance and transparency and the +/// graphic style it is linked to. +pub(crate) struct NewArt { + pub(crate) appearance: Appearance, + opacity: f32, + blend: BlendMode, + style: Option, + /// From the template: placed gradients are relative to the unit box. + unit_box: bool, +} + +impl NewArt { + /// A plain look: `appearance`, full opacity, no link. + pub(crate) fn plain(appearance: Appearance) -> Self { + Self { appearance, opacity: 1.0, blend: BlendMode::Normal, style: None, unit_box: false } + } + + /// Give new object `n` (in `d`) this look: its placed gradients land relative to its bounds, and + /// it links to the style (type keeps its own transparency). + pub(crate) fn apply(&self, d: &mut vectorcraft_doc::Document, n: &mut Node) { + n.appearance = self.appearance.clone(); + if self.unit_box + && n.appearance.has_placed_gradient() + && let Some(b) = n.geometric_bounds() + { + n.appearance.rebase_gradients(GraphicStyle::UNIT_BOX, b); + } + if matches!(n.kind, NodeKind::Text(_)) { + return; + } + (n.opacity, n.blend) = (self.opacity, self.blend); + n.graphic_style = self.style.as_deref().and_then(|name| d.graphic_style_index(name)).map(|i| d.graphic_style_id(i)); + } +} + +impl Session { + /// The template as new art takes it now (an inherited one reduced to basic while New Art Has + /// Basic Appearance is on). + fn template(&self) -> Option> { + let t = self.paint.appearance.as_ref()?; + Some(if self.basic_only() { Cow::Owned(basic_of(t)) } else { Cow::Borrowed(t) }) + } + + /// Does an inherited template give new art only its basic fill and stroke? + fn basic_only(&self) -> bool { + self.paint.inherited && self.prefs.new_art_basic + } + + /// The look of new art painted with `fill` and `stroke` (weight `width`) on its top fill and + /// stroke: the template's, else one plain fill and stroke. + pub(crate) fn new_art_look(&self, fill: Paint, stroke: Paint, width: f64) -> NewArt { + let Some(t) = self.template() else { return NewArt::plain(Appearance::basic(fill, stroke, width)) }; + let mut appearance = t.into_owned(); + repaint(&mut appearance, true, fill); + repaint(&mut appearance, false, stroke); + if let Some(st) = appearance.stroke_mut() { + st.width = width; + } + let (opacity, blend, style) = self.new_art_transparency(); + NewArt { appearance, opacity, blend, style: style.map(str::to_string), unit_box: true } + } + + /// The appearance of the next object drawn (the Appearance panel shows it with nothing + /// selected). Placed gradients are relative to the unit box. + pub fn new_art(&self) -> Appearance { + self.new_art_look(self.paint.fill.clone(), self.paint.stroke.clone(), self.paint.stroke_width).appearance + } + + /// The opacity, blend mode and graphic style (name) of the next object drawn. + pub fn new_art_transparency(&self) -> (f32, BlendMode, Option<&str>) { + if self.basic_only() { (1.0, BlendMode::Normal, None) } else { (self.paint.opacity, self.paint.blend, self.paint.style.as_deref()) } + } + + /// The stroke of the next object drawn (the Stroke panel shows it with nothing selected). + pub fn new_art_stroke(&self) -> StrokeLayer { + let template = self.paint.appearance.as_ref().and_then(Appearance::stroke); + let mut st = template.cloned().unwrap_or_else(|| StrokeLayer::new(Paint::None, 0.0)); + (st.paint, st.width) = (self.paint.stroke.clone(), self.paint.stroke_width); + st + } + + /// The template's top stroke for an edit (`stroke.set`): the template becomes the user's own + /// (an inherited one as new art takes it now), with a stroke to edit. + pub(crate) fn new_art_stroke_mut(&mut self) -> &mut StrokeLayer { + let own = self.template().map(Cow::into_owned); + if self.basic_only() { + (self.paint.opacity, self.paint.blend, self.paint.style) = (1.0, BlendMode::Normal, None); + } + self.paint.inherited = false; + let t = self.paint.appearance.insert(own.unwrap_or_else(|| Appearance::basic(Paint::None, Paint::None, 0.0))); + if t.stroke().is_none() { + t.set_stroke(Paint::None); + } + t.stroke_mut().expect("a stroke was just made") + } + + /// A graphic style clicked with nothing selected: new art takes it (`add`: its fills, strokes + /// and effects on top of the template's, unlinked). + pub(crate) fn new_art_style(&mut self, g: &GraphicStyle, add: bool) { + let ap = if g.unit_box { g.appearance.clone() } else { unplace_all(g.appearance.clone()) }; + if add { + let _ = self.new_art_stroke_mut(); + let t = self.paint.appearance.as_mut().expect("made by new_art_stroke_mut"); + t.items.extend(ap.items); + t.effects.extend(ap.effects); + self.paint.style = None; + return; + } + self.take_new_art(ap, g.opacity, g.blend, Some(g.name.clone()), false); + } + + /// Make `ap` (placed gradients relative to the unit box) and this transparency the template, + /// and its top fill, stroke and weight the proxies. + fn take_new_art(&mut self, ap: Appearance, opacity: f32, blend: BlendMode, style: Option, inherited: bool) { + let p = &mut self.paint; + (p.fill, p.stroke) = (unplaced(&ap.fill_paint()), unplaced(&ap.stroke_paint())); + if let Some(st) = ap.stroke() { + p.stroke_width = st.width; + } + (p.appearance, p.opacity, p.blend, p.style, p.inherited) = (Some(ap), opacity, blend, style, inherited); + } + + /// After each command: with New Art Has Basic Appearance off, new art takes the appearance of + /// the first selected object (so the last selection's once nothing is selected). + pub(crate) fn inherit_new_art(&mut self) { + if self.prefs.new_art_basic { + return; + } + let Some(st) = self.active() else { return }; + let Some(n) = st.selection.subjects().first().and_then(|id| st.doc.node(*id)) else { return }; + let style = super::style::linked_style(&st.doc, n).map(|g| g.name.clone()); + let (ap, opacity, blend) = (super::style::captured(n), n.opacity, n.blend); + self.take_new_art(ap, opacity, blend, style, true); + } +} + +fn set_basic(s: &mut Session, p: &Value) -> Result { + let on = p.get("on").and_then(Value::as_bool).unwrap_or(!s.prefs.new_art_basic); + s.prefs.new_art_basic = on; + Ok(json!({ "on": on })) +} + +fn describe(s: &mut Session, _: &Value) -> Result { + let (opacity, blend, style) = s.new_art_transparency(); + Ok(json!({ + "basic": s.prefs.new_art_basic, + "appearance": s.new_art(), + "opacity": (opacity * 100.0).round(), + "blend": blend.label(), + "graphicStyle": style, + "inherited": s.paint.inherited, + })) +} + +/// The defaults for new art reset (`paint.default`): white fill, 1 pt black stroke, no template. +pub(crate) fn reset(s: &mut Session) { + s.paint = PaintDefaults::default(); +} diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index e052a2b9..cc31449e 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -448,9 +448,7 @@ fn swap(s: &mut Session, p: &Value) -> Result { } fn default_paint(s: &mut Session, p: &Value) -> Result { - s.paint.fill = Paint::solid(Color::WHITE); - s.paint.stroke = Paint::solid(Color::BLACK); - s.paint.stroke_width = 1.0; + super::newart::reset(s); let ids = paint_targets(s, p)?; if !ids.is_empty() { s.edit("Default Fill and Stroke", |d, _| { diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 5bc776d6..912bb7aa 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -296,6 +296,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("patternTileEdgeColor", "Guides & Grid", "Pattern Editing", "Tile Edge Color", color), // Object → Create Trim Marks, Effect → Crop Marks p!("japaneseCropMarks", "General", "Options", "Use Japanese Crop Marks", bool), + // Appearance panel menu + p!("newArtBasic", "General", "Appearance Panel", "New Art Has Basic Appearance", bool), ]; pub fn spec(key: &str) -> Option<&'static PrefSpec> { diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 782c17ff..c4e647a6 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)", has_doc, stroke_set ), @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Stroke Options", [], None, - "{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, item?: stroke item index|null, ids?} Stroke panel extras", + "{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, item?: stroke item index|null, ids?} Stroke panel extras (with nothing selected, all but brush set up the next object drawn)", has_doc, stroke_advanced ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Width Profiles", [], None, - "{} → {profiles: [{id (stroke.set `profile`), label, builtIn, points: [[t, left, right]…]}], current: the selected stroke's profile id or name, \"custom\" when it isn't listed, null without a stroke}", + "{} → {profiles: [{id (stroke.set `profile`), label, builtIn, points: [[t, left, right]…]}], current: the selected stroke's profile id or name (nothing selected: the next object drawn's), \"custom\" when it isn't listed, null without a stroke}", always, profile_list ), @@ -159,6 +159,11 @@ impl StrokeChange { } } +/// Does a Stroke panel edit find nothing selected (and no `ids`)? It then sets up new art. +fn no_targets(ids: &[vectorcraft_doc::NodeId], p: &Value) -> bool { + ids.is_empty() && p.get("ids").is_none() && p.get("id").is_none() +} + fn stroke_set(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.set"; let change = StrokeChange::parse(p, C)?; @@ -167,6 +172,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { } let item = item_target(s, p, C)?.of_kind(s, false); let mut ids = item.targets(s, p)?; + let new_art = no_targets(&ids, p); if item == ItemTarget::Top { // Images and symbol instances take no stroke from the panel. let d = &s.doc()?.doc; @@ -192,17 +198,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { None => None, Some(id) => Some(s.resolve_profile(id).ok_or_else(|| bad(C, format!("unknown profile {id} (see stroke.widthProfile.list)")))?), }; - edit_items(s, &ids, item, C, "Stroke", false, |n, index| { - if index.is_none() - && let NodeKind::Text(t) = &mut n.kind - { - t.runs.iter_mut().for_each(|r| change.apply_char(&mut r.style)); - return Ok(()); - } - if index.is_none() && n.appearance.stroke().is_none() { - n.appearance.set_stroke(Paint::solid(Color::BLACK)); - } - let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) }; + let set = |st: &mut StrokeLayer| { change.apply(st); if let Some(a) = align { st.align = a; @@ -219,6 +215,24 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { if let Some(pr) = &profile { st.profile = pr.clone(); } + }; + // With nothing selected the Stroke panel sets up the next object drawn. + if new_art { + set(s.new_art_stroke_mut()); + } + edit_items(s, &ids, item, C, "Stroke", false, |n, index| { + if index.is_none() + && let NodeKind::Text(t) = &mut n.kind + { + t.runs.iter_mut().for_each(|r| change.apply_char(&mut r.style)); + return Ok(()); + } + if index.is_none() && n.appearance.stroke().is_none() { + n.appearance.set_stroke(Paint::solid(Color::BLACK)); + } + if let Some(st) = n.appearance.stroke_at_mut(index) { + set(st); + } Ok(()) })?; ok() @@ -233,10 +247,12 @@ impl Session { /// The stroke the Stroke panel, the Control bar and the Properties panel show, picked as the /// Stroke proxy picks its paint: the Appearance panel's active item when it is a stroke, else /// the first selected object's (a group's first painted object's) top stroke, type's first - /// run's character stroke. None without a selection or a stroke. + /// run's character stroke; with nothing selected, the next object drawn's + /// ([`Session::new_art_stroke`]). None without a document or the selection's stroke. pub fn shown_stroke(&self) -> Option { let st = self.active()?; - let first = st.doc.node(*st.selection.objects.first()?)?; + let Some(first) = st.selection.objects.first() else { return Some(self.new_art_stroke()) }; + let first = st.doc.node(*first)?; if let Some(i) = first.appearance.item_of_kind(self.appearance_item(), false) { return first.appearance.stroke_at(Some(i)).cloned(); } @@ -331,8 +347,7 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { } let item = item_target(s, p, C)?.of_kind(s, false); let ids = item.targets(s, p)?; - edit_items(s, &ids, item, C, "Stroke", false, |n, index| { - let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) }; + let arrows_and_profile = |st: &mut StrokeLayer| { if let Some(sc) = scale { st.arrow_scale = sc; } @@ -343,6 +358,14 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { if let (Some(along), Some(pr)) = (flip, &st.profile) { st.profile = Some(flip_profile(pr, along)); } + }; + // As stroke.set: with nothing selected, for the next object drawn. + if no_targets(&ids, p) && (scale.is_some() || swap || flip.is_some()) { + arrows_and_profile(s.new_art_stroke_mut()); + } + edit_items(s, &ids, item, C, "Stroke", false, |n, index| { + let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) }; + arrows_and_profile(st); if let Some(b) = &brush { st.brush = b.clone(); } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 0f9bc0be..f0a2acce 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks", + "{name, add?: bool, ids? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -216,7 +216,7 @@ fn unitized(mut ap: Appearance, from: Option) -> Appearance { /// strokes of its own lends those of its topmost painted object, and type its characters' fill /// and stroke. Placed gradients come relative to the unit box (from the bounds of the object /// they're on). -fn captured(n: &Node) -> Appearance { +pub(crate) fn captured(n: &Node) -> Appearance { let mut ap = n.appearance.clone(); if !ap.items.is_empty() { return unitized(ap, n.geometric_bounds()); @@ -325,7 +325,13 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { let chars = s.prefs.override_char_color; let ids = super::appearance::appearance_targets(s, p)?; if ids.is_empty() { - return Err(bad("graphicStyle.apply", "nothing selected (or pass `ids`)")); + if p.get("ids").is_some() || p.get("id").is_some() { + return Err(bad("graphicStyle.apply", "no such objects")); + } + // Nothing selected: the next object drawn takes the style. + let g = s.doc()?.doc.graphic_styles[i].clone(); + s.new_art_style(&g, add); + return Ok(json!({ "newArt": true })); } s.edit("Apply Graphic Style", |d, _| { if add { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8552a66e..c061eccc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -340,6 +340,9 @@ pub struct Prefs { pub width_profiles: Vec, /// General → Use Japanese Crop Marks: the style of Create Trim Marks and Effect → Crop Marks. pub japanese_crop_marks: bool, + /// Appearance panel → New Art Has Basic Appearance (on): new art takes one fill and stroke; + /// off, the whole appearance of the last selection (`appearance.setNewArtBasic`). + pub new_art_basic: bool, } impl Default for Prefs { @@ -461,6 +464,7 @@ impl Default for Prefs { flattener_presets: vec![], width_profiles: vec![], japanese_crop_marks: false, + new_art_basic: true, } } } @@ -529,7 +533,7 @@ impl Session { docs: vec![], active: None, prefs: Prefs::default(), - paint: PaintDefaults { fill: Paint::solid(Color::WHITE), stroke: Paint::solid(Color::BLACK), stroke_width: 1.0 }, + paint: PaintDefaults::default(), fill_active: true, clipboard: vec![], journal: vec![], @@ -649,6 +653,9 @@ impl Session { let r = (spec.run)(self, params); self.depth -= 1; let r = r?; + if self.depth == 0 { + self.inherit_new_art(); + } if spec.journal && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) { let p = self.noted(params); self.journal.push((id.to_string(), p)); @@ -888,6 +895,8 @@ mod tests_maskview; #[cfg(test)] mod tests_menucmds; #[cfg(test)] +mod tests_newart; +#[cfg(test)] mod tests_objexpand; #[cfg(test)] mod tests_opacitymask; diff --git a/crates/engine/src/tests_newart.rs b/crates/engine/src/tests_newart.rs new file mode 100644 index 00000000..5be2e206 --- /dev/null +++ b/crates/engine/src/tests_newart.rs @@ -0,0 +1,143 @@ +//! The new-art template (M3.65): Stroke panel options and graphic styles chosen with nothing +//! selected, New Art Has Basic Appearance and `paint.default`. + +use serde_json::{Value, json}; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{Appearance, Dash, LineCap, Node}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 20, "width": 60, "height": 40}))["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn stroke_options_set_with_nothing_selected_carry_to_the_next_object() { + let mut s = session(); + let before = rect(&mut s, 20.0); + run(&mut s, "select.none", json!({})); + let undo = undo_len(&s); + run(&mut s, "stroke.set", json!({"cap": "round", "dash": [4, 2], "weight": 3, "endArrow": "Arrow"})); + assert_eq!(undo_len(&s), undo, "no document change, no undo step"); + // The Stroke panel shows them with nothing selected. + let shown = s.shown_stroke().unwrap(); + assert_eq!((shown.cap, shown.width, shown.dash.as_ref().map(|d| d.pattern.clone())), (LineCap::Round, 3.0, Some(vec![4.0, 2.0]))); + let id = rect(&mut s, 120.0); + let st = node(&s, id).appearance.stroke().unwrap().clone(); + assert_eq!((st.cap, st.width, st.end_arrow.is_some()), (LineCap::Round, 3.0, true)); + assert_eq!(st.dash, Some(Dash { pattern: vec![4.0, 2.0], ..Default::default() })); + assert_eq!(node(&s, id).appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::WHITE), "the fill stays the proxy's"); + assert_eq!(node(&s, before).appearance.stroke().unwrap().cap, LineCap::Butt, "art drawn before keeps its stroke"); + // Editing a selected object's stroke leaves the template alone. + run(&mut s, "stroke.set", json!({"cap": "square"})); + run(&mut s, "select.none", json!({})); + assert_eq!(s.shown_stroke().unwrap().cap, LineCap::Round); + // Other new art takes it too (a line still without a fill). + let line = NodeId(run(&mut s, "shape.line", json!({"x1": 10, "y1": 200, "x2": 200, "y2": 200}))["id"].as_u64().unwrap()); + let l = node(&s, line).appearance; + assert_eq!((l.fill_paint(), l.stroke().unwrap().cap), (Paint::None, LineCap::Round)); + // Default Fill and Stroke resets the template. + run(&mut s, "paint.default", json!({})); + let plain = rect(&mut s, 200.0); + assert_eq!(node(&s, plain).appearance, Appearance::default_art()); + assert!(s.shown_stroke().is_some_and(|st| st.dash.is_none() && st.cap == LineCap::Butt)); +} + +/// A rectangle with two fills, a drop shadow and 50% opacity, selected. +fn fancy(s: &mut Session) -> NodeId { + let id = rect(s, 20.0); + run(s, "paint.setFill", json!({"color": "#ff0000"})); + run(s, "appearance.addFill", json!({})); + run(s, "paint.setFill", json!({"color": "#0000ff"})); + run(s, "effect.apply", json!({"effect": "stylize.dropShadow"})); + run(s, "transparency.set", json!({"opacity": 50})); + id +} + +#[test] +fn with_basic_appearance_off_new_art_takes_the_last_selections_whole_appearance() { + let mut s = session(); + assert!(s.prefs.new_art_basic, "on by default"); + let src = fancy(&mut s); + run(&mut s, "select.none", json!({})); + let basic = rect(&mut s, 120.0); + let b = node(&s, basic); + assert_eq!((b.appearance.items.len(), b.appearance.effects.len(), b.opacity), (2, 0, 1.0), "on: one fill and stroke"); + assert_eq!(run(&mut s, "appearance.setNewArtBasic", json!({})), json!({"on": false}), "no param toggles"); + run(&mut s, "select.set", json!({"ids": [src.0]})); + run(&mut s, "select.none", json!({})); + let id = rect(&mut s, 220.0); + let (n, from) = (node(&s, id), node(&s, src)); + assert_eq!(n.appearance.items.iter().filter(|i| i.is_fill()).count(), 2, "both fills"); + assert_eq!(n.appearance.effects.len(), 1, "the drop shadow"); + assert!(n.appearance.approx_eq(&from.appearance)); + assert_eq!((n.opacity, n.blend), (0.5, BlendMode::Normal)); + let q = run(&mut s, "appearance.newArt", json!({})); + assert_eq!((q["basic"].clone(), q["inherited"].clone(), q["opacity"].clone()), (json!(false), json!(true), json!(50.0))); + // Back on, new art takes only the basic fill and stroke again. + run(&mut s, "appearance.setNewArtBasic", json!({"on": true})); + run(&mut s, "select.none", json!({})); + let again = rect(&mut s, 300.0); + let again = node(&s, again); + assert_eq!((again.appearance.items.len(), again.appearance.effects.len(), again.opacity), (2, 0, 1.0)); +} + +#[test] +fn a_graphic_style_clicked_with_nothing_selected_styles_the_next_object() { + let mut s = session(); + fancy(&mut s); + // A placed gradient on the top fill: the style keeps it relative to the object's bounds. + let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); + run(&mut s, "paint.setFill", json!({"gradient": {"stops": stops, "start": [20, 40], "end": [50, 40]}})); + let name = run(&mut s, "graphicStyle.new", json!({"name": "Fancy"}))["name"].as_str().unwrap().to_string(); + run(&mut s, "select.none", json!({})); + let undo = undo_len(&s); + assert_eq!(run(&mut s, "graphicStyle.apply", json!({"name": name})), json!({"newArt": true})); + assert_eq!(undo_len(&s), undo); + assert_eq!(run(&mut s, "appearance.newArt", json!({}))["graphicStyle"], json!("Fancy")); + let id = NodeId(run(&mut s, "shape.ellipse", json!({"x": 200, "y": 100, "width": 100, "height": 50}))["id"].as_u64().unwrap()); + let n = node(&s, id); + assert_eq!((n.appearance.items.len(), n.appearance.effects.len(), n.opacity), (3, 1, 0.5)); + // Linked to the style (its look, gradient placed on the ellipse's own bounds). + let (g, linked) = s.selection_graphic_style().unwrap(); + assert_eq!((g.name.as_str(), linked), ("Fancy", true)); + // Alt-click (add) with nothing selected stacks a style on the template, unlinked. + run(&mut s, "select.none", json!({})); + run(&mut s, "graphicStyle.apply", json!({"name": name, "add": true})); + let q = run(&mut s, "appearance.newArt", json!({})); + assert_eq!((q["appearance"]["items"].as_array().unwrap().len(), q["graphicStyle"].clone()), (6, Value::Null)); + assert!(s.execute("graphicStyle.apply", &json!({"name": name, "ids": []})).is_err(), "explicit ids that match nothing"); +} + +#[test] +fn the_option_round_trips_with_the_preferences() { + let mut s = session(); + run(&mut s, "prefs.set", json!({"key": "newArtBasic", "value": false})); + assert!(!s.prefs.new_art_basic); + assert_eq!(run(&mut s, "prefs.get", json!({"key": "newArtBasic"})), json!(false)); + let json = s.prefs.to_json(); + assert_eq!(json["newArtBasic"], json!(false)); + let back: Prefs = serde_json::from_value(json).unwrap(); + assert_eq!(back, s.prefs); + // Older preference files load with it on. + let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap(); + assert!(old.new_art_basic); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 0bf86726..85d92b33 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -129,12 +129,40 @@ pub enum Action { Notify(String), } -/// Paint defaults for new art (the fill/stroke proxy). +/// Paint defaults for new art (the fill/stroke proxy) and the rest of the new-art template. #[derive(Clone, Debug, PartialEq)] pub struct PaintDefaults { pub fill: Paint, pub stroke: Paint, pub stroke_width: f64, + /// The appearance new art takes (stroke options, more fills and strokes, effects), its top + /// fill and stroke repainted with `fill`, `stroke` and `stroke_width`; `None`: one plain fill + /// and stroke. Its placed gradients are relative to the unit box. + pub appearance: Option, + /// New art's opacity and blend mode. + pub opacity: f32, + pub blend: vectorcraft_color::BlendMode, + /// The graphic style the template came from (by name): new art is linked to it. + pub style: Option, + /// The template is the last selection's appearance (New Art Has Basic Appearance off): with + /// the option on again, new art takes only its basic fill and stroke. + pub inherited: bool, +} + +impl Default for PaintDefaults { + /// White fill, 1 pt black stroke, nothing else. + fn default() -> Self { + Self { + fill: Paint::solid(vectorcraft_color::Color::WHITE), + stroke: Paint::solid(vectorcraft_color::Color::BLACK), + stroke_width: 1.0, + appearance: None, + opacity: 1.0, + blend: Default::default(), + style: None, + inherited: false, + } + } } /// Read-only context a tool sees. @@ -332,11 +360,7 @@ pub(crate) mod testutil { } pub fn paint() -> PaintDefaults { - PaintDefaults { - fill: Paint::solid(vectorcraft_color::Color::WHITE), - stroke: Paint::solid(vectorcraft_color::Color::BLACK), - stroke_width: 1.0, - } + PaintDefaults::default() } pub fn cx<'a>(d: &'a Document, s: &'a Selection, p: &'a PaintDefaults) -> ToolContext<'a> { diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 6e471654..5557a25f 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -236,10 +236,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); let mixed = mixed_appearances(app); let label = if mixed { "Mixed Appearances" } else { object_label(app) }; - // A linked object names its graphic style ("Rectangle: Sunshine"). - let label = match app.session.selection_graphic_style() { - Some((g, true)) if !mixed => std::borrow::Cow::Owned(format!("{label}: {}", g.name)), - _ => std::borrow::Cow::Borrowed(label), + // A linked object names its graphic style ("Rectangle: Sunshine"), and so does new art + // with nothing selected. + let style = match app.session.selection_graphic_style() { + Some((g, true)) if !mixed => Some(g.name.as_str()), + _ if node.is_none() => app.session.new_art_transparency().2, + _ => None, + }; + let label = match style { + Some(name) => std::borrow::Cow::Owned(format!("{label}: {name}")), + None => std::borrow::Cow::Borrowed(label), }; if !hide_thumb { let th = Rect::from_min_size(r.left_center() + vec2(6.0, -12.0), vec2(24.0, 24.0)); @@ -298,21 +304,44 @@ fn mixed_appearances(app: &VectorcraftApp) -> bool { nodes.any(|n| n.appearance != first.appearance || n.opacity != first.opacity || n.blend != first.blend) } -/// The defaults for new art when nothing is selected (read-only rows). +/// What the next object drawn gets when nothing is selected (`Session::new_art`): its fills and +/// strokes, effects and transparency as read-only rows; "Stroke:" opens the Stroke panel, which +/// sets up its stroke. fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { - let p = app.session.paint.clone(); - for (lbl, paint, w) in [("Stroke:", p.stroke, Some(p.stroke_width)), ("Fill:", p.fill, None)] { + let t = Tokens::get(ui.ctx()); + let ap = app.session.new_art(); + let (opacity, blend, _) = app.session.new_art_transparency(); + let fx = |ui: &mut Ui, e: &Effect, id: (Option, usize)| { let (r, _) = row(ui, false); - eye(ui, r, ("ap-def-eye", lbl), true, false); - text(ui, r.left_center() + vec2(EYE_W + 24.0, 0.0), lbl, false); - chip(ui, Rect::from_min_size(r.left_center() + vec2(EYE_W + 76.0, -9.0), vec2(18.0, 18.0)), &paint); - if let Some(w) = w { - text(ui, r.left_center() + vec2(EYE_W + 106.0, 0.0), &format!("{w} pt"), false); + eye(ui, r, ("ap-def-fx", id), e.visible, false); + text(ui, pos2(r.left() + EYE_W + 24.0 + if id.0.is_some() { 12.0 } else { 0.0 }, r.center().y), &effect_label(&e.id), false); + icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon); + }; + for (i, it) in ap.items.iter().enumerate().rev() { + let (r, _) = row(ui, false); + eye(ui, r, ("ap-def-eye", i), it.visible(), false); + let lx = r.left() + EYE_W + 24.0; + if it.is_fill() { + text(ui, pos2(lx, r.center().y), "Fill:", false); + } else if link(ui, pos2(lx, r.center().y), ("ap-def-link", i), "Stroke:") { + app.ui.open_panel = Some("stroke".into()); } + chip(ui, Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0)), it.paint()); + if let AppearanceItem::Stroke(st) = it { + let w = format!("{} pt", st.width); + text(ui, pos2(r.left() + EYE_W + 106.0, r.center().y), &w, false); + stroke_notes(ui, Rect::from_min_max(pos2(r.left() + EYE_W + 166.0, r.top()), pos2(r.right() - 4.0, r.bottom())), st); + } + for (k, e) in it.effects().iter().enumerate() { + fx(ui, e, (Some(i), k)); + } + } + for (k, e) in ap.effects.iter().enumerate() { + fx(ui, e, (None, k)); } let (r, _) = row(ui, false); eye(ui, r, "ap-def-op", true, false); - text(ui, r.left_center() + vec2(EYE_W + 24.0, 0.0), "Opacity: Default", false); + text(ui, r.left_center() + vec2(EYE_W + 24.0, 0.0), &format!("Opacity: {}", opacity_text(opacity, blend)), false); } /// A stack row being dragged. @@ -869,7 +898,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("appearance.reduceToBasic", json!({})).ok(); } ui.separator(); - menu_item(ui, "New Art Has Basic Appearance", false, true); + let basic = app.session.prefs.new_art_basic; + if menu_item(ui, "New Art Has Basic Appearance", true, basic) { + app.run("appearance.setNewArtBasic", json!({ "on": !basic })).ok(); + } let hide: bool = pstate(ui.ctx(), "ap-hide-thumb"); if menu_item(ui, if hide { "Show Thumbnail" } else { "Hide Thumbnail" }, true, false) { set_pstate(ui.ctx(), "ap-hide-thumb", !hide); @@ -1016,4 +1048,28 @@ mod tests { assert_eq!(drop(Dragged::Contents, 3), None); assert_eq!(drop(Dragged::Contents, 2), Some(("appearance.moveItem", json!({"from": "contents", "to": 2})))); } + + #[test] + fn with_nothing_selected_it_lists_what_new_art_gets() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})); + run(&mut app, "appearance.addFill", json!({})); + run(&mut app, "effect.apply", json!({"effect": "stylize.dropShadow"})); + run(&mut app, "transparency.set", json!({"opacity": 50})); + run(&mut app, "graphicStyle.new", json!({"name": "Fancy"})); + run(&mut app, "select.none", json!({})); + let shown = frame(&mut app); + assert!(shown.contains(&"No Selection".to_string()) && shown.contains(&"Opacity: Default".to_string()), "{shown:?}"); + // A style clicked with nothing selected: the rows show what the next object gets. + run(&mut app, "graphicStyle.apply", json!({"name": "Fancy"})); + run(&mut app, "stroke.set", json!({"dash": [3, 3]})); + let shown = frame(&mut app); + let rows: Vec<&String> = shown.iter().filter(|t| ["Fill:", "Stroke:"].contains(&t.as_str())).collect(); + assert_eq!(rows, ["Fill:", "Stroke:", "Fill:"], "the added fill is on top"); + for want in ["No Selection: Fancy".to_string(), effect_label("stylize.dropShadow"), "Opacity: 50%".into(), "Dashed".into()] { + assert!(shown.contains(&want), "{want} in {shown:?}"); + } + } } diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index 05ca7a39..d8adbc49 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -424,9 +424,8 @@ fn act(app: &mut VectorcraftApp, ui: &Ui, s: &Shown, ev: Events, picker: bool) { ui.close(); } Some(Click::Apply(name)) => { - if selection_len(app) > 0 { - app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok(); - } + // With nothing selected the next object drawn takes the style. + app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok(); set_pstate(ui.ctx(), "gs-sel", vec![name]); } Some(Click::Toggle(name)) => { diff --git a/docs/mcp.md b/docs/mcp.md index 71f76d52..5ef9ae9c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -676,3 +676,19 @@ points}` replaces them all, as dragging several Shift-selected points with the W {"name":"run_command","arguments":{"command":"stroke.widthPoint.set","params":{"id":9,"t":0.8,"left":12,"right":12}}} {"name":"run_command","arguments":{"command":"stroke.widthPoint.set","params":{"id":9,"index":1,"t":0.8,"left":4,"right":4}}} ``` + +## New art + +New objects take the fill and stroke proxies (`paint.setFill` / `paint.setStroke` and the weight, whatever is +selected) on top of a template. With nothing selected, `stroke.set` / `stroke.setAdvanced` (cap, join, dashes, +arrowheads, profile…) and `graphicStyle.apply` (every fill, stroke and effect, opacity, blend mode and the link to the +style; `add: true` stacks it) set that template instead of editing art. With the Appearance panel's New Art Has Basic +Appearance off (`appearance.setNewArtBasic {on: false}`, the preference `newArtBasic`) new art takes the whole +appearance of the last selected object. `appearance.newArt` reports what the next object gets; `paint.default` +resets it. + +```json +{"name":"run_command","arguments":{"command":"select.none","params":{}}} +{"name":"run_command","arguments":{"command":"stroke.set","params":{"cap":"round","dash":[6,3]}}} +{"name":"run_command","arguments":{"command":"appearance.newArt","params":{}}} +```