//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel. use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind}; use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target}; use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced}; use super::*; use crate::{DocState, EngineError}; pub fn specs() -> Vec { vec![ cmd!( "paint.setFill", "Fill", [], None, "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", has_doc, |s, p| set_paint(s, p, true) ), cmd!("paint.setStroke", "Stroke", [], None, "same as paint.setFill, for the stroke (item?: the stroke item to set)", has_doc, |s, p| { set_paint(s, p, false) }), cmd!( "paint.swap", "Swap Fill and Stroke", [], Some("Shift+X"), "{ids?} swap the fill and stroke of the selection (type too) and of the defaults", has_doc, swap ), cmd!( "paint.default", "Default Fill and Stroke", [], Some("D"), "{ids?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke", has_doc, default_paint ), cmd!("paint.toggleActive", "Toggle Fill/Stroke Focus", [], Some("X"), "{}", always, |s, _| { s.fill_active = !s.fill_active; Ok(json!({ "fillActive": s.fill_active })) }), cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None", has_doc, |s, _| { let f = s.fill_active; set_paint(s, &json!({"none": true}), f) }), cmd!( "transparency.set", "Transparency", ["Window", "Transparency"], None, "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)", has_doc, transparency ), ] } /// Commands of the Fill/Stroke proxies, registered at the end of the command list. pub fn proxy_specs() -> Vec { vec![ cmd!( "paint.invert", "Invert", [], None, "{stroke?: bool (default: the active proxy), ids?} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; with nothing selected, invert the default → {changed}", has_doc, |s, p| proxy_recolor(s, p, "Invert", &super::colorcmds::invert) ), cmd!( "paint.complement", "Complement", [], None, "{stroke?: bool (default: the active proxy), ids?} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; with nothing selected, the default → {changed}", has_doc, |s, p| proxy_recolor(s, p, "Complement", &|c: Color| c.complement_keep_model()) ), cmd!( "paint.lastColor", "Apply Last Color", [], Some(","), "{stroke?: bool (default: the active proxy), ids?} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default", has_doc, |s, p| { let paint = Paint::solid(s.last_solid); apply_to_proxy(s, p, paint) } ), cmd!( "paint.lastGradient", "Apply Last Gradient", [], Some("."), "{stroke?: bool (default: the active proxy), ids?} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default", has_doc, |s, p| { let paint = Paint::Gradient(Box::new(GradientPaint { geom: None, ..s.last_gradient.clone() })); apply_to_proxy(s, p, paint) } ), cmd!( query "paint.recent", "Recent Colors", [], None, "{} → {colors: [{hex, color}] newest first (fed by every paint command and the eyedropper), lastColor: {hex, color}, lastGradient: gradient paint}", always, |s, _| { let c = |c: &Color| json!({"hex": c.to_hex(), "color": c}); Ok(json!({ "colors": s.recent_colors.iter().map(c).collect::>(), "lastColor": c(&s.last_solid), "lastGradient": s.last_gradient, })) } ), cmd!( query "paint.proxies", "Fill and Stroke", [], None, "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy}", always, proxies ), ] } impl Session { /// How many colours the Recent Colors rows keep. pub const RECENT_MAX: usize = 10; /// Remember an applied paint: a solid colour becomes the last colour and the newest recent /// colour, a gradient the last gradient. During a live preview this waits for the commit. pub(crate) fn remember_paint(&mut self, p: &Paint) { if self.in_interaction() { self.pending_paint = Some(p.clone()); } else { self.remember_paint_now(p); } } /// The fill and stroke the Fill/Stroke proxies show: the Appearance panel's active item (in the /// first selected object's own stack) for the proxy of its kind, else the first selected /// object's (a group's first painted object's; type: its first run's), else the defaults for /// new art. pub fn proxy_paints(&self) -> (Paint, Paint) { let default = |stroke: bool| if stroke { self.paint.stroke.clone() } else { self.paint.fill.clone() }; let Some(first) = self.active().and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))) else { return (default(false), default(true)); }; let item = self.appearance_item(); let mut nodes = vec![]; painted(first, true, &mut nodes); let show = |stroke: bool| match first.appearance.item_of_kind(item, !stroke) { Some(_) => proxy_paint(first, stroke, item), None => nodes.first().map_or_else(|| default(stroke), |n| proxy_paint(n, stroke, None)), }; (show(false), show(true)) } pub(crate) fn remember_paint_now(&mut self, p: &Paint) { match p { Paint::Solid { color, .. } => { self.last_solid = *color; self.recent_colors.retain(|c| c != color); self.recent_colors.insert(0, *color); self.recent_colors.truncate(Self::RECENT_MAX); } Paint::Gradient(g) => self.last_gradient = (**g).clone(), Paint::None | Paint::Pattern { .. } => {} } } } /// The fill or stroke a proxy shows for `n` ([`Node::proxy_paint`]: fill or stroke `item` when it /// is of that kind, type's first run, else the topmost one). pub(crate) fn proxy_paint(n: &Node, stroke: bool, item: Option) -> Paint { match n.proxy_paint(stroke, item) { Some((p, ..)) => p.clone(), // Type without runs shows the default character style. None if matches!(n.kind, NodeKind::Text(_)) => { let st = CharStyle::default(); if stroke { st.stroke } else { st.fill } } None => Paint::None, } } /// Do two paints look the same in a proxy? Solid colours compare without their swatch link and /// gradients without their per-object geometry. fn same_in_proxy(a: &Paint, b: &Paint) -> bool { match (a, b) { (Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => x == y, (Paint::Gradient(x), Paint::Gradient(y)) => x.gradient == y.gradient, _ => a == b, } } /// The objects whose paints the proxies show for a selected `n`, the ones the paint commands /// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and /// other containers inside them), a compound path for its parts. fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { let expand = match n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } => true, NodeKind::Compound { .. } => false, _ => !top && n.is_container(), }; if expand { n.children().into_iter().flatten().for_each(|c| painted(c, false, out)); } else { out.push(n); } } impl DocState { /// Whether the selected objects' fills and strokes differ as the proxies show them (a "?" /// proxy; a group whose contents differ counts). One walk of the document, so callers that ask /// every frame cache it by revision. pub fn proxy_mixed(&self) -> (bool, bool) { if self.selection.is_empty() { return (false, false); } let ids: std::collections::HashSet = self.selection.objects.iter().copied().collect(); let mut nodes = vec![]; self.doc.walk(|n| { if ids.contains(&n.id) { painted(n, true, &mut nodes); } }); let mut paints = nodes.iter().map(|n| (proxy_paint(n, false, None), proxy_paint(n, true, None))); let Some((f0, s0)) = paints.next() else { return (false, false) }; let mut mixed = (false, false); for (f, s) in paints { mixed.0 |= !same_in_proxy(&f0, &f); mixed.1 |= !same_in_proxy(&s0, &s); if mixed.0 && mixed.1 { break; } } mixed } } fn proxies(s: &mut Session, _: &Value) -> Result { let (fill, stroke) = s.proxy_paints(); let (fill_mixed, stroke_mixed) = s.active().map_or((false, false), DocState::proxy_mixed); Ok(json!({"fill": fill, "stroke": stroke, "fillActive": s.fill_active, "fillMixed": fill_mixed, "strokeMixed": stroke_mixed})) } /// `stroke` param, defaulting to the proxy that is in front. fn stroke_param(s: &Session, p: &Value) -> bool { p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active) } /// The model RGB colours given in `p` are stored in: the document's in a CMYK document (`None` in /// an RGB one, or with `keepModel`). fn new_color_model(s: &Session, p: &Value) -> Option { let mode = s.active()?.doc.color_mode; (mode != vectorcraft_doc::ColorMode::Rgb && !bool_or(p, "keepModel", false)).then(|| mode.model()) } /// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A /// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]). pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { let model = new_color_model(s, p); let new_color = |c: Color| match (c, model) { (Color::Rgb { .. }, Some(m)) => c.in_model(m), _ => c, }; if bool_or(p, "none", false) { return Ok(Some(Paint::None)); } if let Some(name) = str_param(p, "swatch") { let st = s.doc()?; // A pattern definition works as its swatch even without a swatch entry. if st.doc.swatch(name).is_none() && st.doc.pattern(name).is_some() { return Ok(Some(vectorcraft_doc::pattern::pattern_paint(name))); } let sw = st.doc.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?; let mut paint = sw.paint.clone(); if sw.global && let Paint::Solid { swatch, .. } = &mut paint { *swatch = Some(name.to_string()); } return Ok(Some(paint)); } if let Some(g) = p.get("gradient") { let mut gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?; gp.gradient.stops.iter_mut().for_each(|st| st.color = new_color(st.color)); return Ok(Some(Paint::Gradient(Box::new(gp)))); } if let Some(c) = p.get("color") { let c = color_value(c).ok_or_else(|| bad("paint", format!("bad color {c}")))?; return Ok(Some(Paint::solid(new_color(c)))); } Ok(None) } fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { let cmd = if fill { "paint.setFill" } else { "paint.setStroke" }; let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?; apply_paint(s, p, paint, fill) } /// [`apply_paint`] on the proxy named by `stroke` (default: the active one), focusing it. fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result { let fill = !stroke_param(s, p); apply_paint(s, p, paint, fill) } /// Set the fill (or stroke) of the targets of `p` (the `item` and `ids` params, see /// `paint.setFill`) and of the defaults for new art, focus its proxy unless `focus` is false, and /// remember the paint. fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result { let cmd = if fill { "paint.setFill" } else { "paint.setStroke" }; // New art gets the paint fitted to itself, not placed where this one is. if fill { s.paint.fill = unplaced(&paint); } else { s.paint.stroke = unplaced(&paint); } if bool_or(p, "focus", true) { s.fill_active = fill; } let item = item_target(s, p, cmd)?.of_kind(s, fill); let ids = item.targets(s, p)?; edit_items(s, &ids, item, cmd, if fill { "Fill Color" } else { "Stroke Color" }, fill, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { let (xf, lb) = (t.xf, t.local_bounds()); for r in &mut t.runs { if !fill && !paint.is_none() { run_stroke_weight(&mut r.style); } let (cur, b) = run_paint_mut(r, !fill, lb); *cur = place_run_paint(&paint, p, xf, b); } return Ok(()); } // A missing top fill or stroke is created first: a new stroke's weight sizes the box its // gradient fits. if n.appearance.paint_at(index, fill).is_none() { n.appearance.set_paint_at(index, fill, Paint::None); } let placed = place_paint(&paint, p, item_paint_bounds(n, index, fill)); n.appearance.set_paint_at(index, fill, placed); Ok(()) })?; s.remember_paint(&paint); ok() } /// A type run's stroke; a painted stroke on a run without one gets 1 pt. fn set_run_stroke(style: &mut CharStyle, paint: Paint) { if !paint.is_none() { run_stroke_weight(style); } style.stroke = paint; } fn swap(s: &mut Session, p: &Value) -> Result { std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke); let ids = paint_targets(s, p)?; if !ids.is_empty() { s.edit("Swap Fill and Stroke", |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 fill = std::mem::take(&mut r.style.fill); r.style.fill = std::mem::take(&mut r.style.stroke); set_run_stroke(&mut r.style, fill); } continue; } let f = n.appearance.fill_paint(); let st = n.appearance.stroke_paint(); n.appearance.set_fill(st); n.appearance.set_stroke(f); } Ok(()) })?; } ok() } 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; let ids = paint_targets(s, p)?; if !ids.is_empty() { s.edit("Default Fill and Stroke", |d, _| { for id in &ids { let Some(n) = d.node_mut(*id) else { continue }; match &mut n.kind { NodeKind::Text(t) => { for r in &mut t.runs { r.style.fill = Paint::solid(Color::BLACK); r.style.stroke = Paint::None; } } _ => n.appearance = Appearance::default_art(), } } Ok(()) })?; } ok() } /// Invert / Complement: recolour the active proxy of the targets (or the default when there are /// none) and remember the first target's new paint. fn proxy_recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { let stroke = stroke_param(s, p); let ids = match ids_param(p, "ids") { Some(ids) => ids, None => super::edit::selected_roots(s)?, }; if ids.is_empty() { let def = if stroke { &mut s.paint.stroke } else { &mut s.paint.fill }; let changed = super::colorcmds::map_paint(def, f); let shown = def.clone(); s.remember_paint(&shown); return Ok(json!({ "changed": changed as usize })); } let q = json!({"fill": !stroke, "stroke": stroke, "ids": ids.iter().map(|i| i.0).collect::>()}); let r = super::colorcmds::recolor(s, &q, label, f)?; let first = leaf_targets(s, &ids)?.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, None))); if let Some(shown) = first { s.remember_paint(&shown); } Ok(r) } fn transparency(s: &mut Session, p: &Value) -> Result { const C: &str = "transparency.set"; let ids = super::opacitymask::transparency_targets(s, p)?; if ids.is_empty() { return Err(bad(C, "select objects or give ids")); } // While an opacity mask is edited the selection is its art, so the Appearance panel's active // item (a row of that art) doesn't stand for the masked object. let editing_mask = p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_some(); let item = if editing_mask && p.get("item").is_none() { ItemTarget::Top } else { item_target(s, p, C)? }; let mut q = p.as_object().cloned().unwrap_or_default(); q.remove("item"); q.remove("id"); q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::>())); let ItemTarget::Item { index, explicit } = item else { return s.execute("object.setProps", &Value::Object(q)); }; // Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level. let item_ids = if editing_mask { ids } else { appearance_targets(s, p)? }; if explicit { let d = &s.doc()?.doc; if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { return Err(bad(C, format!("no appearance item {index}"))); } } let (opacity, blend) = (q.remove("opacity"), q.remove("blend")); if let Some(b) = &blend && b.as_str().and_then(BlendMode::parse).is_none() { return Err(bad(C, format!("unknown blend mode {b}"))); } if let Some(k) = q.get("knockout") && vectorcraft_doc::Knockout::from_value(k).is_none() { return Err(bad(C, format!("unknown knockout state {k}"))); } if opacity.is_some() || blend.is_some() { let ids: Vec = item_ids.iter().map(|id| id.0).collect(); s.execute("appearance.setItem", &json!({ "index": index, "ids": ids, "opacity": opacity, "blend": blend }))?; } if q.keys().all(|k| k == "ids") { return ok(); } s.execute("object.setProps", &Value::Object(q)) }