From 8a871d98a9ba9f4e1fd7f1493c7c93ee358b54d8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 12:23:52 +0700 Subject: [PATCH] =?UTF-8?q?M3.29:=20proxy=20paint=20commands=20=E2=80=94?= =?UTF-8?q?=20paint.invert=20/=20paint.complement=20recolour=20the=20activ?= =?UTF-8?q?e=20proxy=20of=20each=20selected=20object=20keeping=20its=20col?= =?UTF-8?q?our=20model=20(the=20defaults=20when=20nothing=20is=20selected)?= =?UTF-8?q?,=20paint.lastColor=20(,)=20and=20paint.lastGradient=20(.)=20re?= =?UTF-8?q?-apply=20the=20last=20solid=20colour=20or=20gradient=20(the=20t?= =?UTF-8?q?oolbar=20chips=20draw=20and=20run=20them),=20swap=20and=20defau?= =?UTF-8?q?lt=20work=20on=20type,=20and=20recent=20colours=20live=20in=20t?= =?UTF-8?q?he=20Session,=20fed=20by=20every=20paint=20command=20(MCP,=20ey?= =?UTF-8?q?edropper,=20live=20previews=20on=20commit)=20and=20listed=20by?= =?UTF-8?q?=20paint.recent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/color/src/lib.rs | 45 ++++ crates/engine/src/cmd/appearance.rs | 1 + crates/engine/src/cmd/colorcmds.rs | 11 +- crates/engine/src/cmd/gradient.rs | 5 + crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/paint.rs | 263 +++++++++++++++++++---- crates/engine/src/lib.rs | 21 +- crates/engine/src/tests_paintproxy.rs | 172 +++++++++++++++ crates/ui-egui/src/panels/color.rs | 36 ++-- crates/ui-egui/src/panels/color_guide.rs | 8 +- crates/ui-egui/src/panels/mod.rs | 18 +- crates/ui-egui/src/panels/swatches.rs | 14 +- crates/ui-egui/src/shortcuts.rs | 5 +- crates/ui-egui/src/toolbar.rs | 51 ++--- crates/ui-egui/src/widgets.rs | 53 ++--- docs/mcp.md | 3 + 16 files changed, 558 insertions(+), 149 deletions(-) create mode 100644 crates/engine/src/tests_paintproxy.rs diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 781160ba..a115f88e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -167,6 +167,38 @@ impl Color { let [r, g, b] = self.to_rgb(); Color::rgb(1.0 - r, 1.0 - g, 1.0 - b) } + /// The RGB inverse expressed in the colour's own model (CMYK through the profile-free + /// [`Color::to_cmyk`]; a grey's ink is inverted). + pub fn invert_keep_model(&self) -> Self { + match *self { + Color::Rgb { .. } => self.invert(), + Color::Cmyk { .. } => { + let [c, m, y, k] = self.invert().to_cmyk(); + Color::Cmyk { c, m, y, k } + } + Color::Gray { k } => Color::Gray { k: 1.0 - k }, + } + } + /// Complement in the colour's own model: each component becomes (highest + lowest) − itself, + /// over R, G, B or over C, M, Y (K kept). For RGB this is the hue turned by 180°; a grey is its + /// own complement. + pub fn complement_keep_model(&self) -> Self { + let flip = |v: [f32; 3]| { + let s = v[0].max(v[1]).max(v[2]) + v[0].min(v[1]).min(v[2]); + v.map(|x| (s - x).clamp(0.0, 1.0)) + }; + match *self { + Color::Rgb { r, g, b } => { + let [r, g, b] = flip([r, g, b]); + Color::Rgb { r, g, b } + } + Color::Cmyk { c, m, y, k } => { + let [c, m, y] = flip([c, m, y]); + Color::Cmyk { c, m, y, k } + } + Color::Gray { .. } => *self, + } + } /// Linear interpolation in display RGB. pub fn lerp(&self, other: &Color, t: f32) -> Color { let a = self.to_rgb(); @@ -277,6 +309,19 @@ mod tests { assert_eq!(Color::rgb(1.0, 0.0, 0.0).invert().to_hex(), "#00ffff"); } + #[test] + fn complement_keeps_the_model() { + assert_eq!(Color::rgb(1.0, 0.0, 0.0).complement_keep_model(), Color::rgb(0.0, 1.0, 1.0)); + // Same as turning the hue by 180° for RGB. + let c = Color::rgb8(204, 102, 51); + assert_eq!(c.complement_keep_model().to_hex(), c.complement().to_hex()); + assert_eq!(Color::cmyk(0.0, 1.0, 1.0, 0.2).complement_keep_model(), Color::cmyk(1.0, 0.0, 0.0, 0.2)); + assert_eq!(Color::gray(0.3).complement_keep_model(), Color::gray(0.3)); + assert_eq!(Color::gray(0.25).invert_keep_model(), Color::gray(0.75)); + assert!(matches!(Color::cmyk(0.0, 1.0, 1.0, 0.0).invert_keep_model(), Color::Cmyk { .. })); + assert_eq!(Color::rgb(1.0, 1.0, 0.0).invert_keep_model(), Color::rgb(0.0, 0.0, 1.0)); + } + #[test] fn paint_serde() { let p = Paint::solid(Color::rgb8(10, 20, 30)); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 10f24bad..c60cdd34 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -210,6 +210,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { }; s.paint.fill = appearance.fill_paint(); s.paint.stroke = appearance.stroke_paint(); + s.remember_paint(&s.paint.fill.clone()); if appearance.stroke().is_some() { s.paint.stroke_width = appearance.stroke_width(); } diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index abc40f22..632c49e8 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { None, "{fill?: true, stroke?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) → {changed}", has_selection, - |s, p| recolor(s, p, "Invert Colors", &|c| keep_model(c, c.invert())) + |s, p| recolor(s, p, "Invert Colors", &invert) ), cmd!( "edit.colors.toCMYK", @@ -95,6 +95,11 @@ pub fn specs() -> Vec { ] } +/// The RGB inverse of `c`, expressed in `c`'s colour model. +pub(crate) fn invert(c: Color) -> Color { + c.invert_keep_model() +} + /// Express `new` in the colour model of `orig`. fn keep_model(orig: Color, new: Color) -> Color { match orig { @@ -121,7 +126,7 @@ pub(crate) fn to_gray(c: Color) -> Color { } /// Apply `f` to a paint; returns whether anything changed. -fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool { +pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool { match p { Paint::Solid { color, swatch } => { let n = f(*color); @@ -195,7 +200,7 @@ fn recolor_ids(s: &mut Session, label: &str, ids: Vec, fill: bool, strok Ok(json!({ "changed": n })) } -fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { +pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { let ids = match ids_param(p, "ids") { Some(v) => v, None => selected_roots(s)?, diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 01b0d0e7..5d918090 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -118,6 +118,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { 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() { + s.remember_paint(&new_default); if stroke { s.paint.stroke = new_default; } else { @@ -158,6 +159,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { None => Ok(()), } })?; + // The last gradient is the one the first object now shows. + if let Some(shown) = s.doc()?.doc.node(ids[0]).map(|n| super::paint::proxy_paint(n, stroke)) { + s.remember_paint(&shown); + } if stroke { s.paint.stroke = new_default; } else { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 11231ac4..9fdbe581 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -191,6 +191,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(patterncmds::specs()); v.extend(prefscmds::specs()); v.extend(distortcmds::specs()); + v.extend(paint::proxy_specs()); v }) } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 712d0af5..cb5ba895 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint}; -use vectorcraft_doc::{Appearance, NodeKind}; +use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind}; use super::*; use crate::EngineError; @@ -14,13 +14,29 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind, stops:[{offset,color}], angle?}, ids?} sets selection fill and the default", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind, stops:[{offset,color}], angle?}, ids?, focus?: true (false keeps the active proxy)} sets selection fill and the default", has_doc, |s, p| set_paint(s, p, true) ), cmd!("paint.setStroke", "Stroke", [], None, "same as paint.setFill, for the stroke", has_doc, |s, p| set_paint(s, p, false)), - cmd!("paint.swap", "Swap Fill and Stroke", [], Some("Shift+X"), "{}", has_doc, swap), - cmd!("paint.default", "Default Fill and Stroke", [], Some("D"), "{}", has_doc, default_paint), + 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 })) @@ -41,6 +57,115 @@ pub fn specs() -> Vec { ] } +/// 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", + "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", + "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, + })) + } + ), + ] +} + +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); + } + } + + 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` (type shows its first run's style). +pub(crate) fn proxy_paint(n: &Node, stroke: bool) -> Paint { + match &n.kind { + NodeKind::Text(t) => { + let st = t.runs.first().map(|r| &r.style); + st.map(|st| if stroke { st.stroke.clone() } else { st.fill.clone() }).unwrap_or_default() + } + _ if stroke => n.appearance.stroke_paint(), + _ => n.appearance.fill_paint(), + } +} + +/// `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) +} + /// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { if bool_or(p, "none", false) { @@ -94,28 +219,45 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { 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 bool_or(p, "focus", true) { + s.fill_active = fill; + } + let ids = paint_targets(s, p)?; + apply_paint(s, &ids, 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); + s.fill_active = fill; + let ids = paint_targets(s, p)?; + apply_paint(s, &ids, paint, fill) +} + +/// Set the fill (or stroke) of `ids` and of the defaults for new art, and remember it. +fn apply_paint(s: &mut Session, ids: &[NodeId], paint: Paint, fill: bool) -> Result { if fill { s.paint.fill = paint.clone(); } else { s.paint.stroke = paint.clone(); } - s.fill_active = fill; - let ids = paint_targets(s, p)?; - if ids.is_empty() { - return ok(); + if !ids.is_empty() { + apply_to_nodes(s, ids, &paint, fill)?; } + s.remember_paint(&paint); + ok() +} + +fn apply_to_nodes(s: &mut Session, ids: &[NodeId], paint: &Paint, fill: bool) -> Result<()> { s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| { - for id in &ids { + 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 { if fill { r.style.fill = paint.clone(); } else { - r.style.stroke = paint.clone(); - if r.style.stroke_width == 0.0 { - r.style.stroke_width = 1.0; - } + set_run_stroke(&mut r.style, paint.clone()); } } continue; @@ -127,21 +269,60 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { } } Ok(()) - })?; - ok() + }) } -fn swap(s: &mut Session, _: &Value) -> Result { +/// 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() && style.stroke_width == 0.0 { + style.stroke_width = 1.0; + } + 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, &json!({}))?; + let ids = paint_targets(s, p)?; if !ids.is_empty() { s.edit("Swap Fill and Stroke", |d, _| { for id in &ids { - if let Some(n) = d.node_mut(*id) { - let f = n.appearance.fill_paint(); - let st = n.appearance.stroke_paint(); - n.appearance.set_fill(st); - n.appearance.set_stroke(f); + 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(()) @@ -150,24 +331,28 @@ fn swap(s: &mut Session, _: &Value) -> Result { ok() } -fn default_paint(s: &mut Session, _: &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, &json!({}))?; - if !ids.is_empty() { - s.edit("Default Fill and Stroke", |d, _| { - for id in &ids { - if let Some(n) = d.node_mut(*id) - && !matches!(n.kind, NodeKind::Text(_)) - { - n.appearance = Appearance::default_art(); - } - } - 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 })); } - ok() + 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))); + if let Some(shown) = first { + s.remember_paint(&shown); + } + Ok(r) } fn transparency(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 020464db..e293a93a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,7 +13,7 @@ mod tooling; use std::sync::Arc; use serde_json::Value; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{Color, GradientPaint, Paint}; use vectorcraft_doc::{Document, NodeId, NodeKind, Selection}; use vectorcraft_geom::Affine; use vectorcraft_tools::{PaintDefaults, Tool}; @@ -443,6 +443,15 @@ pub struct Session { untitled_counter: u32, /// Session-level state of the menu commands (saved selections, guide lock). pub(crate) menu: cmd::menucmds::MenuState, + /// The last solid colour applied (the toolbar's Color button; `,` applies it again). + pub last_solid: Color, + /// The last gradient applied (the toolbar's Gradient button; `.` applies it again). + pub last_gradient: GradientPaint, + /// Recently applied solid colours, newest first (the Recent Colors rows), fed by every paint + /// command whichever frontend runs it. + pub recent_colors: Vec, + /// A paint applied by a live preview: remembered when the interaction commits. + pub(crate) pending_paint: Option, } impl Default for Session { @@ -468,6 +477,10 @@ impl Session { draw_inside: None, untitled_counter: 0, menu: Default::default(), + last_solid: Color::WHITE, + last_gradient: GradientPaint::new(Default::default()), + recent_colors: vec![], + pending_paint: None, } } @@ -663,6 +676,9 @@ impl Session { } pub fn commit_interaction(&mut self) -> Result<()> { + if let Some(p) = self.pending_paint.take() { + self.remember_paint_now(&p); + } let st = self.doc_mut()?; let Some(it) = st.interaction.take() else { return Ok(()) }; let Some(preview) = it.preview else { return Ok(()) }; @@ -683,6 +699,7 @@ impl Session { } pub fn cancel_interaction(&mut self) -> Result<()> { + self.pending_paint = None; let st = self.doc_mut()?; if let Some(it) = st.interaction.take() { st.doc = it.doc; @@ -725,6 +742,8 @@ mod tests_live; #[cfg(test)] mod tests_menucmds; #[cfg(test)] +mod tests_paintproxy; +#[cfg(test)] mod tests_panelcmds; #[cfg(test)] mod tests_pathops; diff --git a/crates/engine/src/tests_paintproxy.rs b/crates/engine/src/tests_paintproxy.rs new file mode 100644 index 00000000..fc54544b --- /dev/null +++ b/crates/engine/src/tests_paintproxy.rs @@ -0,0 +1,172 @@ +//! Fill/Stroke proxy commands: invert, complement, last colour/gradient, swap/default on type and +//! the Session's recent colours. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::NodeKind; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn rect(s: &mut Session) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn hexes(s: &Session) -> Vec { + s.recent_colors.iter().map(Color::to_hex).collect() +} + +#[test] +fn complement_of_red_on_the_fill_only_gives_cyan() { + let mut s = session(); + let id = rect(&mut s); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let r = s.execute("paint.complement", &json!({})).unwrap(); + assert_eq!(r["changed"], 1); + let n = node(&s, id); + assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#00ffff"); + assert_eq!(n.appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000"); + // One undo step, and the new colour is remembered. + assert_eq!(s.last_solid.to_hex(), "#00ffff"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); + // `stroke: true` picks the stroke whatever proxy is in front. + s.execute("paint.complement", &json!({"stroke": true})).unwrap(); + assert_eq!(node(&s, id).appearance.stroke_paint().color().unwrap().to_hex(), "#00ffff"); +} + +#[test] +fn invert_keeps_cmyk_and_acts_on_defaults_without_selection() { + let mut s = session(); + let id = rect(&mut s); + s.execute("paint.setFill", &json!({"color": {"c": 0.0, "m": 1.0, "y": 1.0, "k": 0.0}})).unwrap(); + s.execute("paint.invert", &json!({})).unwrap(); + let c = node(&s, id).appearance.fill_paint().color().unwrap(); + assert!(matches!(c, Color::Cmyk { .. }), "{c:?}"); + assert_ne!(c, Color::cmyk(0.0, 1.0, 1.0, 0.0)); + // A CMYK complement stays CMYK too (K kept). + s.execute("paint.setFill", &json!({"color": {"c": 0.0, "m": 1.0, "y": 1.0, "k": 0.25}})).unwrap(); + s.execute("paint.complement", &json!({})).unwrap(); + assert_eq!(node(&s, id).appearance.fill_paint().color(), Some(Color::cmyk(1.0, 0.0, 0.0, 0.25))); + // Nothing selected: the default fill changes and the document doesn't. + s.execute("select.none", &json!({})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + s.paint.fill = Paint::solid(Color::rgb(1.0, 1.0, 0.0)); + s.execute("paint.invert", &json!({})).unwrap(); + assert_eq!(s.paint.fill.color().unwrap().to_hex(), "#0000ff"); + assert_eq!(s.doc().unwrap().doc, before); +} + +#[test] +fn last_color_and_gradient_are_reapplied() { + let mut s = session(); + let id = rect(&mut s); + s.execute( + "paint.setFill", + &json!({"gradient": {"kind": "radial", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}), + ) + .unwrap(); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + assert_eq!(node(&s, id).appearance.fill_paint().color().unwrap().to_hex(), "#336699"); + // `.` brings back the gradient after a solid colour, `,` the colour after the gradient. + s.execute("paint.lastGradient", &json!({})).unwrap(); + let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!("not a gradient") }; + assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000"); + assert!(g.geom.is_none(), "fitted to the object"); + s.execute("paint.lastColor", &json!({})).unwrap(); + assert_eq!(node(&s, id).appearance.fill_paint().color().unwrap().to_hex(), "#336699"); + // On the stroke proxy they paint the stroke and focus it. + s.execute("paint.lastColor", &json!({"stroke": true})).unwrap(); + assert_eq!(node(&s, id).appearance.stroke_paint().color().unwrap().to_hex(), "#336699"); + assert!(!s.fill_active); + let r = s.execute("paint.recent", &json!({})).unwrap(); + assert_eq!(r["lastColor"]["hex"], "#336699"); + assert_eq!(r["lastGradient"]["gradient"]["kind"], "Radial"); +} + +#[test] +fn swap_and_default_work_on_type() { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let style = |s: &Session| match &node(s, id).kind { + NodeKind::Text(t) => t.first_style(), + _ => panic!("not text"), + }; + s.execute("paint.swap", &json!({})).unwrap(); + let st = style(&s); + assert_eq!(st.fill, Paint::None); + assert_eq!(st.stroke.color().unwrap().to_hex(), "#ff0000"); + assert!(st.stroke_width > 0.0, "a painted stroke is visible"); + s.execute("paint.default", &json!({})).unwrap(); + let st = style(&s); + assert_eq!(st.fill, Paint::solid(Color::BLACK)); + assert_eq!(st.stroke, Paint::None); +} + +#[test] +fn recent_colors_are_fed_by_every_paint_command() { + let mut s = session(); + let id = rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(hexes(&s)[..2], ["#ff0000", "#00ff00"]); + // The eyedropper's colour sample goes through the same path. + s.execute("paint.sampleColor", &json!({"color": "#0000ff", "stroke": false})).unwrap(); + assert_eq!(hexes(&s)[0], "#0000ff"); + // None and gradients don't add colours. + s.execute("paint.none", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + assert_eq!(hexes(&s).len(), 3); + // The list holds the newest `RECENT_MAX` colours. + for i in 0..20u8 { + s.execute("paint.setFill", &json!({"color": Color::rgb8(i, 0, 0).to_hex()})).unwrap(); + } + assert_eq!(s.recent_colors.len(), Session::RECENT_MAX); + assert_eq!(s.recent_colors[0].to_hex(), "#130000"); + let r = s.execute("paint.recent", &json!({})).unwrap(); + assert_eq!(r["colors"][0]["hex"], "#130000"); + let _ = id; +} + +#[test] +fn live_previews_remember_only_the_committed_colour() { + let mut s = session(); + rect(&mut s); + s.begin_interaction("Color").unwrap(); + for hex in ["#100000", "#200000", "#300000"] { + s.preview("paint.setFill", &json!({"color": hex})).unwrap(); + } + assert!(s.recent_colors.is_empty(), "nothing is remembered while dragging"); + s.commit_interaction().unwrap(); + assert_eq!(hexes(&s), ["#300000"]); + s.begin_interaction("Color").unwrap(); + s.preview("paint.setFill", &json!({"color": "#400000"})).unwrap(); + s.cancel_interaction().unwrap(); + assert_eq!(hexes(&s), ["#300000"], "a cancelled preview is forgotten"); +} + +#[test] +fn focus_false_keeps_the_active_proxy() { + let mut s = session(); + let id = rect(&mut s); + assert!(s.fill_active); + s.execute("paint.setStroke", &json!({"color": "#ff0000", "focus": false})).unwrap(); + assert!(s.fill_active); + assert_eq!(node(&s, id).appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000"); +} diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index eb2f44db..27d71944 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -6,7 +6,7 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use vectorcraft_color::{Color, Paint}; -use super::{active_paint, color_json, live_run, paint_target, pstate, push_recent, set_pstate}; +use super::{active_paint, color_json, live_run, paint_target, pstate, set_pstate}; use crate::theme::Tokens; use crate::widgets::{self, Live, menu_item}; use crate::{VectorcraftApp, icons}; @@ -172,7 +172,7 @@ fn target(app: &VectorcraftApp, ui: &Ui) -> Target { } } -fn apply(app: &mut VectorcraftApp, ui: &Ui, tgt: &Target, c: Color, phase: Live) { +fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live) { match tgt { Target::Paint(_) => { let cmd = paint_target(app); @@ -189,9 +189,6 @@ fn apply(app: &mut VectorcraftApp, ui: &Ui, tgt: &Target, c: Color, phase: Live) } } } - if phase == Live::Released { - push_recent(ui.ctx(), c); - } } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -366,7 +363,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some((c, phase, comps2)) = new { let key = (c.to_hex(), mode as u8); set_pstate(ui.ctx(), "color-comps", Some((key, comps2))); - apply(app, ui, &tgt, c, phase); + apply(app, &tgt, c, phase); } } @@ -390,21 +387,28 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(c) = color { let conv = from_components(if m == Mode::Hsb { Mode::Rgb } else { m }, &components(if m == Mode::Hsb { Mode::Rgb } else { m }, &c)); if conv != c { - apply(app, ui, &tgt, conv, Live::Released); + apply(app, &tgt, conv, Live::Released); } } } } ui.separator(); - if menu_item(ui, "Invert", color.is_some(), false) - && let Some(c) = color - { - apply(app, ui, &tgt, c.invert(), Live::Released); - } - if menu_item(ui, "Complement", color.is_some(), false) - && let Some(c) = color - { - apply(app, ui, &tgt, c.complement(), Live::Released); + // Solid colours go through the proxy commands (each selected object keeps its colour model); + // a gradient stop is recoloured in place. + for (label, cmd, f) in [ + ("Invert", "paint.invert", Color::invert_keep_model as fn(&Color) -> Color), + ("Complement", "paint.complement", Color::complement_keep_model), + ] { + if menu_item(ui, label, color.is_some(), false) + && let Some(c) = color + { + match &tgt { + Target::Paint(_) => { + app.run(cmd, json!({})).ok(); + } + Target::Stop { .. } => apply(app, &tgt, f(&c), Live::Released), + } + } } ui.separator(); if menu_item(ui, "Create New Swatch…", color.is_some(), false) diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index f3c2ef78..73d89e3e 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -6,7 +6,7 @@ use serde_json::json; use vectorcraft_color::harmony::Harmony; use vectorcraft_color::{Color, Paint}; -use super::{active_paint, color_json, paint_target, pstate, push_recent, set_pstate}; +use super::{active_paint, color_json, paint_target, pstate, set_pstate}; use crate::VectorcraftApp; use crate::widgets::{self, menu_item}; @@ -122,10 +122,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("swatch.newGroup", json!({"name": h.label(), "colors": cs})).ok(); } }); - if let Some(c) = chosen - && app.run(paint_target(app), json!({"color": color_json(&c)})).is_ok() - { - push_recent(ui.ctx(), c); + if let Some(c) = chosen { + app.run(paint_target(app), json!({"color": color_json(&c)})).ok(); } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0b163e51..4a70a1c8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -229,27 +229,15 @@ pub(crate) fn set_pstate(ctx: &egui::Context, ctx.data_mut(|d| d.insert_temp(egui::Id::new(("panel-state", key)), v)); } -/// Recently applied colours (Swatches / Color panels "Recent Colors" row), newest first. -pub(crate) fn recent_colors(ctx: &egui::Context) -> Vec { - pstate::>(ctx, "recent-colors") -} -pub(crate) fn push_recent(ctx: &egui::Context, c: Color) { - let mut v = recent_colors(ctx); - v.retain(|x| x.to_hex() != c.to_hex()); - v.insert(0, c); - v.truncate(10); - set_pstate(ctx, "recent-colors", v); -} - -/// "Recent Colors" header + a row of chips; clicking one applies it to the active proxy. +/// "Recent Colors" header + a row of chips (the Session's recent colours, which every paint +/// command feeds); clicking one applies it to the active proxy. pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); crate::widgets::subheader(ui, "Recent Colors"); - let recent = recent_colors(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); let mut chosen = None; - for (i, c) in recent.iter().enumerate() { + for (i, c) in app.session.recent_colors.iter().enumerate() { let cell = Rect::from_min_size(r.min + vec2(3.0 + i as f32 * 19.0, 3.0), vec2(16.0, 16.0)); if cell.right() > r.right() - 2.0 { break; diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 2320f8f7..2fcf2ccf 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -5,7 +5,7 @@ use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use vectorcraft_color::Paint; -use super::{active_paint, paint_target, pstate, push_recent, set_pstate}; +use super::{active_paint, paint_target, pstate, set_pstate}; use crate::theme::Tokens; use crate::widgets::{self, menu_item, swatch_tile}; use crate::{VectorcraftApp, icons}; @@ -112,19 +112,15 @@ fn entries(app: &VectorcraftApp, kind: Kind) -> Vec { out } -fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) { +fn apply(app: &mut VectorcraftApp, e: &Entry) { let target = paint_target(app); match e { Entry::Registration => { app.run(target, json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}})).ok(); } Entry::Swatch { name, paint, .. } => { - let r = if paint.is_none() { app.run(target, json!({"none": true})) } else { app.run(target, json!({"swatch": name})) }; - if r.is_ok() - && let Some(c) = paint.color() - { - push_recent(ui.ctx(), c); - } + let params = if paint.is_none() { json!({"none": true}) } else { json!({"swatch": name}) }; + app.run(target, params).ok(); } Entry::Folder(_) => {} } @@ -343,7 +339,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { Entry::Folder(n) => n.clone(), }; set_pstate(ui.ctx(), "swatch-selected", Some(name)); - apply(app, ui, &e); + apply(app, &e); } bottom(app, ui); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4398bb06..5b814a74 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -147,9 +147,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // Command shortcuts. let mut fire: Option<&'static str> = None; for (sc, id) in all_shortcuts() { - // Letter / slash keys without Cmd/Alt/Ctrl are handled below as text (tool shortcuts, X, D, /). + // Letter / punctuation keys without Cmd/Alt/Ctrl are handled below as text (tool shortcuts, + // X, D, /, `,` and `.`). let plain = !(sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl); - if plain && (sc.logical_key.name().len() == 1 || sc.logical_key == Key::Slash) { + if plain && (sc.logical_key.name().len() == 1 || matches!(sc.logical_key, Key::Slash | Key::Comma | Key::Period)) { continue; } if app.native_shortcuts.contains(id) { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index d71475c0..461bc9aa 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -4,6 +4,7 @@ use egui::{Color32, CornerRadius, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; +use vectorcraft_color::Paint; use vectorcraft_tools::{TOOL_GROUPS, ToolInfo, tool_info}; use crate::theme::{self, Tokens}; @@ -178,46 +179,26 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) { - let (fill, stroke) = match app.session.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id))) { - Some(n) if !n.is_container() => (n.appearance.fill_paint(), n.appearance.stroke_paint()), - _ => (app.session.paint.fill.clone(), app.session.paint.stroke.clone()), - }; - ui.vertical_centered(|ui| { - let (f, s, swap, def) = widgets::fill_stroke_proxy(ui, &fill, &stroke, app.session.fill_active, 36.0); - if f { - app.session.fill_active = true; - } - if s { - app.session.fill_active = false; - } - if swap { - app.run("paint.swap", json!({})).ok(); - } - if def { - app.run("paint.default", json!({})).ok(); - } - }); + ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0)); ui.add_space(5.0); - let target = if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" }; + // Color (the last solid colour), Gradient (the last gradient) and None, as commands. ui.horizontal(|ui| { ui.add_space((ui.available_width() - 27.0) / 2.0); ui.spacing_mut().item_spacing.x = 2.0; - let s = 7.5; - let last = app.session.paint.fill.color().unwrap_or(vectorcraft_color::Color::BLACK); - let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click()); - widgets::paint_chip(ui, r, &vectorcraft_color::Paint::solid(last)); - if resp.on_hover_text("Color (,)").clicked() { - app.run(target, json!({"color": last.to_hex()})).ok(); + let mut clicked = None; + for (tip, cmd) in [("Color (,)", "paint.lastColor"), ("Gradient (.)", "paint.lastGradient"), ("None (/)", "paint.none")] { + let (r, resp) = ui.allocate_exact_size(vec2(7.5, 7.5), Sense::click()); + match cmd { + "paint.lastColor" => widgets::paint_chip(ui, r, &Paint::solid(app.session.last_solid)), + "paint.lastGradient" => widgets::gradient_chip(ui, r, &app.session.last_gradient.gradient), + _ => widgets::paint_chip(ui, r, &Paint::None), + } + if resp.on_hover_text(tip).clicked() { + clicked = Some(cmd); + } } - let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click()); - widgets::paint_chip(ui, r, &vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default())))); - if resp.on_hover_text("Gradient (.)").clicked() { - app.run(target, json!({"gradient": {"kind": "linear"}})).ok(); - } - let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click()); - widgets::paint_chip(ui, r, &vectorcraft_color::Paint::None); - if resp.on_hover_text("None (/)").clicked() { - app.run(target, json!({"none": true})).ok(); + if let Some(cmd) = clicked { + app.run(cmd, json!({})).ok(); } }); ui.add_space(6.0); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 968b6e9b..514f9eb5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -183,30 +183,7 @@ pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { let [r, g, b, _] = color.to_rgba8(1.0); p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); } - Paint::Gradient(gp) => { - if gp.gradient.kind == vectorcraft_color::GradientKind::Radial { - // Outer colour fills the corners beyond the largest circle. - let (c, _) = gp.gradient.sample(1.0); - let [r, g, b, _] = c.to_rgba8(1.0); - p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); - } - let n = 24; - let w = rect.width() / n as f32; - for i in 0..n { - let tt = (i as f32 + 0.5) / n as f32; - let (c, _) = gp.gradient.sample(tt); - let [r, g, b, _] = c.to_rgba8(1.0); - let rr = Rect::from_min_size(pos2(rect.left() + i as f32 * w, rect.top()), vec2(w + 0.5, rect.height())); - if gp.gradient.kind == vectorcraft_color::GradientKind::Radial { - let (c, _) = gp.gradient.sample(1.0 - tt); - let [r, g, b, _] = c.to_rgba8(1.0); - let s = rect.width().min(rect.height()) * tt / 2.0; - p.circle_filled(rect.center(), s.max(0.5), Color32::from_rgb(r, g, b)); - } else { - p.rect_filled(rr, 0.0, Color32::from_rgb(r, g, b)); - } - } - } + Paint::Gradient(gp) => gradient_chip(ui, rect, &gp.gradient), Paint::Pattern { .. } => { p.rect_filled(rect, 0.0, Color32::from_gray(200)); for i in 0..4 { @@ -217,6 +194,34 @@ pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { } } +/// Draw a gradient preview into `rect` (linear left to right; radial from the centre). +pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient) { + let p = ui.painter(); + let radial = gradient.kind == vectorcraft_color::GradientKind::Radial; + if radial { + // Outer colour fills the corners beyond the largest circle. + let (c, _) = gradient.sample(1.0); + let [r, g, b, _] = c.to_rgba8(1.0); + p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); + } + let n = 24; + let w = rect.width() / n as f32; + for i in 0..n { + let tt = (i as f32 + 0.5) / n as f32; + if radial { + let (c, _) = gradient.sample(1.0 - tt); + let [r, g, b, _] = c.to_rgba8(1.0); + let s = rect.width().min(rect.height()) * tt / 2.0; + p.circle_filled(rect.center(), s.max(0.5), Color32::from_rgb(r, g, b)); + } else { + let (c, _) = gradient.sample(tt); + let [r, g, b, _] = c.to_rgba8(1.0); + let rr = Rect::from_min_size(pos2(rect.left() + i as f32 * w, rect.top()), vec2(w + 0.5, rect.height())); + p.rect_filled(rr, 0.0, Color32::from_rgb(r, g, b)); + } + } +} + /// The Fill/Stroke proxy pair (Illustrator's overlapping squares). Returns (fill clicked, stroke clicked, swap, default). pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: bool, size: f32) -> (bool, bool, bool, bool) { let t = Tokens::get(ui.ctx()); diff --git a/docs/mcp.md b/docs/mcp.md index 50b51256..2dd262db 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -40,6 +40,9 @@ explicit `ids`. Paint values (`fill`, `stroke`) accept `"#rrggbb"`, `"none"`, `[r,g,b]` (0..1), `{"c","m","y","k"}`, `{"gray"}`, or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"}`). +The Fill/Stroke proxy commands run through `run_command`: `paint.invert` and `paint.complement` recolour the active +proxy keeping each colour's model, `paint.lastColor` / `paint.lastGradient` re-apply the last solid colour or gradient, +and `paint.recent` returns the recent colours that every paint command (and the eyedropper) feeds. | Tool | Arguments | Notes | |---|---|---|