M3.29: proxy paint commands — paint.invert / paint.complement recolour the active proxy of each selected object keeping its colour model (the defaults when nothing is selected), paint.lastColor (,) and paint.lastGradient (.) re-apply the last solid colour or gradient (the toolbar chips draw and run them), swap and default work on type, and recent colours live in the Session, fed by every paint command (MCP, eyedropper, live previews on commit) and listed by paint.recent

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-04 18:01:58 +07:00
co-authored by Claude Opus 5.5
parent 8f9d6ef2bd
commit 8a871d98a9
16 changed files with 558 additions and 149 deletions
+1
View File
@@ -210,6 +210,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
};
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();
}
+8 -3
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
]
}
/// 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<NodeId>, fill: bool, strok
Ok(json!({ "changed": n }))
}
fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
+5
View File
@@ -118,6 +118,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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 {
+1
View File
@@ -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
})
}
+224 -39
View File
@@ -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<CommandSpec> {
"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<CommandSpec> {
]
}
/// Commands of the Fill/Stroke proxies, registered at the end of the command list.
pub fn proxy_specs() -> Vec<CommandSpec> {
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::<Vec<_>>(),
"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<Option<Paint>> {
if bool_or(p, "none", false) {
@@ -94,28 +219,45 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
}
}
Ok(())
})?;
ok()
})
}
fn swap(s: &mut Session, _: &Value) -> Result<Value> {
/// 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<Value> {
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<Value> {
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<Value> {
ok()
}
fn default_paint(s: &mut Session, _: &Value) -> Result<Value> {
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<Value> {
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::<Vec<_>>()});
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<Value> {