From 8a871d98a9ba9f4e1fd7f1493c7c93ee358b54d8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 12:23:52 +0700 Subject: [PATCH 1/6] =?UTF-8?q?M3.29:=20proxy=20paint=20commands=20?= =?UTF-8?q?=E2=80=94=20paint.invert=20/=20paint.complement=20recolour=20th?= =?UTF-8?q?e=20active=20proxy=20of=20each=20selected=20object=20keeping=20?= =?UTF-8?q?its=20colour=20model=20(the=20defaults=20when=20nothing=20is=20?= =?UTF-8?q?selected),=20paint.lastColor=20(,)=20and=20paint.lastGradient?= =?UTF-8?q?=20(.)=20re-apply=20the=20last=20solid=20colour=20or=20gradient?= =?UTF-8?q?=20(the=20toolbar=20chips=20draw=20and=20run=20them),=20swap=20?= =?UTF-8?q?and=20default=20work=20on=20type,=20and=20recent=20colours=20li?= =?UTF-8?q?ve=20in=20the=20Session,=20fed=20by=20every=20paint=20command?= =?UTF-8?q?=20(MCP,=20eyedropper,=20live=20previews=20on=20commit)=20and?= =?UTF-8?q?=20listed=20by=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 | |---|---|---| From f21161ce8c1d556b9d58cef01e95f3869c9f964c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:49:26 +0700 Subject: [PATCH 2/6] =?UTF-8?q?M3.27:=20Color=20Picker=20dialog=20?= =?UTF-8?q?=E2=80=94=20double-clicking=20a=20Fill/Stroke=20proxy=20(toolba?= =?UTF-8?q?r,=20Color,=20Swatches,=20Properties)=20or=20ui.colorPicker=20{?= =?UTF-8?q?stroke=3F,=20color=3F}=20opens=20a=20colour=20field=20with=20an?= =?UTF-8?q?=20H/S/B/R/G/B=20channel=20slider,=20HSB/RGB/Lab/CMYK=20fields?= =?UTF-8?q?=20and=20hex,=20Only=20Web=20Colors,=20out-of-gamut=20(correcte?= =?UTF-8?q?d=20through=20color.convert)=20and=20out-of-web=20warnings,=20N?= =?UTF-8?q?ew/Original=20chips=20and=20a=20Color=20Swatches=20list;=20OK?= =?UTF-8?q?=20runs=20paint.setFill=20/=20paint.setStroke=20(CMYK=20in=20CM?= =?UTF-8?q?YK=20documents)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/color_picker.rs | 514 +++++++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 3 + crates/ui-egui/src/menus.rs | 7 + crates/ui-egui/src/panels/color.rs | 79 ++-- crates/ui-egui/src/panels/mod.rs | 24 +- crates/ui-egui/src/panels/properties.rs | 4 +- crates/ui-egui/src/widgets.rs | 193 +++++++- docs/control-protocol.md | 2 + 8 files changed, 762 insertions(+), 64 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/color_picker.rs diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs new file mode 100644 index 00000000..eef6ec4d --- /dev/null +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -0,0 +1,514 @@ +//! The Color Picker (double-click a Fill/Stroke proxy, or `ui.colorPicker`): a colour field with a +//! channel slider for H, S, B, R, G or B; HSB, RGB, Lab and CMYK fields and hex; Only Web Colors; +//! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; and a Color +//! Swatches list. OK applies the colour to its proxy through `paint.setFill` / `paint.setStroke`. +//! +//! Fields agents set with `ui.dialog.set`: `hex` (`"00FF00"`) or `color` (any colour the paint +//! commands take), `channel` (`hue`, `saturation`, `brightness`, `red`, `green`, `blue`), `webOnly` +//! and `swatches`. `stroke` and `original` come from the opener. + +use egui::{Rect, Sense, Ui, vec2}; +use serde_json::{Value, json}; +use vectorcraft_color::cms::{self, Lab}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::ColorMode; + +use super::DialogSpec; +use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, hex_digits, in_gamut, is_web_safe, parse_hex, warning_chip, web_safe}; +use crate::panels::{c32, color_from_json, color_json}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +pub(super) const KIND: &str = "colorPicker"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Color Picker".into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM }; + +/// Side of the colour field (and height of the channel slider). +const FIELD: f32 = 240.0; +/// Width of the value fields. +const VALUE_W: f32 = 54.0; + +/// The component the channel slider edits; the colour field shows the other two. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Channel { + #[default] + Hue, + Saturation, + Brightness, + Red, + Green, + Blue, +} + +impl Channel { + pub const ALL: [Channel; 6] = [Channel::Hue, Channel::Saturation, Channel::Brightness, Channel::Red, Channel::Green, Channel::Blue]; + + pub fn id(self) -> &'static str { + match self { + Channel::Hue => "hue", + Channel::Saturation => "saturation", + Channel::Brightness => "brightness", + Channel::Red => "red", + Channel::Green => "green", + Channel::Blue => "blue", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|c| c.id() == s) + } + fn label(self) -> &'static str { + match self { + Channel::Hue => "H", + Channel::Saturation => "S", + Channel::Brightness | Channel::Blue => "B", + Channel::Red => "R", + Channel::Green => "G", + } + } + fn is_hsb(self) -> bool { + matches!(self, Channel::Hue | Channel::Saturation | Channel::Brightness) + } + /// Indices of (slider, field x, field y) into the channel's space: [h, s, b] or [r, g, b]. + fn axes(self) -> (usize, usize, usize) { + match self { + Channel::Hue => (0, 1, 2), + Channel::Saturation => (1, 0, 2), + Channel::Brightness => (2, 0, 1), + Channel::Red => (0, 2, 1), + Channel::Green => (1, 2, 0), + Channel::Blue => (2, 0, 1), + } + } +} + +/// The picked colour with its HSB (hue in degrees), kept so the hue survives grey colours. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Picked { + pub color: Color, + pub hsb: [f32; 3], +} + +impl Picked { + pub fn of(color: Color) -> Self { + Self { color, hsb: color.to_hsb() } + } + fn from_hsb(hsb: [f32; 3]) -> Self { + Self { color: Color::from_hsb(hsb[0], hsb[1], hsb[2]), hsb } + } + /// The nearest web colour when Only Web Colors is on. + pub fn snapped(self, web: bool) -> Self { + if web && !is_web_safe(&self.color) { Self::of(web_safe(&self.color)) } else { self } + } +} + +/// The slider value and the field point (x right, y down; 0..1 each) of a colour. +pub fn field_pos(ch: Channel, p: &Picked) -> (f32, f32, f32) { + let v = if ch.is_hsb() { [p.hsb[0] / 360.0, p.hsb[1], p.hsb[2]] } else { p.color.to_rgb() }; + let (z, x, y) = ch.axes(); + (v[z], v[x], 1.0 - v[y]) +} + +/// The colour at slider value `z` and field point (x, y): the inverse of [`field_pos`]. +pub fn field_pick(ch: Channel, z: f32, x: f32, y: f32) -> Picked { + let (zi, xi, yi) = ch.axes(); + let mut v = [0.0; 3]; + v[zi] = z; + v[xi] = x; + v[yi] = 1.0 - y; + let v = v.map(|c| c.clamp(0.0, 1.0)); + if ch.is_hsb() { Picked::from_hsb([v[0] * 360.0, v[1], v[2]]) } else { Picked::of(Color::rgb(v[0], v[1], v[2])) } +} + +/// The channel slider's track at `z`: the full spectrum for hue, else the colour with only the +/// slider's component changing. +pub fn slider_color(ch: Channel, z: f32, x: f32, y: f32) -> Color { + if ch == Channel::Hue { Color::from_hsb(z * 360.0, 1.0, 1.0) } else { field_pick(ch, z, x, y).color } +} + +/// The colour the dialog holds: an edited `hex` wins over `color` (agents set either one). +fn picked(d: &Dialog) -> Picked { + let hex = d.str("hex"); + let color = match parse_hex(&hex) { + Some(c) if hex != d.str("__hex") => c, + _ => d.fields.get("color").and_then(color_from_json).unwrap_or(Color::WHITE), + }; + let hsb = d.fields.get("__hsb").and_then(|v| serde_json::from_value::<[f32; 3]>(v.clone()).ok()); + let p = match hsb { + Some(h) if Picked::from_hsb(h).color.to_hex() == color.to_hex() => Picked { color, hsb: h }, + _ => Picked::of(color), + }; + p.snapped(d.bool("webOnly")) +} + +fn store(d: &mut Dialog, p: &Picked) { + let hex = hex_digits(&p.color); + d.fields.insert("color".into(), color_json(&p.color)); + d.fields.insert("hex".into(), json!(hex)); + d.fields.insert("__hex".into(), json!(hex)); + d.fields.insert("__hsb".into(), json!(p.hsb)); +} + +/// The colour the proxy shows (a gradient's first stop; the last colour for None and patterns). +fn proxy_color(app: &VectorcraftApp, stroke: bool) -> Color { + let (f, s) = crate::panels::current_paints(app); + match if stroke { s } else { f } { + Paint::Solid { color, .. } => color, + Paint::Gradient(g) => g.gradient.stops.first().map_or(app.session.last_solid, |s| s.color), + Paint::None | Paint::Pattern { .. } => app.session.last_solid, + } +} + +/// `ui.colorPicker {stroke?, color?}`: open the picker for the fill (or stroke) proxy, starting +/// from `color` or the proxy's colour. +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!app.session.fill_active); + let color = match p.get("color") { + Some(v) => color_from_json(v).ok_or_else(|| format!("bad color {v}"))?, + None => proxy_color(app, stroke), + }; + let mut d = Dialog::new( + KIND, + json!({"stroke": stroke, "original": color_json(&color), "channel": Channel::default().id(), "webOnly": false, "swatches": false}), + ); + store(&mut d, &Picked::of(color)); + app.ui.dialog = Some(d); + Ok(Value::Null) +} + +/// The cached out-of-gamut correction of `c` (`color.convert` runs once per colour). +fn gamut_fix(app: &mut VectorcraftApp, d: &mut Dialog, c: &Color) -> Option { + let key = color_json(c); + if d.fields.get("__gamutOf") != Some(&key) { + let fix = in_gamut(app, c).map_or(Value::Null, |f| color_json(&f)); + d.fields.insert("__gamut".into(), fix); + d.fields.insert("__gamutOf".into(), key); + } + d.fields.get("__gamut").and_then(color_from_json) +} + +fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let channel = Channel::parse(&d.str("channel")).unwrap_or_default(); + let web = d.bool("webOnly"); + let swatches = d.bool("swatches"); + let p = picked(d); + let original = d.fields.get("original").and_then(color_from_json).unwrap_or(p.color); + let gamut = gamut_fix(app, d, &p.color); + let mut pick: Option = None; + let mut new_channel = None; + let mut toggle_swatches = false; + ui.horizontal_top(|ui| { + if swatches { + pick = swatch_list(app, ui, &p.color).map(Picked::of); + } else { + let (z, x, y) = field_pos(channel, &p); + let at = |x: f32, y: f32| c32(&field_pick(channel, z, x, y).snapped(web).color); + if let (Some((x, y)), _) = widgets::color_field(ui, "cp-field", vec2(FIELD, FIELD), (x, y), &at) { + pick = Some(field_pick(channel, z, x, y)); + } + ui.add_space(6.0); + let track = |z: f32| c32(&Picked::of(slider_color(channel, z, x, y)).snapped(web).color); + if let (Some(z), _) = widgets::channel_slider(ui, "cp-slider", z, vec2(22.0, FIELD), &track) { + pick = Some(field_pick(channel, z, x, y)); + } + } + ui.add_space(14.0); + ui.vertical(|ui| { + ui.horizontal_top(|ui| { + if new_original_chips(ui, &p.color, &original) { + pick = Some(Picked::of(original)); + } + ui.add_space(8.0); + ui.vertical(|ui| { + if let Some(fix) = gamut + && warning_chip(ui, GAMUT_WARNING, &fix) + { + pick = Some(Picked::of(fix)); + } + let ws = web_safe(&p.color); + if !web && !is_web_safe(&p.color) && warning_chip(ui, WEB_WARNING, &ws) { + pick = Some(Picked::of(ws)); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + toggle_swatches = widgets::flat_button(ui, if swatches { "Color Models" } else { "Color Swatches" }, 110.0).clicked(); + }); + }); + ui.add_space(10.0); + let (edit, ch) = value_fields(ui, &p, channel); + pick = pick.or(edit); + new_channel = ch; + }); + }); + ui.add_space(8.0); + if widgets::check(ui, "Only Web Colors", web, true) { + d.fields.insert("webOnly".into(), json!(!web)); + } + if let Some(ch) = new_channel { + d.fields.insert("channel".into(), json!(ch.id())); + } + if toggle_swatches { + d.fields.insert("swatches".into(), json!(!swatches)); + } + store(d, &pick.unwrap_or(p).snapped(d.bool("webOnly"))); + false +} + +/// The New (top) and Original (bottom) chips. Returns true when Original is clicked. +fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(64.0, 60.0), Sense::hover()); + let top = Rect::from_min_max(r.min, egui::pos2(r.right(), r.center().y)); + let bottom = Rect::from_min_max(egui::pos2(r.left(), r.center().y), r.max); + widgets::paint_chip(ui, top, &Paint::solid(*new)); + widgets::paint_chip(ui, bottom, &Paint::solid(*original)); + ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + ui.interact(top, ui.id().with("cp-new"), Sense::hover()).on_hover_text("New"); + ui.interact(bottom, ui.id().with("cp-original"), Sense::click()).on_hover_text("Original: click to restore").clicked() +} + +/// The HSB and RGB fields with the channel radios, hex, Lab and CMYK. Returns the edited colour +/// and the newly chosen channel. +fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option, Option) { + let t = Tokens::get(ui.ctx()); + let rgb = p.color.to_rgb(); + let lab = p.color.to_lab(); + let cms = cms::active(); + let cmyk = p.color.to_cmyk_managed(cms.settings().intent); + let mut edit = None; + let mut chosen = None; + egui::Grid::new("cp-values").num_columns(4).spacing([6.0, 4.0]).show(ui, |ui| { + for i in 0..7 { + if let Some(&ch) = Channel::ALL.get(i) { + if widgets::radio(ui, ch.label(), ch == channel) { + chosen = Some(ch); + } + let (v, suffix) = match i { + 0 => (p.hsb[0], "°"), + 1 | 2 => (p.hsb[i] * 100.0, "%"), + _ => (rgb[i - 3] * 255.0, ""), + }; + if let Some(nv) = widgets::plain_field(ui, ("cp-left", i), v as f64, suffix, 0, VALUE_W) { + let nv = nv as f32; + edit = Some(match i { + 0..3 => { + let mut hsb = p.hsb; + hsb[i] = if i == 0 { nv.clamp(0.0, 360.0) } else { (nv / 100.0).clamp(0.0, 1.0) }; + Picked::from_hsb(hsb) + } + _ => { + let mut c = rgb; + c[i - 3] = (nv / 255.0).clamp(0.0, 1.0); + Picked::of(Color::rgb(c[0], c[1], c[2])) + } + }); + } + } else if let Some(c) = widgets::hex_field(ui, "cp-hex", &hex_digits(&p.color)).as_deref().and_then(parse_hex) { + edit = Some(Picked::of(c)); + } + let (label, v, suffix) = match i { + 0 => ("L", lab.l, ""), + 1 => ("a", lab.a, ""), + 2 => ("b", lab.b, ""), + _ => (["C", "M", "Y", "K"][i - 3], cmyk[i - 3] * 100.0, "%"), + }; + ui.horizontal(|ui| { + ui.add_space(12.0); + ui.label(egui::RichText::new(label).size(12.5).color(t.text)); + }); + if let Some(nv) = widgets::plain_field(ui, ("cp-right", i), v as f64, suffix, 0, VALUE_W) { + let nv = nv as f32; + edit = Some(Picked::of(if i < 3 { + let mut l = [lab.l, lab.a, lab.b]; + l[i] = if i == 0 { nv.clamp(0.0, 100.0) } else { nv.clamp(-128.0, 127.0) }; + cms.from_lab(Lab::new(l[0], l[1], l[2])) + } else { + let mut k = cmyk; + k[i - 3] = (nv / 100.0).clamp(0.0, 1.0); + Color::cmyk(k[0], k[1], k[2], k[3]) + })); + } + ui.end_row(); + } + }); + (edit, chosen) +} + +/// The document's colour swatches as a list; returns the clicked swatch's colour. +fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color) -> Option { + let t = Tokens::get(ui.ctx()); + let mut chosen = None; + ui.allocate_ui(vec2(FIELD + 28.0, FIELD), |ui| { + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().id_salt("cp-swatches").max_height(FIELD).show(ui, |ui| { + ui.set_width(FIELD + 24.0); + let Some(st) = app.session.active() else { + widgets::dim_label(ui, "Open a document to see its swatches."); + return; + }; + let doc = &st.doc; + for s in doc.swatches.iter().chain(doc.swatch_groups.iter().flat_map(|g| g.swatches.iter())) { + let Paint::Solid { color, .. } = &s.paint else { continue }; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let chip = Rect::from_min_size(r.left_center() + vec2(4.0, -7.0), vec2(14.0, 14.0)); + widgets::swatch_tile(ui, chip, &s.paint, color == current, false); + ui.painter().text( + egui::pos2(chip.right() + 8.0, r.center().y), + egui::Align2::LEFT_CENTER, + &s.name, + egui::FontId::proportional(12.0), + t.text, + ); + if resp.clicked() { + chosen = Some(*color); + } + } + }); + }); + }); + chosen +} + +/// OK: apply the colour to the proxy (as CMYK in a CMYK document). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut c = picked(d).color; + if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) { + let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent); + c = Color::cmyk(cy, m, y, k); + } + let cmd = if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" }; + super::run_and_close(app, cmd, json!({ "color": color_json(&c) })) +} + +#[cfg(test)] +mod tests { + use vectorcraft_engine::Session; + + use super::*; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app + } + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn fill_hex(app: &VectorcraftApp) -> String { + crate::panels::current_paints(app).0.color().unwrap().to_hex() + } + + #[test] + fn field_and_slider_map_back_per_channel() { + let p = Picked::of(Color::rgb8(51, 153, 204)); + for ch in Channel::ALL { + let (z, x, y) = field_pos(ch, &p); + assert_eq!(field_pick(ch, z, x, y).color.to_hex(), "#3399cc", "{ch:?}"); + assert_eq!(Channel::parse(ch.id()), Some(ch)); + } + // Hue: x is saturation, y brightness (white at the top left, black along the bottom). + assert_eq!(field_pick(Channel::Hue, 0.0, 0.0, 0.0).color.to_hex(), "#ffffff"); + assert_eq!(field_pick(Channel::Hue, 0.0, 1.0, 0.0).color.to_hex(), "#ff0000"); + assert_eq!(field_pick(Channel::Hue, 0.5, 1.0, 1.0).color.to_hex(), "#000000"); + // Red: the slider is red, x blue and y green (green grows upwards). + assert_eq!(field_pick(Channel::Red, 1.0, 1.0, 0.0).color.to_hex(), "#ffffff"); + assert_eq!(field_pick(Channel::Red, 0.0, 1.0, 1.0).color.to_hex(), "#0000ff"); + assert_eq!(field_pick(Channel::Green, 1.0, 0.0, 1.0).color.to_hex(), "#00ff00"); + assert_eq!(field_pick(Channel::Blue, 0.0, 1.0, 1.0).color.to_hex(), "#ff0000"); + // The hue slider shows the spectrum whatever the colour; the others vary one component. + assert_eq!(slider_color(Channel::Hue, 1.0 / 3.0, 0.0, 1.0).to_hex(), "#00ff00"); + assert_eq!(slider_color(Channel::Saturation, 0.0, 0.0, 0.0).to_hex(), "#ffffff"); + // A grey keeps the hue it was picked with. + let grey = field_pick(Channel::Hue, 0.5, 0.0, 0.5); + assert_eq!(grey.hsb[0], 180.0); + assert_eq!(field_pos(Channel::Hue, &grey).0, 0.5); + } + + #[test] + fn only_web_colors_snaps() { + let p = Picked::of(Color::rgb8(230, 120, 40)); + assert_eq!(p.snapped(false), p); + assert_eq!(p.snapped(true).color.to_hex(), "#ff6633"); + assert!(is_web_safe(&p.snapped(true).color)); + let mut d = Dialog::new(KIND, json!({"color": "#e67828", "webOnly": true})); + assert_eq!(picked(&d).color.to_hex(), "#ff6633"); + d.fields.insert("webOnly".into(), json!(false)); + assert_eq!(picked(&d).color.to_hex(), "#e67828"); + } + + #[test] + fn gamut_correction_gives_a_printable_colour() { + let mut app = app(); + let blue = Color::rgb(0.0, 0.0, 1.0); + assert!(blue.out_of_gamut()); + let fix = in_gamut(&mut app, &blue).expect("pure blue can't be printed"); + assert!(!fix.out_of_gamut(), "{fix:?}"); + assert!(matches!(fix, Color::Rgb { .. })); + assert_eq!(in_gamut(&mut app, &Color::rgb8(128, 128, 128)), None); + // The dialog offers it and clicking it would pick it. + app.run("ui.colorPicker", json!({"color": "#0000ff"})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + assert_eq!(gamut_fix(&mut app, &mut d, &blue), Some(fix)); + } + + #[test] + fn open_set_hex_and_confirm_fills() { + let mut app = app(); + app.run("ui.colorPicker", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(super::super::DialogKind::of(&d.kind), Some(super::super::DialogKind::ColorPicker)); + assert_eq!(d.str("hex"), "FFFFFF", "starts from the fill"); + assert!(!d.bool("stroke")); + frame(&mut app); + // `ui.dialog.set {field: "hex"}` then `ui.dialog.confirm`. + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00")); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(fill_hex(&app), "#00ff00"); + // Without a frame in between, a set hex still wins; `color` works as well. + app.run("ui.colorPicker", json!({"stroke": true})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("#3366CC")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(crate::panels::current_paints(&app).1.color().unwrap().to_hex(), "#3366cc"); + // The stroke proxy is in front now: the opener follows it unless told otherwise. + app.run("ui.colorPicker", json!({})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool("stroke")); + app.run("ui.colorPicker", json!({"stroke": false})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!({"c": 0, "m": 1, "y": 1, "k": 0})); + super::super::confirm(&mut app).unwrap(); + assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK is kept"); + assert!(app.run("ui.colorPicker", json!({"color": "nope"})).is_err()); + } + + #[test] + fn every_mode_draws_and_cmyk_documents_get_cmyk() { + let mut app = app(); + for fields in + [json!({"channel": "red"}), json!({"channel": "brightness", "webOnly": true}), json!({"swatches": true}), json!({"color": "#0000ff"})] + { + app.run("ui.colorPicker", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in fields.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } + frame(&mut app); + assert!(app.ui.dialog.is_some()); + } + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + app.run("ui.colorPicker", json!({"color": "#ff0000"})).unwrap(); + super::super::confirm(&mut app).unwrap(); + assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. }))); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 9532e989..f79ce01d 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -9,6 +9,7 @@ mod about; mod all_tools; mod artboard_options; +mod color_picker; mod command; mod document_setup; mod effect; @@ -24,6 +25,7 @@ mod transform; use serde_json::{Value, json}; +pub use color_picker::open as open_color_picker; pub use tools::open_tool_dialog; use crate::state::Dialog; @@ -127,6 +129,7 @@ registry! { Shortcuts: ["shortcuts"] => DialogSpec::window(crate::shortcut_editor::show, |app, _| crate::shortcut_editor::confirm(app)), Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)), FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)), + ColorPicker: [color_picker::KIND] => color_picker::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7c61cf88..c9010356 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -160,6 +160,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("help.about", "About VectorCraft", "", "{}"), ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), + ( + "ui.colorPicker", + "Color Picker…", + "", + "{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -482,6 +488,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::unsaved::close_all(app, "closeAll"), "app.quit" => crate::unsaved::close_all(app, "quit"), + "ui.colorPicker" => crate::dialogs::open_color_picker(app, p), _ => return None, }; Some(r) diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 27d71944..7900673c 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -140,6 +140,11 @@ pub fn spectrum_at(mode: Mode, x: f32, y: f32) -> Color { } } +/// The hex field text of a colour (`E67828`). +pub fn hex_digits(c: &Color) -> String { + c.to_hex().trim_start_matches('#').to_uppercase() +} + /// Parse a hex field (`E67828`, `#e67828`, `fff`). pub fn parse_hex(s: &str) -> Option { let s = s.trim().trim_start_matches('#'); @@ -149,6 +154,37 @@ pub fn parse_hex(s: &str) -> Option { Color::from_hex(s) } +/// The out-of-web-colour warning: its icon and tooltip. +pub(crate) const WEB_WARNING: (&str, &str) = ("dc-cube", "Out of Web Color Warning"); +/// The out-of-gamut warning: its icon and tooltip. +pub(crate) const GAMUT_WARNING: (&str, &str) = ("dc-gamut", "Out of Gamut Warning"); + +/// A warning icon with the corrected colour beside it. Returns true when the colour is clicked. +pub(crate) fn warning_chip(ui: &mut Ui, (icon, warning): (&str, &str), fix: &Color) -> bool { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + icons::icon(ui, icon, 16.0, t.icon).on_hover_text(warning); + let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); + widgets::swatch_tile(ui, r, &Paint::solid(*fix), false, resp.hovered()); + resp.on_hover_text(format!("{warning}: click to correct to the closest color ({})", fix.to_hex())).clicked() + }) + .inner +} + +/// The printable colour closest to `c` (its working-CMYK reproduction, back in RGB) when `c` is out +/// of the CMYK gamut, through `color.convert`. +pub(crate) fn in_gamut(app: &mut VectorcraftApp, c: &Color) -> Option { + let r = app.run("color.convert", json!({"color": color_json(c), "to": "cmyk"})).ok()?; + if !r["outOfGamut"].as_bool()? { + return None; + } + let v = |r: &serde_json::Value, i: usize| r["values"][i].as_f64(); + let ink = json!({"c": v(&r, 0)?, "m": v(&r, 1)?, "y": v(&r, 2)?, "k": v(&r, 3)?}); + let rgb = app.run("color.convert", json!({"color": ink, "to": "rgb"})).ok()?; + Some(Color::rgb(v(&rgb, 0)? as f32, v(&rgb, 1)? as f32, v(&rgb, 2)? as f32)) +} + fn to32(c: &Color) -> Color32 { super::c32(c) } @@ -224,16 +260,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { && !is_web_safe(&c) { ui.add_space(8.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 3.0; - icons::icon(ui, "dc-cube", 16.0, t.icon).on_hover_text("Out of Web Color Warning"); - let ws = web_safe(&c); - let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); - widgets::swatch_tile(ui, r, &Paint::solid(ws), false, resp.hovered()); - if resp.on_hover_text(format!("Click to correct to the closest web color ({})", ws.to_hex())).clicked() { - new = Some((ws, Live::Released, components(mode, &ws))); - } - }); + let ws = web_safe(&c); + if warning_chip(ui, WEB_WARNING, &ws) { + new = Some((ws, Live::Released, components(mode, &ws))); + } } }); // Sliders. @@ -292,33 +322,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.spacing_mut().item_spacing.x = 6.0; if matches!(mode, Mode::Rgb | Mode::WebSafe | Mode::Hsb) { ui.add_space((ui.available_width() - 96.0).max(4.0)); - ui.label(egui::RichText::new("#").size(15.0).color(t.text)); - let hex = color.map(|c| c.to_hex().trim_start_matches('#').to_uppercase()).unwrap_or_default(); - let id = ui.id().with("hex"); - let editing = ui.memory(|m| m.has_focus(id)); - let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.clone()) } else { hex.clone() }; - let resp = egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(2) - .inner_margin(egui::Margin::symmetric(6, 4)) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .desired_width(62.0) - .char_limit(7) - .font(egui::FontId::proportional(12.5)) - .text_color(t.text_strong), - ) - }) - .inner; - ui.data_mut(|d| d.insert_temp(id, buf.clone())); - if resp.lost_focus() - && buf != hex - && let Some(c) = parse_hex(&buf) - { + let hex = color.map(|c| hex_digits(&c)).unwrap_or_default(); + if let Some(c) = widgets::hex_field(ui, "hex", &hex).as_deref().and_then(parse_hex) { new = Some((c, Live::Released, components(mode, &c))); } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 4a70a1c8..e86dd4e2 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -188,16 +188,19 @@ pub(crate) fn active_paint(app: &VectorcraftApp) -> Paint { /// Draw the Fill/Stroke proxy and handle its clicks through commands. pub(crate) fn proxy(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { let (f, s) = current_paints(app); - let (a, b, swap, def) = crate::widgets::fill_stroke_proxy(ui, &f, &s, app.session.fill_active, size); - if (a && !app.session.fill_active) || (b && app.session.fill_active) { + let c = crate::widgets::fill_stroke_proxy(ui, &f, &s, app.session.fill_active, size); + if (c.fill && !app.session.fill_active) || (c.stroke && app.session.fill_active) { app.run("paint.toggleActive", json!({})).ok(); } - if swap { + if c.swap { app.run("paint.swap", json!({})).ok(); } - if def { + if c.default { app.run("paint.default", json!({})).ok(); } + if let Some(stroke) = c.pick { + app.run("ui.colorPicker", json!({ "stroke": stroke })).ok(); + } } /// Run `cmd` live: while dragging, preview on top of an interaction snapshot; on release, commit @@ -253,6 +256,19 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// A colour from command JSON: `"#rrggbb"`, `[r,g,b]`, `{c,m,y,k}`, `{gray}` (inverse of +/// [`color_json`]) or a serialized colour. +pub(crate) fn color_from_json(v: &Value) -> Option { + let f = |v: Option<&Value>| v.and_then(Value::as_f64).map(|x| x as f32); + match v { + Value::String(s) => color::parse_hex(s), + Value::Array(a) if a.len() == 3 => Some(Color::rgb(f(a.first())?, f(a.get(1))?, f(a.get(2))?)), + Value::Object(o) if o.contains_key("gray") => Some(Color::gray(f(o.get("gray"))?)), + Value::Object(o) if !o.contains_key("model") => Some(Color::cmyk(f(o.get("c"))?, f(o.get("m"))?, f(o.get("y"))?, f(o.get("k"))?)), + _ => serde_json::from_value(v.clone()).ok(), + } +} + /// A colour as command JSON, keeping its model. pub(crate) fn color_json(c: &Color) -> Value { match *c { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index abf79623..05139999 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -279,7 +279,9 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().rect_filled(r.shrink(7.0), 0.0, t.panel); } ui.painter().rect_stroke(r.shrink(2.0), 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside); - if resp.clicked() { + if resp.double_clicked() { + app.run("ui.colorPicker", json!({ "stroke": !is_fill })).ok(); + } else if resp.clicked() { app.session.fill_active = is_fill; app.ui.open_panel = Some("swatches".into()); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 514f9eb5..0f9288db 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -222,8 +222,19 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient } } -/// 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) { +/// What the user did on a [`fill_stroke_proxy`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ProxyClicks { + pub fill: bool, + pub stroke: bool, + pub swap: bool, + pub default: bool, + /// Double-clicked a square: open the Color Picker for it (`true` = the stroke). + pub pick: Option, +} + +/// The Fill/Stroke proxy pair (two overlapping squares). +pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: bool, size: f32) -> ProxyClicks { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); let s = size * 0.62; @@ -264,12 +275,20 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: ui.painter().rect_stroke(b, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside); ui.painter().rect_filled(a, 0.0, Color32::WHITE); ui.painter().rect_stroke(a, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); - ( - fill_resp.on_hover_text("Fill (X)").clicked(), - stroke_resp.on_hover_text("Stroke (X)").clicked(), - swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(), - def.on_hover_text("Default Fill and Stroke (D)").clicked(), - ) + let pick = if fill_resp.double_clicked() { + Some(false) + } else if stroke_resp.double_clicked() { + Some(true) + } else { + None + }; + ProxyClicks { + fill: fill_resp.on_hover_text("Fill (X), double-click for the Color Picker").clicked(), + stroke: stroke_resp.on_hover_text("Stroke (X), double-click for the Color Picker").clicked(), + swap: swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(), + default: def.on_hover_text("Default Fill and Stroke (D)").clicked(), + pick, + } } /// A compact dropdown. Returns the chosen index. @@ -519,12 +538,7 @@ pub fn color_slider( let (rect, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); let bar = Rect::from_min_size(pos2(rect.left() + 4.0, rect.top() + 4.0), vec2(width - 8.0, 7.0)); let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag()); - let n = (bar.width() / 2.0).ceil().max(2.0) as usize; - let seg = bar.width() / n as f32; - for i in 0..n { - let r = Rect::from_min_size(pos2(bar.left() + i as f32 * seg, bar.top()), vec2(seg + 0.6, bar.height())); - ui.painter().rect_filled(r, 0.0, track((i as f32 + 0.5) / n as f32)); - } + gradient_strip(ui, bar, (bar.width() / 2.0).ceil().max(2.0) as usize, false, track); ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); let v = value.clamp(0.0, 1.0); let x = bar.left() + v * bar.width(); @@ -532,15 +546,150 @@ pub fn color_slider( let thumb = vec![tip, pos2(x + 5.5, tip.y + 6.0), pos2(x + 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 6.0)]; let fill = if resp.dragged() || resp.hovered() { Color32::WHITE } else { t.icon }; ui.painter().add(egui::Shape::convex_polygon(thumb, fill, Stroke::new(1.0, t.border))); - let mut out = None; - let mut phase = Live::Idle; - if (resp.dragged() || resp.clicked() || resp.drag_stopped()) - && let Some(p) = resp.interact_pointer_pos() - { - out = Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)); - phase = if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released }; + match pointer_phase(&resp) { + Some((p, phase)) => (Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)), phase), + None => (None, Live::Idle), } - (out, phase) +} + +/// The pointer of a click or drag on `resp` and its phase (dragging, or released on click/drop). +fn pointer_phase(resp: &Response) -> Option<(Pos2, Live)> { + if !(resp.dragged() || resp.clicked() || resp.drag_stopped()) { + return None; + } + let p = resp.interact_pointer_pos()?; + Some((p, if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released })) +} + +/// A gradient strip into `rect`: `n` bands along x (or up y when `vertical`), each filled with +/// `track(t)` at its centre. +fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn(f32) -> Color32) { + let len = if vertical { rect.height() } else { rect.width() }; + let seg = len / n as f32; + for i in 0..n { + let r = if vertical { + Rect::from_min_size(pos2(rect.left(), rect.bottom() - (i + 1) as f32 * seg), vec2(rect.width(), seg + 0.6)) + } else { + Rect::from_min_size(pos2(rect.left() + i as f32 * seg, rect.top()), vec2(seg + 0.6, rect.height())) + }; + ui.painter().rect_filled(r, 0.0, track((i as f32 + 0.5) / n as f32)); + } +} + +/// The Color Picker's 2D colour field: `color_at(x, y)` gives the colour at a point (0..1 each, y +/// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase. +pub fn color_field( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + size: Vec2, + pos: (f32, f32), + color_at: &dyn Fn(f32, f32) -> Color32, +) -> (Option<(f32, f32)>, Live) { + const CELLS: u32 = 32; + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag()); + let mut mesh = egui::Mesh::default(); + let row = CELLS + 1; + mesh.reserve_vertices((row * row) as usize); + mesh.reserve_triangles((CELLS * CELLS * 2) as usize); + for j in 0..row { + for i in 0..row { + let (x, y) = (i as f32 / CELLS as f32, j as f32 / CELLS as f32); + mesh.colored_vertex(pos2(rect.left() + x * rect.width(), rect.top() + y * rect.height()), color_at(x, y)); + } + } + for j in 0..CELLS { + for i in 0..CELLS { + let a = j * row + i; + mesh.add_triangle(a, a + 1, a + row); + mesh.add_triangle(a + 1, a + row + 1, a + row); + } + } + let painter = ui.painter_at(rect.expand(6.0)); + painter.add(egui::Shape::mesh(mesh)); + painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height()); + painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); + painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); + match pointer_phase(&resp) { + Some((p, phase)) => { + let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); + (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase) + } + None => (None, Live::Idle), + } +} + +/// The Color Picker's vertical channel slider: `track(t)` gives the colour at 0..1 (bottom to top) +/// and arrows on both sides mark `value`. Returns the new value and the phase. +pub fn channel_slider( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + value: f32, + size: Vec2, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag()); + let bar = rect.shrink2(vec2(6.0, 0.0)); + gradient_strip(ui, bar, (bar.height() / 2.0).ceil().max(2.0) as usize, true, track); + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + let y = bar.bottom() - value.clamp(0.0, 1.0) * bar.height(); + let fill = if resp.dragged() || resp.hovered() { t.text_strong } else { t.icon }; + for (x, d) in [(rect.left(), 1.0), (rect.right(), -1.0)] { + let tip = pos2(x + d * 5.0, y); + ui.painter().add(egui::Shape::convex_polygon(vec![tip, pos2(x, y + 4.0 * d), pos2(x, y - 4.0 * d)], fill, Stroke::NONE)); + } + match pointer_phase(&resp) { + Some((p, phase)) => (Some(((bar.bottom() - p.y) / bar.height()).clamp(0.0, 1.0)), phase), + None => (None, Live::Idle), + } +} + +/// A radio button (a ring, dotted when selected) with a label. Returns true when clicked. +pub fn radio(ui: &mut Ui, label: &str, selected: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(12.5), t.text); + let (rect, resp) = ui.allocate_exact_size(vec2(18.0 + galley.size().x, 20.0f32.max(galley.size().y)), Sense::click()); + let c = pos2(rect.left() + 6.5, rect.center().y); + ui.painter().circle(c, 6.0, t.input, Stroke::new(1.0, if resp.hovered() { t.text } else { t.button_border })); + if selected { + ui.painter().circle_filled(c, 3.5, t.accent_strong); + } + ui.painter().galley(pos2(rect.left() + 18.0, rect.center().y - galley.size().y / 2.0), galley, t.text); + resp.clicked() +} + +/// A hex colour field (`E67828`, no `#`) after a `#` label. Returns the text on commit when it +/// changed. +pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, hex: &str) -> Option { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new("#").size(15.0).color(t.text)); + let id = ui.id().with(id); + let editing = ui.memory(|m| m.has_focus(id)); + let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; + let resp = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(2) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(id) + .frame(egui::Frame::NONE) + .desired_width(62.0) + .char_limit(7) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) + .inner; + let commit = resp.lost_focus() && buf != hex; + ui.data_mut(|d| d.insert_temp(id, buf.clone())); + commit.then_some(buf) } /// One swatch tile (Illustrator: 1 px dark frame, white inset on hover/selection). diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9f7d6c78..eb69b6c8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -19,3 +19,5 @@ | `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) | | `ui.resize` / `ui.focus` | | | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | + +The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`. From 6d2dc7cbbccb0399080034eaae7f7c5fae44d22c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 18:01:39 +0700 Subject: [PATCH 3/6] =?UTF-8?q?M3.28:=20Color=20panel=20behaviour=20?= =?UTF-8?q?=E2=80=94=20the=20mode=20follows=20the=20colour's=20own=20model?= =?UTF-8?q?=20(the=20last=20mode=20picked=20returns=20for=20colours=20in?= =?UTF-8?q?=20its=20model),=20Alt-clicking=20the=20spectrum,=20None/Black/?= =?UTF-8?q?White,=20swatches,=20recent=20colours=20or=20Color=20Guide=20ch?= =?UTF-8?q?ips=20paints=20the=20inactive=20proxy,=20Hide=20Options=20leave?= =?UTF-8?q?s=20the=20proxy=20and=20spectrum,=20RGB/HSB=20show=20the=20out-?= =?UTF-8?q?of-gamut=20warning=20(click=20to=20correct),=20Shift-clicking?= =?UTF-8?q?=20the=20spectrum=20cycles=20modes,=20Shift-dragging=20a=20slid?= =?UTF-8?q?er=20moves=20the=20others=20in=20tandem,=20and=20a=20mixed=20se?= =?UTF-8?q?lection=20draws=20"=3F"=20proxies=20(paint.proxies=20reports=20?= =?UTF-8?q?fillMixed/strokeMixed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/paint.rs | 58 ++- crates/engine/src/tests_paintproxy.rs | 25 ++ crates/ui-egui/src/dialogs/color_picker.rs | 25 +- crates/ui-egui/src/panels/color.rs | 458 ++++++++++++++++----- crates/ui-egui/src/panels/color_guide.rs | 4 +- crates/ui-egui/src/panels/mod.rs | 47 ++- crates/ui-egui/src/panels/swatches.rs | 25 +- crates/ui-egui/src/widgets.rs | 69 ++-- docs/mcp.md | 2 + 9 files changed, 542 insertions(+), 171 deletions(-) diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index cb5ba895..0a12035a 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -5,7 +5,7 @@ use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind}; use super::*; -use crate::EngineError; +use crate::{DocState, EngineError}; pub fn specs() -> Vec { vec![ @@ -118,6 +118,15 @@ pub fn proxy_specs() -> Vec { })) } ), + cmd!( + query "paint.proxies", + "Fill and Stroke", + [], + None, + "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the first selected object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' fills (strokes) differ, shown as a \"?\" proxy}", + always, + proxies + ), ] } @@ -161,6 +170,53 @@ pub(crate) fn proxy_paint(n: &Node, stroke: bool) -> Paint { } } +/// 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, + } +} + +impl DocState { + /// Whether the selected objects' fills and strokes differ as the proxies show them (a "?" + /// proxy). 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.len() < 2 { + return (false, false); + } + let ids: std::collections::HashSet = self.selection.objects.iter().copied().collect(); + let mut first: Option<(Paint, Paint)> = None; + let mut mixed = (false, false); + self.doc.walk(|n| { + if (mixed.0 && mixed.1) || !ids.contains(&n.id) { + return; + } + let (f, s) = (proxy_paint(n, false), proxy_paint(n, true)); + match &first { + Some((f0, s0)) => { + mixed.0 |= !same_in_proxy(f0, &f); + mixed.1 |= !same_in_proxy(s0, &s); + } + None => first = Some((f, s)), + } + }); + mixed + } +} + +fn proxies(s: &mut Session, _: &Value) -> Result { + let st = s.doc().ok(); + let (fill, stroke) = match st.and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))) { + Some(n) => (proxy_paint(n, false), proxy_paint(n, true)), + None => (s.paint.fill.clone(), s.paint.stroke.clone()), + }; + let (fill_mixed, stroke_mixed) = st.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) diff --git a/crates/engine/src/tests_paintproxy.rs b/crates/engine/src/tests_paintproxy.rs index fc54544b..8a9d7e73 100644 --- a/crates/engine/src/tests_paintproxy.rs +++ b/crates/engine/src/tests_paintproxy.rs @@ -170,3 +170,28 @@ fn focus_false_keeps_the_active_proxy() { assert!(s.fill_active); assert_eq!(node(&s, id).appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000"); } + +#[test] +fn a_mixed_selection_shows_a_question_mark_proxy() { + let mut s = session(); + let a = rect(&mut s); + let b = rect(&mut s); + let q = |s: &mut Session| s.execute("paint.proxies", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let r = q(&mut s); + assert_eq!((r["fillMixed"].as_bool(), r["strokeMixed"].as_bool()), (Some(false), Some(false))); + assert_eq!(r["fillActive"], true); + s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": [b.0]})).unwrap(); + let r = q(&mut s); + assert_eq!((r["fillMixed"].as_bool(), r["strokeMixed"].as_bool()), (Some(true), Some(false))); + assert_eq!(r["fill"]["color"], json!(Color::rgb(1.0, 0.0, 0.0)), "the first selected object's fill"); + // One selected object, or the same gradient fitted to different objects, is never mixed. + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(q(&mut s)["fillMixed"], false); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("paint.setFill", &json!({"gradient": {"stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]}})).unwrap(); + assert_eq!(s.doc().unwrap().proxy_mixed(), (false, false)); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(q(&mut s)["fillMixed"], false); +} diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index eef6ec4d..00f81ff3 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -14,7 +14,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::ColorMode; use super::DialogSpec; -use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, hex_digits, in_gamut, is_web_safe, parse_hex, warning_chip, web_safe}; +use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe}; use crate::panels::{c32, color_from_json, color_json}; use crate::state::Dialog; use crate::theme::Tokens; @@ -177,24 +177,13 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { Ok(Value::Null) } -/// The cached out-of-gamut correction of `c` (`color.convert` runs once per colour). -fn gamut_fix(app: &mut VectorcraftApp, d: &mut Dialog, c: &Color) -> Option { - let key = color_json(c); - if d.fields.get("__gamutOf") != Some(&key) { - let fix = in_gamut(app, c).map_or(Value::Null, |f| color_json(&f)); - d.fields.insert("__gamut".into(), fix); - d.fields.insert("__gamutOf".into(), key); - } - d.fields.get("__gamut").and_then(color_from_json) -} - fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { let channel = Channel::parse(&d.str("channel")).unwrap_or_default(); let web = d.bool("webOnly"); let swatches = d.bool("swatches"); let p = picked(d); let original = d.fields.get("original").and_then(color_from_json).unwrap_or(p.color); - let gamut = gamut_fix(app, d, &p.color); + let gamut = gamut_fix(app, ui.ctx(), "picker-gamut", &p.color); let mut pick: Option = None; let mut new_channel = None; let mut toggle_swatches = false; @@ -389,6 +378,7 @@ mod tests { use vectorcraft_engine::Session; use super::*; + use crate::panels::color::in_gamut; fn app() -> VectorcraftApp { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -455,10 +445,11 @@ mod tests { assert!(!fix.out_of_gamut(), "{fix:?}"); assert!(matches!(fix, Color::Rgb { .. })); assert_eq!(in_gamut(&mut app, &Color::rgb8(128, 128, 128)), None); - // The dialog offers it and clicking it would pick it. - app.run("ui.colorPicker", json!({"color": "#0000ff"})).unwrap(); - let mut d = app.ui.dialog.clone().unwrap(); - assert_eq!(gamut_fix(&mut app, &mut d, &blue), Some(fix)); + // The dialog and the Color panel ask through a cache. + let ctx = egui::Context::default(); + assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), Some(fix)); + assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), Some(fix)); + assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None); } #[test] diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 7900673c..44605737 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -1,12 +1,17 @@ //! Color panel: Grayscale / RGB / HSB / CMYK / Web Safe RGB sliders with colour-gradient tracks, //! value fields, hex field, None/Black/White chips, spectrum ramp and the Fill/Stroke proxy. //! When the active paint is a gradient, the sliders edit the Gradient panel's selected stop. +//! +//! The mode follows the colour's own model (the last mode picked comes back for colours in its +//! model). Alt-clicking the spectrum or a chip paints the inactive proxy; Shift-clicking the +//! spectrum cycles the modes; Shift-dragging a slider moves the others in tandem; RGB and HSB show +//! the out-of-gamut warning; Hide Options leaves just the proxy and the spectrum. -use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; use vectorcraft_color::{Color, Paint}; -use super::{active_paint, color_json, live_run, paint_target, pstate, set_pstate}; +use super::{active_paint, color_json, live_run, pstate, set_pstate}; use crate::theme::Tokens; use crate::widgets::{self, Live, menu_item}; use crate::{VectorcraftApp, icons}; @@ -108,8 +113,8 @@ pub fn is_web_safe(c: &Color) -> bool { web_safe(c).to_hex() == c.to_hex() } -/// Track colour of slider `i` at position `t` (0..1) with the other components fixed — -/// Illustrator's dynamic colour sliders. +/// Track colour of slider `i` at position `t` (0..1) with the other components fixed (dynamic +/// colour sliders). pub fn track_color(mode: Mode, comps: &[f32], i: usize, t: f32) -> Color { let mut v = comps.to_vec(); if i < v.len() { @@ -185,6 +190,70 @@ pub(crate) fn in_gamut(app: &mut VectorcraftApp, c: &Color) -> Option { Some(Color::rgb(v(&rgb, 0)? as f32, v(&rgb, 1)? as f32, v(&rgb, 2)? as f32)) } +/// [`in_gamut`], cached under `key` for the last colour asked (panels ask every frame). +pub(crate) fn gamut_fix(app: &mut VectorcraftApp, ctx: &egui::Context, key: &str, c: &Color) -> Option { + match pstate::)>>(ctx, key) { + Some((k, fix)) if k == *c => fix, + _ => { + let fix = in_gamut(app, c); + set_pstate(ctx, key, Some((*c, fix))); + fix + } + } +} + +/// The panel mode for `color`: the one picked last (`stored`) when it shows the colour's own +/// model (HSB and Web Safe RGB show RGB), else the colour's model. +pub fn display_mode(stored: Option, color: Option<&Color>) -> Mode { + match color { + Some(c) => stored.filter(|m| m.holds(c)).unwrap_or_else(|| Mode::of(c)), + None => stored.unwrap_or_default(), + } +} + +impl Mode { + /// The colour model the mode edits. + fn model(self) -> Mode { + match self { + Mode::Hsb | Mode::WebSafe => Mode::Rgb, + m => m, + } + } + /// Does the mode show `c` in its own model? + pub fn holds(self, c: &Color) -> bool { + self.model() == Mode::of(c) + } + /// The next mode (Shift-click on the spectrum). + pub fn next(self) -> Mode { + let i = Mode::ALL.iter().position(|(m, _)| *m == self).unwrap_or(0); + Mode::ALL[(i + 1) % Mode::ALL.len()].0 + } + /// Can Shift-drag move the sliders in tandem? (Not HSB, and Grayscale has one slider.) + fn tandem(self) -> bool { + matches!(self, Mode::Rgb | Mode::WebSafe | Mode::Cmyk) + } +} + +/// `c` in `mode`'s model: picking CMYK or Grayscale converts the colour, Web Safe RGB snaps it. +pub fn convert_to(mode: Mode, c: &Color) -> Color { + match mode { + Mode::WebSafe => web_safe(c), + m if m.holds(c) => *c, + m => from_components(m.model(), &components(m.model(), c)), + } +} + +/// Shift-drag of slider `i` to `v`: the other sliders move with it keeping their ratios, stopping +/// where one reaches its maximum. With slider `i` at 0 the others move by the same amount. +pub fn tandem(mode: Mode, comps: &[f32], i: usize, v: f32) -> Vec { + let old = comps.get(i).copied().unwrap_or(0.0); + if old <= 0.0 { + return comps.iter().enumerate().map(|(j, c)| (c + v - old).clamp(0.0, mode.max(j))).collect(); + } + let f = comps.iter().enumerate().filter(|(_, c)| **c > 0.0).fold((v / old).max(0.0), |f, (j, c)| f.min(mode.max(j) / c)); + comps.iter().map(|c| c * f).collect() +} + fn to32(c: &Color) -> Color32 { super::c32(c) } @@ -195,7 +264,20 @@ enum Target { Stop { paint: Paint, index: usize, color: Color }, } +impl Target { + fn color(&self) -> Option { + match self { + Target::Paint(c) => *c, + Target::Stop { color, .. } => Some(*color), + } + } +} + +/// The active proxy's colour or gradient stop; no colour for None, patterns and a "?" proxy. fn target(app: &VectorcraftApp, ui: &Ui) -> Target { + if super::active_mixed(app, ui.ctx()) { + return Target::Paint(None); + } let p = active_paint(app); match &p { Paint::Gradient(g) => { @@ -208,35 +290,81 @@ fn target(app: &VectorcraftApp, ui: &Ui) -> Target { } } -fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live) { +/// Apply `c` to the target, or with `behind` (an Alt-click) to the inactive proxy as a solid +/// colour, keeping the active proxy in front. +fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: bool) { match tgt { - Target::Paint(_) => { - let cmd = paint_target(app); - live_run(app, "Color", cmd, json!({"color": color_json(&c)}), phase); - } - Target::Stop { paint, index, .. } => { - if let Paint::Gradient(g) = paint { - let mut stops = g.gradient.stops.clone(); - if let Some(s) = stops.get_mut(*index) { - s.color = c; - } - let params = json!({"stroke": !app.session.fill_active, "stops": super::gradient::stops_json(&stops)}); - live_run(app, "Gradient", "paint.editGradient", params, phase); + Target::Stop { paint: Paint::Gradient(g), index, .. } if !behind => { + let mut stops = g.gradient.stops.clone(); + if let Some(s) = stops.get_mut(*index) { + s.color = c; } + let params = json!({"stroke": !app.session.fill_active, "stops": super::gradient::stops_json(&stops)}); + live_run(app, "Gradient", "paint.editGradient", params, phase); + } + _ => { + let cmd = super::proxy_cmd(app, behind); + live_run(app, "Color", cmd, json!({"color": color_json(&c), "focus": !behind}), phase); } } } +/// Switch the panel to `mode`, converting the colour to its model. +fn set_mode(app: &mut VectorcraftApp, ctx: &egui::Context, tgt: &Target, mode: Mode) { + set_pstate(ctx, "color-mode", Some(mode)); + if let Some(c) = tgt.color() { + let conv = convert_to(mode, &c); + if conv != c { + apply(app, tgt, conv, Live::Released, false); + } + } +} + +/// What happened on the spectrum ramp. +enum SpectrumHit { + Pick(Color, Live), + /// Shift-click: the next colour mode. + Cycle, +} + +/// The spectrum ramp: hue across, white to full colour to black down (a grey ramp in Grayscale). +fn spectrum(ui: &mut Ui, mode: Mode, size: egui::Vec2) -> Option { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(r, ui.id().with("color-spectrum"), Sense::click_and_drag()); + ui.painter().add(widgets::color_mesh(r, (72, 12), &|x, y| to32(&spectrum_at(mode, x, y)))); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + if let Some(p) = resp.hover_pos() { + ui.painter().rect_stroke(Rect::from_center_size(p, vec2(5.0, 5.0)), 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle); + } + if resp.clicked() && ui.input(|i| i.modifiers.shift) { + return Some(SpectrumHit::Cycle); + } + let (p, phase) = widgets::pointer_phase(&resp)?; + Some(SpectrumHit::Pick(spectrum_at(mode, (p.x - r.left()) / r.width(), (p.y - r.top()) / r.height()), phase)) +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let tgt = target(app, ui); - let color = match &tgt { - Target::Paint(c) => *c, - Target::Stop { color, .. } => Some(*color), - }; - let stored: Option = pstate(ui.ctx(), "color-mode"); - let mode = stored.unwrap_or_else(|| color.as_ref().map(Mode::of).unwrap_or_default()); - let show_options: bool = !pstate::(ui.ctx(), "color-hide-options"); + let color = tgt.color(); + let mode = display_mode(pstate(ui.ctx(), "color-mode"), color.as_ref()); + let alt = super::alt_held(ui); + // Hide Options: just the proxy and the spectrum. + if pstate::(ui.ctx(), "color-hide-options") { + let hit = ui + .horizontal(|ui| { + super::proxy(app, ui, 40.0); + spectrum(ui, mode, vec2(ui.available_width(), 40.0)) + }) + .inner; + match hit { + Some(SpectrumHit::Pick(c, phase)) => apply(app, &tgt, c, phase, alt), + Some(SpectrumHit::Cycle) => set_mode(app, ui.ctx(), &tgt, mode.next()), + None => {} + } + return; + } // Keep the displayed components while the colour is unchanged (hue survives S = 0 etc.). let key = color.map(|c| (c.to_hex(), mode as u8)); let comps: Vec = match (color, pstate::)>>(ui.ctx(), "color-comps")) { @@ -244,39 +372,39 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { (Some(c), _) => components(mode, &c), (None, _) => vec![0.0; mode.labels().len()], }; - if show_options { - super::recent_colors_row(app, ui); - widgets::divider(ui); - } + super::recent_colors_row(app, ui); + widgets::divider(ui); + // An edit of the active colour with its displayed components (`new`), or a colour clicked with + // Alt held for the inactive proxy (`behind`). let mut new: Option<(Color, Live, Vec)> = None; + let mut behind: Option<(Color, Live)> = None; + let gamut = color.filter(|_| matches!(mode, Mode::Rgb | Mode::Hsb)).and_then(|c| gamut_fix(app, ui.ctx(), "color-gamut", &c)); ui.horizontal(|ui| { - // Left column: proxy + web-safe warning. + // Left column: proxy, out-of-gamut and out-of-web warnings. ui.vertical(|ui| { ui.set_width(44.0); super::proxy(app, ui, 40.0); - if let Some(c) = color - && mode != Mode::WebSafe - && mode != Mode::Grayscale - && !is_web_safe(&c) - { - ui.add_space(8.0); - let ws = web_safe(&c); - if warning_chip(ui, WEB_WARNING, &ws) { - new = Some((ws, Live::Released, components(mode, &ws))); + let web = color.filter(|c| !matches!(mode, Mode::WebSafe | Mode::Grayscale) && !is_web_safe(c)).map(|c| web_safe(&c)); + for (warning, fix) in [(GAMUT_WARNING, gamut), (WEB_WARNING, web)] { + if let Some(fix) = fix { + ui.add_space(6.0); + if warning_chip(ui, warning, &fix) { + new = Some((fix, Live::Released, components(mode, &fix))); + } } } }); - // Sliders. + // Sliders (Shift-drag moves them in tandem). ui.vertical(|ui| { - let labels = mode.labels(); + let shift = ui.input(|i| i.modifiers.shift); let field_w = 48.0; let slider_w = (ui.available_width() - field_w - 24.0).max(60.0); - for (i, lbl) in labels.iter().enumerate() { + let enabled = color.is_some(); + for (i, lbl) in mode.labels().iter().enumerate() { ui.horizontal(|ui| { ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(*lbl).size(12.5).color(t.text))); let max = mode.max(i); let v = comps.get(i).copied().unwrap_or(0.0); - let enabled = color.is_some(); let track = |x: f32| to32(&track_color(mode, &comps, i, x)); let grey = |_x: f32| t.input; let (nv, phase) = if enabled { @@ -287,12 +415,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(nv) = nv && enabled { - let mut c2 = comps.clone(); - c2[i] = if mode == Mode::WebSafe { ((nv * 5.0).round() / 5.0) * max } else { (nv * max).round() }; + let nv = if mode == Mode::WebSafe { ((nv * 5.0).round() / 5.0) * max } else { (nv * max).round() }; + let c2 = if shift && mode.tandem() { + tandem(mode, &comps, i, nv) + } else { + let mut c2 = comps.clone(); + c2[i] = nv; + c2 + }; new = Some((from_components(mode, &c2), phase, c2)); } - let dec = 0; - if let Some(fv) = widgets::plain_field(ui, ("color-field", i), v as f64, mode.suffix(i), dec, field_w) + if let Some(fv) = widgets::plain_field(ui, ("color-field", i), v as f64, mode.suffix(i), 0, field_w) && enabled { let mut c2 = comps.clone(); @@ -304,6 +437,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); }); ui.add_space(4.0); + // A clicked chip or spectrum colour: the active colour, or with Alt the inactive proxy's. + let mut pick = |c: Color, phase: Live, new: &mut Option<(Color, Live, Vec)>| { + if alt { + behind = Some((c, phase)); + } else { + *new = Some((c, phase, components(mode, &c))); + } + }; // None / Black / White chips and the hex field. ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; @@ -312,10 +453,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::swatch_tile(ui, r, &p, false, resp.hovered()); if resp.on_hover_text(tip).clicked() { match p.color() { - Some(c) => new = Some((c, Live::Released, components(mode, &c))), - None => { - app.run(paint_target(app), json!({"none": true})).ok(); - } + Some(c) => pick(c, Live::Released, &mut new), + None => super::apply_click(app, ui, json!({"none": true})), } } } @@ -329,46 +468,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } }); ui.add_space(4.0); - // Spectrum ramp. - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click_and_drag()); - let cols = 72; - let rows = 12; - let (cw, rh) = (r.width() / cols as f32, r.height() / rows as f32); - let mut mesh = egui::Mesh::default(); - for j in 0..=rows { - for i in 0..=cols { - let c = spectrum_at(mode, i as f32 / cols as f32, j as f32 / rows as f32); - mesh.colored_vertex(pos2(r.left() + i as f32 * cw, r.top() + j as f32 * rh), to32(&c)); - } + match spectrum(ui, mode, vec2(ui.available_width(), 46.0)) { + Some(SpectrumHit::Pick(c, phase)) => pick(c, phase, &mut new), + Some(SpectrumHit::Cycle) => set_mode(app, ui.ctx(), &tgt, mode.next()), + None => {} } - for j in 0..rows { - for i in 0..cols { - let a = (j * (cols + 1) + i) as u32; - let b = a + 1; - let c = a + (cols + 1) as u32; - let d = c + 1; - mesh.add_triangle(a, b, c); - mesh.add_triangle(b, d, c); - } - } - ui.painter().add(egui::Shape::mesh(mesh)); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); - if (resp.dragged() || resp.clicked() || resp.drag_stopped()) - && let Some(p) = resp.interact_pointer_pos() - { - let c = spectrum_at(mode, (p.x - r.left()) / r.width(), (p.y - r.top()) / r.height()); - let phase = if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released }; - new = Some((c, phase, components(mode, &c))); - } - if resp.hovered() - && let Some(p) = resp.hover_pos() - { - ui.painter().rect_stroke(Rect::from_center_size(p, vec2(5.0, 5.0)), 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle); + if let Some((c, phase)) = behind { + apply(app, &tgt, c, phase, true); } 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, &tgt, c, phase); + apply(app, &tgt, c, phase, false); } } @@ -379,39 +490,27 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.separator(); let tgt = target(app, ui); - let color = match &tgt { - Target::Paint(c) => *c, - Target::Stop { color, .. } => Some(*color), - }; - let cur: Option = pstate(ui.ctx(), "color-mode"); - let cur = cur.unwrap_or_else(|| color.as_ref().map(Mode::of).unwrap_or_default()); + let color = tgt.color(); + let cur = display_mode(pstate(ui.ctx(), "color-mode"), color.as_ref()); for (m, label) in Mode::ALL { if menu_item(ui, label, true, m == cur) { - set_pstate(ui.ctx(), "color-mode", Some(m)); - // Converting the colour's model follows Illustrator (picking CMYK converts the colour). - 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, &tgt, conv, Live::Released); - } - } + set_mode(app, ui.ctx(), &tgt, m); } } ui.separator(); - // Solid colours go through the proxy commands (each selected object keeps its colour model); - // a gradient stop is recoloured in place. + // Solid colours (and a "?" proxy) go through the proxy commands (each selected object keeps + // its colour model); a gradient stop is recoloured in place. + let recolor = color.is_some() || super::active_mixed(app, ui.ctx()); 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(_) => { + if menu_item(ui, label, recolor, false) { + match (&tgt, color) { + (Target::Stop { .. }, Some(c)) => apply(app, &tgt, f(&c), Live::Released, false), + _ => { app.run(cmd, json!({})).ok(); } - Target::Stop { .. } => apply(app, &tgt, f(&c), Live::Released), } } } @@ -431,6 +530,151 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { #[cfg(test)] mod tests { use super::*; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app + } + + /// Run the panel for one frame with `events` and `modifiers`; returns the spectrum's rect (when + /// the options are shown) and the panel's height. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, modifiers: egui::Modifiers) -> (Option, f32) { + let mut out = (None, 0.0); + let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); + let input = egui::RawInput { events, ..Default::default() }; + let mut full = ctx.run_ui(input, |ui| { + show(app, ui); + out = (ctx.read_response(ui.id().with("color-spectrum")).map(|r| r.rect), ui.min_rect().height()); + }); + full.textures_delta.clear(); + out + } + + /// Click the spectrum at (x, y) (0..1 each) with `modifiers` held. + fn click_spectrum(app: &mut VectorcraftApp, ctx: &egui::Context, (x, y): (f32, f32), modifiers: egui::Modifiers) { + let r = frame(app, ctx, vec![], modifiers).0.expect("the spectrum is drawn"); + let pos = egui::pos2(r.left() + x * r.width(), r.top() + y * r.height()); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + frame(app, ctx, vec![egui::Event::PointerMoved(pos), button(true)], modifiers); + frame(app, ctx, vec![button(false)], modifiers); + frame(app, ctx, vec![], egui::Modifiers::NONE); + } + + fn paints_hex(app: &VectorcraftApp) -> (String, String) { + let (f, s) = crate::panels::current_paints(app); + (f.color().map(|c| c.to_hex()).unwrap_or_default(), s.color().map(|c| c.to_hex()).unwrap_or_default()) + } + + #[test] + fn tandem_keeps_ratios() { + let t = tandem(Mode::Rgb, &[100.0, 50.0, 25.0], 0, 200.0); + assert_eq!(t, vec![200.0, 100.0, 50.0]); + // It stops where a component reaches its maximum. + let t = tandem(Mode::Rgb, &[200.0, 100.0, 0.0], 1, 200.0); + assert_eq!(t, vec![255.0, 127.5, 0.0]); + let t = tandem(Mode::Cmyk, &[10.0, 40.0, 20.0, 5.0], 1, 20.0); + assert_eq!(t, vec![5.0, 20.0, 10.0, 2.5]); + // From 0 the others move by the same amount. + assert_eq!(tandem(Mode::Rgb, &[0.0, 50.0, 250.0], 0, 10.0), vec![10.0, 60.0, 255.0]); + assert!(Mode::Rgb.tandem() && Mode::Cmyk.tandem() && !Mode::Hsb.tandem() && !Mode::Grayscale.tandem()); + } + + #[test] + fn the_mode_follows_the_colours_model() { + let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4); + let rgb = Color::rgb(0.2, 0.4, 0.6); + assert_eq!(display_mode(Some(Mode::Rgb), Some(&cmyk)), Mode::Cmyk); + assert_eq!(display_mode(Some(Mode::Hsb), Some(&rgb)), Mode::Hsb, "HSB is remembered for RGB colours"); + assert_eq!(display_mode(Some(Mode::Cmyk), Some(&rgb)), Mode::Rgb); + assert_eq!(display_mode(Some(Mode::Rgb), Some(&Color::gray(0.5))), Mode::Grayscale); + assert_eq!(display_mode(Some(Mode::Cmyk), None), Mode::Cmyk); + assert_eq!(convert_to(Mode::Hsb, &rgb), rgb); + assert!(matches!(convert_to(Mode::Cmyk, &rgb), Color::Cmyk { .. })); + assert_eq!(convert_to(Mode::WebSafe, &rgb).to_hex(), "#336699"); + assert_eq!(Mode::Rgb.next(), Mode::Hsb); + assert_eq!(Mode::WebSafe.next(), Mode::Grayscale); + // In the panel: an RGB pick, then a CMYK object shows its 4 sliders. + let mut app = app(); + let ctx = egui::Context::default(); + set_pstate(&ctx, "color-mode", Some(Mode::Rgb)); + app.run("paint.setFill", json!({"color": {"c": 0.1, "m": 0.2, "y": 0.3, "k": 0.4}})).unwrap(); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + let _ = ctx.run_ui(Default::default(), |ui| { + let mode = display_mode(pstate(ui.ctx(), "color-mode"), target(&app, ui).color().as_ref()); + assert_eq!(mode.labels(), ["C", "M", "Y", "K"]); + }); + } + + #[test] + fn pure_blue_is_out_of_gamut_in_rgb() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("paint.setFill", json!({"color": "#0000ff"})).unwrap(); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + let (of, fix) = pstate::)>>(&ctx, "color-gamut").expect("RGB mode checks the gamut"); + assert_eq!(of.to_hex(), "#0000ff"); + assert!(fix.is_some_and(|f| !f.out_of_gamut())); + app.run("paint.setFill", json!({"color": "#808080"})).unwrap(); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + assert_eq!(pstate::)>>(&ctx, "color-gamut").unwrap().1, None); + } + + #[test] + fn alt_click_on_the_spectrum_paints_the_stroke() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("paint.setFill", json!({"color": "#123456"})).unwrap(); + app.run("paint.setStroke", json!({"color": "#000000", "focus": false})).unwrap(); + assert!(app.session.fill_active); + click_spectrum(&mut app, &ctx, (0.0, 0.5), egui::Modifiers::ALT); + assert_eq!(paints_hex(&app), ("#123456".into(), "#ff0000".into()), "the stroke changes"); + assert!(app.session.fill_active, "the fill stays in front"); + // A plain click paints the fill. + click_spectrum(&mut app, &ctx, (0.0, 0.5), egui::Modifiers::NONE); + assert_eq!(paints_hex(&app).0, "#ff0000"); + } + + #[test] + fn shift_click_on_the_spectrum_cycles_modes() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("paint.setFill", json!({"color": "#336699"})).unwrap(); + click_spectrum(&mut app, &ctx, (0.5, 0.5), egui::Modifiers::SHIFT); + assert_eq!(pstate::>(&ctx, "color-mode"), Some(Mode::Hsb)); + assert_eq!(paints_hex(&app).0, "#336699", "HSB keeps the RGB colour"); + click_spectrum(&mut app, &ctx, (0.5, 0.5), egui::Modifiers::SHIFT); + assert_eq!(pstate::>(&ctx, "color-mode"), Some(Mode::Cmyk)); + assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK converts the colour"); + } + + #[test] + fn hide_options_leaves_the_proxy_and_spectrum() { + let mut app = app(); + let ctx = egui::Context::default(); + let (spectrum, full) = frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + assert!(spectrum.is_some()); + set_pstate(&ctx, "color-hide-options", true); + let (_, hidden) = frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + assert!(hidden <= 44.0 && full > 150.0, "{hidden} / {full}"); + } + + #[test] + fn a_mixed_selection_has_no_colour_to_edit() { + let mut app = app(); + let ctx = egui::Context::default(); + let b = app.run("shape.rectangle", json!({"x": 80, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("select.all", json!({})).unwrap(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + app.run("paint.setFill", json!({"color": "#00ff00", "ids": [b]})).unwrap(); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (true, false)); + let _ = ctx.run_ui(Default::default(), |ui| assert_eq!(target(&app, ui).color(), None)); + app.run("paint.setFill", json!({"color": "#0000ff"})).unwrap(); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false), "a new revision recomputes it"); + } #[test] fn rgb_roundtrip() { diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index 73d89e3e..d124c477 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, set_pstate}; +use super::{active_paint, apply_click, color_json, pstate, set_pstate}; use crate::VectorcraftApp; use crate::widgets::{self, menu_item}; @@ -123,7 +123,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } }); if let Some(c) = chosen { - app.run(paint_target(app), json!({"color": color_json(&c)})).ok(); + apply_click(app, ui, json!({"color": color_json(&c)})); } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index e86dd4e2..7c77250a 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -159,9 +159,22 @@ pub fn libraries(_app: &mut VectorcraftApp, ui: &mut Ui) { // ---------- shared helpers ---------- -/// The paint command for the active proxy (Fill or Stroke). -pub(crate) fn paint_target(app: &VectorcraftApp) -> &'static str { - if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" } +/// The paint command of the active proxy (Fill or Stroke), or of the inactive one. +pub(crate) fn proxy_cmd(app: &VectorcraftApp, inactive: bool) -> &'static str { + if app.session.fill_active != inactive { "paint.setFill" } else { "paint.setStroke" } +} + +/// Is Alt held? A click on a colour then paints the inactive proxy. +pub(crate) fn alt_held(ui: &Ui) -> bool { + ui.input(|i| i.modifiers.alt) +} + +/// Apply a clicked colour, swatch or None (`params`: `{color}`, `{swatch}` or `{none}`) to the +/// active proxy, or with Alt held to the inactive one, which stays behind. +pub(crate) fn apply_click(app: &mut VectorcraftApp, ui: &Ui, mut params: Value) { + let alt = alt_held(ui); + params["focus"] = json!(!alt); + app.run(proxy_cmd(app, alt), params).ok(); } /// Fill and stroke as the proxies show them: the first selected object's (text uses its first @@ -179,6 +192,27 @@ pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) { } } +/// Whether the selected objects' fills and strokes differ (the proxies show "?"), cached per +/// document revision. +pub(crate) fn mixed_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (bool, bool) { + let Some(st) = app.session.active() else { return (false, false) }; + let key = (st.uid, st.revision); + match pstate::>(ctx, "proxy-mixed") { + Some((k, mixed)) if k == key => mixed, + _ => { + let mixed = st.proxy_mixed(); + set_pstate(ctx, "proxy-mixed", Some((key, mixed))); + mixed + } + } +} + +/// Is the active proxy "?" (the selected objects' paints differ)? +pub(crate) fn active_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> bool { + let (f, s) = mixed_paints(app, ctx); + if app.session.fill_active { f } else { s } +} + /// The paint behind the active proxy. pub(crate) fn active_paint(app: &VectorcraftApp) -> Paint { let (f, s) = current_paints(app); @@ -188,7 +222,8 @@ pub(crate) fn active_paint(app: &VectorcraftApp) -> Paint { /// Draw the Fill/Stroke proxy and handle its clicks through commands. pub(crate) fn proxy(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { let (f, s) = current_paints(app); - let c = crate::widgets::fill_stroke_proxy(ui, &f, &s, app.session.fill_active, size); + let mixed = mixed_paints(app, ui.ctx()); + let c = crate::widgets::fill_stroke_proxy(ui, &f, &s, mixed, app.session.fill_active, size); if (c.fill && !app.session.fill_active) || (c.stroke && app.session.fill_active) { app.run("paint.toggleActive", json!({})).ok(); } @@ -233,7 +268,7 @@ pub(crate) fn set_pstate(ctx: &egui::Context, } /// "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. +/// command feeds); clicking one applies it to the active proxy (Alt: the inactive one). pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); crate::widgets::subheader(ui, "Recent Colors"); @@ -252,7 +287,7 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) { } } if let Some(c) = chosen { - app.run(paint_target(app), json!({"color": color_json(&c)})).ok(); + apply_click(app, ui, json!({"color": color_json(&c)})); } } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 2fcf2ccf..0cde8b40 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, set_pstate}; +use super::{active_paint, pstate, set_pstate}; use crate::theme::Tokens; use crate::widgets::{self, menu_item, swatch_tile}; use crate::{VectorcraftApp, icons}; @@ -112,18 +112,15 @@ fn entries(app: &VectorcraftApp, kind: Kind) -> Vec { out } -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 params = if paint.is_none() { json!({"none": true}) } else { json!({"swatch": name}) }; - app.run(target, params).ok(); - } - Entry::Folder(_) => {} - } +/// Apply a clicked swatch to the active proxy (Alt: the inactive one). +fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) { + let params = match e { + Entry::Registration => json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}}), + Entry::Swatch { paint, .. } if paint.is_none() => json!({"none": true}), + Entry::Swatch { name, .. } => json!({"swatch": name}), + Entry::Folder(_) => return, + }; + super::apply_click(app, ui, params); } /// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). @@ -339,7 +336,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, &e); + apply(app, ui, &e); } bottom(app, ui); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0f9288db..95b91714 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -233,8 +233,9 @@ pub struct ProxyClicks { pub pick: Option, } -/// The Fill/Stroke proxy pair (two overlapping squares). -pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: bool, size: f32) -> ProxyClicks { +/// The Fill/Stroke proxy pair (two overlapping squares). `mixed` (fill, stroke) draws a "?" square +/// for a selection whose objects differ. +pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool, bool), fill_active: bool, size: f32) -> ProxyClicks { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); let s = size * 0.62; @@ -242,16 +243,31 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s)); let stroke_resp = ui.interact(stroke_r, ui.id().with("stroke-proxy"), Sense::click()); let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click()); + let question = |ui: &Ui, r: Rect, color: Color32| { + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(r.height() * 0.8), color); + }; let draw_fill = |ui: &Ui| { - paint_chip(ui, fill_r, fill); + if mixed.0 { + ui.painter().rect_filled(fill_r, 0.0, Color32::WHITE); + question(ui, fill_r, Color32::BLACK); + } else { + paint_chip(ui, fill_r, fill); + } ui.painter().rect_stroke(fill_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); ui.painter().rect_stroke(fill_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); }; let draw_stroke = |ui: &Ui| { let w = s * 0.28; - paint_chip(ui, stroke_r, stroke); + if mixed.1 { + ui.painter().rect_filled(stroke_r, 0.0, Color32::WHITE); + } else { + paint_chip(ui, stroke_r, stroke); + } let inner = stroke_r.shrink(w); ui.painter().rect_filled(inner, 0.0, t.panel); + if mixed.1 { + question(ui, inner, t.text_strong); + } ui.painter().rect_stroke(stroke_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Outside); ui.painter().rect_stroke(stroke_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); @@ -553,7 +569,7 @@ pub fn color_slider( } /// The pointer of a click or drag on `resp` and its phase (dragging, or released on click/drop). -fn pointer_phase(resp: &Response) -> Option<(Pos2, Live)> { +pub(crate) fn pointer_phase(resp: &Response) -> Option<(Pos2, Live)> { if !(resp.dragged() || resp.clicked() || resp.drag_stopped()) { return None; } @@ -585,29 +601,11 @@ pub fn color_field( pos: (f32, f32), color_at: &dyn Fn(f32, f32) -> Color32, ) -> (Option<(f32, f32)>, Live) { - const CELLS: u32 = 32; let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag()); - let mut mesh = egui::Mesh::default(); - let row = CELLS + 1; - mesh.reserve_vertices((row * row) as usize); - mesh.reserve_triangles((CELLS * CELLS * 2) as usize); - for j in 0..row { - for i in 0..row { - let (x, y) = (i as f32 / CELLS as f32, j as f32 / CELLS as f32); - mesh.colored_vertex(pos2(rect.left() + x * rect.width(), rect.top() + y * rect.height()), color_at(x, y)); - } - } - for j in 0..CELLS { - for i in 0..CELLS { - let a = j * row + i; - mesh.add_triangle(a, a + 1, a + row); - mesh.add_triangle(a + 1, a + row + 1, a + row); - } - } let painter = ui.painter_at(rect.expand(6.0)); - painter.add(egui::Shape::mesh(mesh)); + painter.add(color_mesh(rect, (32, 32), color_at)); painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height()); painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); @@ -621,6 +619,29 @@ pub fn color_field( } } +/// A smoothly shaded colour area: a `cols` × `rows` grid over `rect` with `color_at(x, y)` (0..1 +/// each, y down) at its vertices. +pub fn color_mesh(rect: Rect, (cols, rows): (u32, u32), color_at: &dyn Fn(f32, f32) -> Color32) -> egui::Shape { + let mut mesh = egui::Mesh::default(); + let row = cols + 1; + mesh.reserve_vertices((row * (rows + 1)) as usize); + mesh.reserve_triangles((cols * rows * 2) as usize); + for j in 0..=rows { + for i in 0..=cols { + let (x, y) = (i as f32 / cols as f32, j as f32 / rows as f32); + mesh.colored_vertex(pos2(rect.left() + x * rect.width(), rect.top() + y * rect.height()), color_at(x, y)); + } + } + for j in 0..rows { + for i in 0..cols { + let a = j * row + i; + mesh.add_triangle(a, a + 1, a + row); + mesh.add_triangle(a + 1, a + row + 1, a + row); + } + } + egui::Shape::mesh(mesh) +} + /// The Color Picker's vertical channel slider: `track(t)` gives the colour at 0..1 (bottom to top) /// and arrows on both sides mark `value`. Returns the new value and the phase. pub fn channel_slider( diff --git a/docs/mcp.md b/docs/mcp.md index 2dd262db..d3d4265a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -43,6 +43,8 @@ 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. +`paint.proxies` returns what the proxies show: the fill and stroke, which one is active, and whether the selected +objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" proxy). | Tool | Arguments | Notes | |---|---|---| From ff9af2628a38828016305c5e7b645bcb7fcb1575 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 18:20:19 +0700 Subject: [PATCH 4/6] M3.29: Live Paint Bucket and Appearance item colours feed the recent colours; the last-colour/gradient commands read "Apply Last Color" / "Apply Last Gradient" (no clash with Window > Gradient); `,` and `.` keyboard test Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/appearance.rs | 3 +++ crates/engine/src/cmd/buildcmds.rs | 6 ++++-- crates/engine/src/cmd/paint.rs | 4 ++-- crates/engine/src/tests_paintproxy.rs | 15 +++++++++++++++ crates/ui-egui/src/shortcuts.rs | 14 ++++++++++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index c60cdd34..051c7e6c 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -126,6 +126,9 @@ fn set_item(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; + if let Some(pa) = &paint { + s.remember_paint(pa); + } ok() } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 9e24fe2b..48207f44 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -570,9 +570,10 @@ fn lp_fill(s: &mut Session, p: &Value) -> Result { }; let fid = d.node(gid).and_then(|n| face_at(n, pt)).map(|f| f.id).ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; - d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint); + d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone()); Ok((gid, fid)) })?; + s.remember_paint(&paint); Ok(json!({ "group": g.0, "face": face.0 })) } @@ -590,12 +591,13 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?; let eid = edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?; - e.appearance.set_stroke(paint); + e.appearance.set_stroke(paint.clone()); if let Some(st) = e.appearance.stroke_mut() { st.width = width; } Ok(eid) })?; + s.remember_paint(&paint); Ok(json!({ "group": g.0, "edge": edge.0 })) } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 0a12035a..66058c45 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -80,7 +80,7 @@ pub fn proxy_specs() -> Vec { ), cmd!( "paint.lastColor", - "Color", + "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", @@ -92,7 +92,7 @@ pub fn proxy_specs() -> Vec { ), cmd!( "paint.lastGradient", - "Gradient", + "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", diff --git a/crates/engine/src/tests_paintproxy.rs b/crates/engine/src/tests_paintproxy.rs index 8a9d7e73..50c83fdf 100644 --- a/crates/engine/src/tests_paintproxy.rs +++ b/crates/engine/src/tests_paintproxy.rs @@ -195,3 +195,18 @@ fn a_mixed_selection_shows_a_question_mark_proxy() { s.execute("select.none", &json!({})).unwrap(); assert_eq!(q(&mut s)["fillMixed"], false); } + +#[test] +fn live_paint_and_appearance_items_feed_recent_colours() { + let mut s = session(); + let id = rect(&mut s); + s.execute("appearance.setItem", &json!({"index": 0, "color": "#0a0b0c"})).unwrap(); + assert_eq!(hexes(&s)[0], "#0a0b0c"); + // Opacity alone doesn't change the recent colours. + s.execute("appearance.setItem", &json!({"index": 0, "opacity": 50})).unwrap(); + assert_eq!(hexes(&s).len(), 1); + s.execute("livePaint.make", &json!({"ids": [id.0]})).unwrap(); + let g = s.doc().unwrap().selection.objects[0]; + s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 30], "color": "#00ff00"})).unwrap(); + assert_eq!(hexes(&s)[0], "#00ff00"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 5b814a74..f02ffbcf 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -244,6 +244,20 @@ mod tests { assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 8); } + #[test] + fn comma_and_period_reapply_the_last_colour_and_gradient() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.session.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + app.session.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + let fill = |app: &VectorcraftApp| crate::panels::current_paints(app).0; + frame(&mut app, vec![egui::Event::Text(".".into())]); + assert!(matches!(fill(&app), vectorcraft_color::Paint::Gradient(_)), "`.` applies the last gradient"); + frame(&mut app, vec![egui::Event::Text(",".into())]); + assert_eq!(fill(&app).color().unwrap().to_hex(), "#336699", "`,` applies the last colour"); + } + #[test] fn parses() { let s = parse("Cmd+Shift+]").unwrap(); From c071dcd4d0cfbc5b7b356257f4be6ff826ed0478 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 18:22:05 +0700 Subject: [PATCH 5/6] M3.28: proxies look through groups like the paint commands (a group shows its contents' paint, and contents that differ make a "?" proxy), the toolbar and panels share the engine's Session::proxy_paints, Magic Wand and Select Text by Font bump the revision so the cached "?" state follows them; double-click proxy test Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fonts.rs | 2 +- crates/engine/src/cmd/paint.rs | 78 ++++++++++++++++++--------- crates/engine/src/cmd/wand.rs | 2 +- crates/engine/src/tests_paintproxy.rs | 63 ++++++++++++++++++++++ crates/ui-egui/src/panels/color.rs | 28 ++++++++++ crates/ui-egui/src/panels/mod.rs | 17 ++---- 6 files changed, 149 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 3b87a7e3..82a1365e 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -159,7 +159,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result { }) .collect(); let n = hits.len(); - s.doc_mut()?.selection.set(hits); + s.select(|_, sel| sel.set(hits))?; Ok(json!({ "count": n })) } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 66058c45..8a553d52 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -123,7 +123,7 @@ pub fn proxy_specs() -> Vec { "Fill and Stroke", [], None, - "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the first selected object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' fills (strokes) differ, shown as a \"?\" proxy}", + "{} → {fill, stroke: the paints the Fill/Stroke proxies show (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 ), @@ -144,6 +144,19 @@ impl Session { } } + /// The fill and stroke the Fill/Stroke proxies show: 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 mut nodes = vec![]; + if let Some(n) = self.active().and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))) { + painted(n, true, &mut nodes); + } + match nodes.first() { + Some(n) => (proxy_paint(n, false), proxy_paint(n, true)), + None => (self.paint.fill.clone(), self.paint.stroke.clone()), + } + } + pub(crate) fn remember_paint_now(&mut self, p: &Paint) { match p { Paint::Solid { color, .. } => { @@ -158,13 +171,12 @@ impl Session { } } -/// The fill or stroke a proxy shows for `n` (type shows its first run's style). +/// The fill or stroke a proxy shows for `n` (type shows its first run's style, as +/// `TextNode::first_style` does). pub(crate) fn proxy_paint(n: &Node, stroke: bool) -> Paint { + let pick = |st: &CharStyle| if stroke { st.stroke.clone() } else { st.fill.clone() }; 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() - } + NodeKind::Text(t) => t.runs.first().map_or_else(|| pick(&CharStyle::default()), |r| pick(&r.style)), _ if stroke => n.appearance.stroke_paint(), _ => n.appearance.fill_paint(), } @@ -180,40 +192,54 @@ fn same_in_proxy(a: &Paint, b: &Paint) -> bool { } } +/// 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). One walk of the document, so callers that ask every frame cache it by revision. + /// 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.len() < 2 { + if self.selection.is_empty() { return (false, false); } let ids: std::collections::HashSet = self.selection.objects.iter().copied().collect(); - let mut first: Option<(Paint, Paint)> = None; - let mut mixed = (false, false); + let mut nodes = vec![]; self.doc.walk(|n| { - if (mixed.0 && mixed.1) || !ids.contains(&n.id) { - return; - } - let (f, s) = (proxy_paint(n, false), proxy_paint(n, true)); - match &first { - Some((f0, s0)) => { - mixed.0 |= !same_in_proxy(f0, &f); - mixed.1 |= !same_in_proxy(s0, &s); - } - None => first = Some((f, s)), + if ids.contains(&n.id) { + painted(n, true, &mut nodes); } }); + let mut paints = nodes.iter().map(|n| (proxy_paint(n, false), proxy_paint(n, true))); + 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 st = s.doc().ok(); - let (fill, stroke) = match st.and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))) { - Some(n) => (proxy_paint(n, false), proxy_paint(n, true)), - None => (s.paint.fill.clone(), s.paint.stroke.clone()), - }; - let (fill_mixed, stroke_mixed) = st.map_or((false, false), DocState::proxy_mixed); + 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})) } diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index 8f6b118e..fd843800 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -159,7 +159,7 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result { other => return Err(bad("select.magicWand", format!("unknown mode `{other}` (set, add, subtract)"))), }; let n = ids.len(); - s.doc_mut()?.selection.set(ids); + s.select(|_, sel| sel.set(ids))?; Ok(json!({ "count": n })) } diff --git a/crates/engine/src/tests_paintproxy.rs b/crates/engine/src/tests_paintproxy.rs index 50c83fdf..be0ee209 100644 --- a/crates/engine/src/tests_paintproxy.rs +++ b/crates/engine/src/tests_paintproxy.rs @@ -196,6 +196,48 @@ fn a_mixed_selection_shows_a_question_mark_proxy() { assert_eq!(q(&mut s)["fillMixed"], false); } +#[test] +fn every_selection_change_bumps_the_revision() { + // The "?" proxy is cached per revision, so selecting by Magic Wand or font must bump it. + let mut s = session(); + let a = rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let b = rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#fa0000"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let rev = s.doc().unwrap().revision; + s.execute("select.magicWand", &json!({"id": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.len(), 2, "{a:?} and {b:?} are within the tolerance"); + assert!(s.doc().unwrap().revision > rev); + assert_eq!(s.doc().unwrap().proxy_mixed(), (true, false)); + let r = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "Hi"})).unwrap(); + let family = match &node(&s, NodeId(r["id"].as_u64().unwrap())).kind { + NodeKind::Text(t) => t.first_style().font_family, + _ => panic!("not text"), + }; + let rev = s.doc().unwrap().revision; + s.execute("select.font", &json!({"family": family})).unwrap(); + assert!(s.doc().unwrap().revision > rev); +} + +#[test] +fn proxies_show_the_first_selected_object_or_the_defaults() { + let mut s = session(); + let id = rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#123456"})).unwrap(); + assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#123456"); + s.execute("select.none", &json!({})).unwrap(); + s.paint.fill = Paint::solid(Color::WHITE); + assert_eq!(s.proxy_paints().0, Paint::solid(Color::WHITE)); + // Type shows its first run's paints. + let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap(); + s.execute("select.set", &json!({"ids": [r["id"]]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#654321"})).unwrap(); + assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#654321"); + assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fill"], json!(s.proxy_paints().0)); + let _ = id; +} + #[test] fn live_paint_and_appearance_items_feed_recent_colours() { let mut s = session(); @@ -210,3 +252,24 @@ fn live_paint_and_appearance_items_feed_recent_colours() { s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 30], "color": "#00ff00"})).unwrap(); assert_eq!(hexes(&s)[0], "#00ff00"); } + +#[test] +fn a_group_shows_its_contents_in_the_proxies() { + let mut s = session(); + let a = rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let b = rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + // The group shows what the paint commands change: its contents' red fill, not mixed. + assert_eq!(s.doc().unwrap().selection.len(), 1); + assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#ff0000"); + assert_eq!(s.doc().unwrap().proxy_mixed(), (false, false)); + // Contents that differ make even a single selected group a "?" proxy. + s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": [b.0]})).unwrap(); + assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fillMixed"], true); + // An empty selection shows the defaults. + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.proxy_paints().0, s.paint.fill); +} diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 44605737..6664ba4c 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -650,6 +650,34 @@ mod tests { assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK converts the colour"); } + #[test] + fn double_clicking_a_proxy_opens_the_color_picker_for_it() { + let mut app = app(); + let ctx = egui::Context::default(); + // One frame of the proxy alone; returns the stroke square's rect. + let run = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let input = egui::RawInput { time: Some(time), events, ..Default::default() }; + let mut rect = None; + let mut out = ctx.run_ui(input, |ui| { + crate::panels::proxy(app, ui, 40.0); + rect = ctx.read_response(ui.id().with("stroke-proxy")).map(|r| r.rect); + }); + out.textures_delta.clear(); + rect + }; + let pos = run(&mut app, 0.0, vec![]).expect("the stroke square is drawn").right_bottom() - vec2(3.0, 3.0); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + run(&mut app, 0.1, vec![egui::Event::PointerMoved(pos), button(true)]); + run(&mut app, 0.15, vec![button(false)]); + assert!(!app.session.fill_active, "a click brings the stroke to the front"); + assert!(app.ui.dialog.is_none()); + run(&mut app, 0.2, vec![button(true)]); + run(&mut app, 0.25, vec![button(false)]); + let d = app.ui.dialog.as_ref().expect("a double-click opens the Color Picker"); + assert_eq!(d.kind, "colorPicker"); + assert!(d.bool("stroke")); + } + #[test] fn hide_options_leaves_the_proxy_and_spectrum() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7c77250a..a97e51d2 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -38,7 +38,7 @@ pub mod transparency; use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::Node; use crate::theme::Tokens; use crate::widgets::{Live, dim_label}; @@ -177,19 +177,10 @@ pub(crate) fn apply_click(app: &mut VectorcraftApp, ui: &Ui, mut params: Value) app.run(proxy_cmd(app, alt), params).ok(); } -/// Fill and stroke as the proxies show them: the first selected object's (text uses its first -/// run's style), else the defaults for new art. +/// Fill and stroke as the proxies show them ([`vectorcraft_engine::Session::proxy_paints`]: the +/// first selected object's, a group's first painted object's, else the defaults for new art). pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) { - match first_selected(app) { - Some(n) => match &n.kind { - NodeKind::Text(t) => { - let s = t.first_style(); - (s.fill, s.stroke) - } - _ => (n.appearance.fill_paint(), n.appearance.stroke_paint()), - }, - None => (app.session.paint.fill.clone(), app.session.paint.stroke.clone()), - } + app.session.proxy_paints() } /// Whether the selected objects' fills and strokes differ (the proxies show "?"), cached per From ad82e6e9a9236766d874fe1b034069962ca881d2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 18:24:22 +0700 Subject: [PATCH 6/6] M3.27: Color Picker reads colours with the engine's color_value (one parser; CMYK and grey in percent as the paint commands take them) instead of its own copy Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 2 +- crates/ui-egui/src/dialogs/color_picker.rs | 13 +++++++++---- crates/ui-egui/src/panels/mod.rs | 13 ------------- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9fdbe581..0a438f13 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -240,7 +240,7 @@ pub fn matrix_param(p: &Value, key: &str) -> Option { Some(Affine::new(c)) } /// A colour from `"#rrggbb"`, `[r,g,b]` (0..1), `{"c":..,"m":..,"y":..,"k":..}` or `{"gray":..}`. -pub(crate) fn color_value(v: &Value) -> Option { +pub fn color_value(v: &Value) -> Option { match v { Value::String(s) => Color::from_hex(s), Value::Array(a) if a.len() >= 3 => Some(Color::rgb(a[0].as_f64()? as f32, a[1].as_f64()? as f32, a[2].as_f64()? as f32)), diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index 00f81ff3..8b69fc5f 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -12,10 +12,11 @@ use serde_json::{Value, json}; use vectorcraft_color::cms::{self, Lab}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::ColorMode; +use vectorcraft_engine::cmd::color_value; use super::DialogSpec; use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe}; -use crate::panels::{c32, color_from_json, color_json}; +use crate::panels::{c32, color_json}; use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, widgets}; @@ -132,7 +133,7 @@ fn picked(d: &Dialog) -> Picked { let hex = d.str("hex"); let color = match parse_hex(&hex) { Some(c) if hex != d.str("__hex") => c, - _ => d.fields.get("color").and_then(color_from_json).unwrap_or(Color::WHITE), + _ => d.fields.get("color").and_then(color_value).unwrap_or(Color::WHITE), }; let hsb = d.fields.get("__hsb").and_then(|v| serde_json::from_value::<[f32; 3]>(v.clone()).ok()); let p = match hsb { @@ -165,7 +166,7 @@ fn proxy_color(app: &VectorcraftApp, stroke: bool) -> Color { pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!app.session.fill_active); let color = match p.get("color") { - Some(v) => color_from_json(v).ok_or_else(|| format!("bad color {v}"))?, + Some(v) => color_value(v).ok_or_else(|| format!("bad color {v}"))?, None => proxy_color(app, stroke), }; let mut d = Dialog::new( @@ -182,7 +183,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { let web = d.bool("webOnly"); let swatches = d.bool("swatches"); let p = picked(d); - let original = d.fields.get("original").and_then(color_from_json).unwrap_or(p.color); + let original = d.fields.get("original").and_then(color_value).unwrap_or(p.color); let gamut = gamut_fix(app, ui.ctx(), "picker-gamut", &p.color); let mut pick: Option = None; let mut new_channel = None; @@ -481,6 +482,10 @@ mod tests { super::super::confirm(&mut app).unwrap(); assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK is kept"); assert!(app.run("ui.colorPicker", json!({"color": "nope"})).is_err()); + // Colours take every form the paint commands take (CMYK in percent too). + app.run("ui.colorPicker", json!({"stroke": false, "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap(); + super::super::confirm(&mut app).unwrap(); + assert_eq!(crate::panels::current_paints(&app).0.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0))); } #[test] diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a97e51d2..87c49631 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -282,19 +282,6 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// A colour from command JSON: `"#rrggbb"`, `[r,g,b]`, `{c,m,y,k}`, `{gray}` (inverse of -/// [`color_json`]) or a serialized colour. -pub(crate) fn color_from_json(v: &Value) -> Option { - let f = |v: Option<&Value>| v.and_then(Value::as_f64).map(|x| x as f32); - match v { - Value::String(s) => color::parse_hex(s), - Value::Array(a) if a.len() == 3 => Some(Color::rgb(f(a.first())?, f(a.get(1))?, f(a.get(2))?)), - Value::Object(o) if o.contains_key("gray") => Some(Color::gray(f(o.get("gray"))?)), - Value::Object(o) if !o.contains_key("model") => Some(Color::cmyk(f(o.get("c"))?, f(o.get("m"))?, f(o.get("y"))?, f(o.get("k"))?)), - _ => serde_json::from_value(v.clone()).ok(), - } -} - /// A colour as command JSON, keeping its model. pub(crate) fn color_json(c: &Color) -> Value { match *c {