Merge wp/I1

# Conflicts:
#	crates/engine/src/cmd/gradient.rs
#	crates/engine/src/cmd/paint.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/dialogs/mod.rs
#	crates/ui-egui/src/menus.rs
#	crates/ui-egui/src/panels/color.rs
#	crates/ui-egui/src/panels/mod.rs
#	crates/ui-egui/src/toolbar.rs
#	docs/control-protocol.md
This commit is contained in:
bflatastic
2026-10-04 22:14:37 +07:00
26 changed files with 1961 additions and 348 deletions
+4
View File
@@ -379,6 +379,9 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
if let Some(pa) = &paint {
s.remember_paint(pa);
}
ok()
}
@@ -467,6 +470,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();
}
+4 -2
View File
@@ -570,9 +570,10 @@ fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
};
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<Value> {
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 }))
}
+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)?,
+1 -1
View File
@@ -159,7 +159,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let n = hits.len();
s.doc_mut()?.selection.set(hits);
s.select(|_, sel| sel.set(hits))?;
Ok(json!({ "count": n }))
}
+21 -13
View File
@@ -43,18 +43,11 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// The gradient behind the active proxy: the first selected object's (see
/// `Node::proxy_paint`; the Appearance panel's active item when it is of the proxy's kind), else
/// the default paint for new art.
/// The gradient behind the active proxy ([`Session::proxy_paints`]).
pub(crate) fn active_gradient(s: &Session) -> Option<GradientPaint> {
let stroke = !s.fill_active;
let first = s.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id)));
let paint = match first {
Some(n) => n.proxy_paint(stroke, s.appearance_item()).map(|(p, ..)| p.clone()),
None => Some(if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }),
};
match paint {
Some(Paint::Gradient(g)) => Some(*g),
let (fill, stroke) = s.proxy_paints();
match if s.fill_active { fill } else { stroke } {
Paint::Gradient(g) => Some(*g),
_ => None,
}
}
@@ -281,6 +274,14 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
n.appearance.set_paint_at(index, !stroke, np);
Ok(())
})?;
// The last gradient is the one the first object now shows (the defaults' without objects).
let shown = match ids.first() {
Some(id) => s.doc()?.doc.node(*id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten())),
None => Some(new_default.clone()),
};
if let Some(shown) = shown {
s.remember_paint(&shown);
}
if stroke {
s.paint.stroke = new_default;
} else {
@@ -289,6 +290,13 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// A painted stroke on a type run without a weight gets 1 pt.
pub(crate) fn run_stroke_weight(style: &mut vectorcraft_doc::CharStyle) {
if style.stroke_width == 0.0 {
style.stroke_width = 1.0;
}
}
/// A type run's fill or stroke paint and the box (text space) an unplaced gradient on it fits:
/// the layout bounds `lb`, grown by half the run's stroke weight for strokes.
pub(crate) fn run_paint_mut(r: &mut vectorcraft_doc::TextRun, stroke: bool, lb: Rect) -> (&mut Paint, Rect) {
@@ -377,8 +385,8 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
{
let (xf, lb) = (t.xf, t.local_bounds());
for r in &mut t.runs {
if stroke && r.style.stroke_width == 0.0 {
r.style.stroke_width = 1.0;
if stroke {
run_stroke_weight(&mut r.style);
}
let (paint, b) = run_paint_mut(r, stroke, lb);
if let Some(np) = vector_paint(paint, start, end, xf, Some(b)) {
+2 -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
})
}
@@ -239,7 +240,7 @@ pub fn matrix_param(p: &Value, key: &str) -> Option<Affine> {
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<Color> {
pub fn color_value(v: &Value) -> Option<Color> {
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)),
+311 -34
View File
@@ -1,13 +1,13 @@
//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel.
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, NodeKind};
use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind};
use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target};
use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, unplaced};
use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced};
use super::*;
use crate::EngineError;
use crate::{DocState, EngineError};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -16,15 +16,31 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?} sets the selection's fill and the default (new art fits a gradient to itself)",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy)} sets the selection's fill and the default (new art fits a gradient to itself)",
has_doc,
|s, p| set_paint(s, p, true)
),
cmd!("paint.setStroke", "Stroke", [], None, "same as paint.setFill, for the stroke (item?: the stroke item to set)", has_doc, |s, p| {
set_paint(s, p, false)
}),
cmd!("paint.swap", "Swap Fill and Stroke", [], Some("Shift+X"), "{}", 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 }))
@@ -45,6 +61,206 @@ 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",
"Apply Last Color",
[],
Some(","),
"{stroke?: bool (default: the active proxy), ids?} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default",
has_doc,
|s, p| {
let paint = Paint::solid(s.last_solid);
apply_to_proxy(s, p, paint)
}
),
cmd!(
"paint.lastGradient",
"Apply Last Gradient",
[],
Some("."),
"{stroke?: bool (default: the active proxy), ids?} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default",
has_doc,
|s, p| {
let paint = Paint::Gradient(Box::new(GradientPaint { geom: None, ..s.last_gradient.clone() }));
apply_to_proxy(s, p, paint)
}
),
cmd!(
query "paint.recent",
"Recent Colors",
[],
None,
"{} → {colors: [{hex, color}] newest first (fed by every paint command and the eyedropper), lastColor: {hex, color}, lastGradient: gradient paint}",
always,
|s, _| {
let c = |c: &Color| json!({"hex": c.to_hex(), "color": c});
Ok(json!({
"colors": s.recent_colors.iter().map(c).collect::<Vec<_>>(),
"lastColor": c(&s.last_solid),
"lastGradient": s.last_gradient,
}))
}
),
cmd!(
query "paint.proxies",
"Fill and Stroke",
[],
None,
"{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy}",
always,
proxies
),
]
}
impl Session {
/// How many colours the Recent Colors rows keep.
pub const RECENT_MAX: usize = 10;
/// Remember an applied paint: a solid colour becomes the last colour and the newest recent
/// colour, a gradient the last gradient. During a live preview this waits for the commit.
pub(crate) fn remember_paint(&mut self, p: &Paint) {
if self.in_interaction() {
self.pending_paint = Some(p.clone());
} else {
self.remember_paint_now(p);
}
}
/// The fill and stroke the Fill/Stroke proxies show: the Appearance panel's active item (in the
/// first selected object's own stack) for the proxy of its kind, else the first selected
/// object's (a group's first painted object's; type: its first run's), else the defaults for
/// new art.
pub fn proxy_paints(&self) -> (Paint, Paint) {
let default = |stroke: bool| if stroke { self.paint.stroke.clone() } else { self.paint.fill.clone() };
let Some(first) = self.active().and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))) else {
return (default(false), default(true));
};
let item = self.appearance_item();
let mut nodes = vec![];
painted(first, true, &mut nodes);
let show = |stroke: bool| match first.appearance.item_of_kind(item, !stroke) {
Some(_) => proxy_paint(first, stroke, item),
None => nodes.first().map_or_else(|| default(stroke), |n| proxy_paint(n, stroke, None)),
};
(show(false), show(true))
}
pub(crate) fn remember_paint_now(&mut self, p: &Paint) {
match p {
Paint::Solid { color, .. } => {
self.last_solid = *color;
self.recent_colors.retain(|c| c != color);
self.recent_colors.insert(0, *color);
self.recent_colors.truncate(Self::RECENT_MAX);
}
Paint::Gradient(g) => self.last_gradient = (**g).clone(),
Paint::None | Paint::Pattern { .. } => {}
}
}
}
/// The fill or stroke a proxy shows for `n` ([`Node::proxy_paint`]: fill or stroke `item` when it
/// is of that kind, type's first run, else the topmost one).
pub(crate) fn proxy_paint(n: &Node, stroke: bool, item: Option<usize>) -> Paint {
match n.proxy_paint(stroke, item) {
Some((p, ..)) => p.clone(),
// Type without runs shows the default character style.
None if matches!(n.kind, NodeKind::Text(_)) => {
let st = CharStyle::default();
if stroke { st.stroke } else { st.fill }
}
None => Paint::None,
}
}
/// Do two paints look the same in a proxy? Solid colours compare without their swatch link and
/// gradients without their per-object geometry.
fn same_in_proxy(a: &Paint, b: &Paint) -> bool {
match (a, b) {
(Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => x == y,
(Paint::Gradient(x), Paint::Gradient(y)) => x.gradient == y.gradient,
_ => a == b,
}
}
/// The objects whose paints the proxies show for a selected `n`, the ones the paint commands
/// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and
/// other containers inside them), a compound path for its parts.
fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
let expand = match n.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } => true,
NodeKind::Compound { .. } => false,
_ => !top && n.is_container(),
};
if expand {
n.children().into_iter().flatten().for_each(|c| painted(c, false, out));
} else {
out.push(n);
}
}
impl DocState {
/// Whether the selected objects' fills and strokes differ as the proxies show them (a "?"
/// proxy; a group whose contents differ counts). One walk of the document, so callers that ask
/// every frame cache it by revision.
pub fn proxy_mixed(&self) -> (bool, bool) {
if self.selection.is_empty() {
return (false, false);
}
let ids: std::collections::HashSet<NodeId> = self.selection.objects.iter().copied().collect();
let mut nodes = vec![];
self.doc.walk(|n| {
if ids.contains(&n.id) {
painted(n, true, &mut nodes);
}
});
let mut paints = nodes.iter().map(|n| (proxy_paint(n, false, None), proxy_paint(n, true, None)));
let Some((f0, s0)) = paints.next() else { return (false, false) };
let mut mixed = (false, false);
for (f, s) in paints {
mixed.0 |= !same_in_proxy(&f0, &f);
mixed.1 |= !same_in_proxy(&s0, &s);
if mixed.0 && mixed.1 {
break;
}
}
mixed
}
}
fn proxies(s: &mut Session, _: &Value) -> Result<Value> {
let (fill, stroke) = s.proxy_paints();
let (fill_mixed, stroke_mixed) = s.active().map_or((false, false), DocState::proxy_mixed);
Ok(json!({"fill": fill, "stroke": stroke, "fillActive": s.fill_active, "fillMixed": fill_mixed, "strokeMixed": stroke_mixed}))
}
/// `stroke` param, defaulting to the proxy that is in front.
fn stroke_param(s: &Session, p: &Value) -> bool {
p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active)
}
/// 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) {
@@ -79,13 +295,29 @@ 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 cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?;
apply_paint(s, p, paint, fill)
}
/// [`apply_paint`] on the proxy named by `stroke` (default: the active one), focusing it.
fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
let fill = !stroke_param(s, p);
apply_paint(s, p, paint, fill)
}
/// Set the fill (or stroke) of the targets of `p` (the `item` and `ids` params, see
/// `paint.setFill`) and of the defaults for new art, focus its proxy unless `focus` is false, and
/// remember the paint.
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
// New art gets the paint fitted to itself, not placed where this one is.
if fill {
s.paint.fill = unplaced(&paint);
} else {
s.paint.stroke = unplaced(&paint);
}
s.fill_active = fill;
if bool_or(p, "focus", true) {
s.fill_active = fill;
}
let item = item_target(s, p, cmd)?.of_kind(s, fill);
let ids = item.targets(s, p)?;
edit_items(s, &ids, item, cmd, if fill { "Fill Color" } else { "Stroke Color" }, fill, |n, index| {
@@ -94,8 +326,8 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
{
let (xf, lb) = (t.xf, t.local_bounds());
for r in &mut t.runs {
if !fill && r.style.stroke_width == 0.0 {
r.style.stroke_width = 1.0;
if !fill && !paint.is_none() {
run_stroke_weight(&mut r.style);
}
let (cur, b) = run_paint_mut(r, !fill, lb);
*cur = place_run_paint(&paint, p, xf, b);
@@ -111,20 +343,61 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
n.appearance.set_paint_at(index, fill, placed);
Ok(())
})?;
s.remember_paint(&paint);
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() {
run_stroke_weight(style);
}
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(())
@@ -133,24 +406,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, None)));
if let Some(shown) = first {
s.remember_paint(&shown);
}
Ok(r)
}
fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -159,7 +159,7 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> {
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 }))
}
+20 -1
View File
@@ -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};
@@ -450,6 +450,15 @@ pub struct Session {
/// The Appearance panel's active fill/stroke row (`appearance.setActiveItem`); not saved. Read
/// it through [`Session::appearance_item`], which drops it once the selection changes.
pub(crate) active_appearance_item: Option<cmd::appearance::ActiveItem>,
/// 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<Color>,
/// A paint applied by a live preview: remembered when the interaction commits.
pub(crate) pending_paint: Option<Paint>,
}
impl Default for Session {
@@ -477,6 +486,10 @@ impl Session {
menu: Default::default(),
gradient_stop: None,
active_appearance_item: None,
last_solid: Color::WHITE,
last_gradient: GradientPaint::new(Default::default()),
recent_colors: vec![],
pending_paint: None,
}
}
@@ -673,6 +686,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(()) };
@@ -693,6 +709,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;
@@ -743,6 +760,8 @@ mod tests_menucmds;
#[cfg(test)]
mod tests_opacitymask;
#[cfg(test)]
mod tests_paintproxy;
#[cfg(test)]
mod tests_panelcmds;
#[cfg(test)]
mod tests_pathops;
+3
View File
@@ -103,6 +103,9 @@ fn active_item_targets_paint_and_proxies_and_clears_on_selection_change() {
// The stroke edits fall back to the top stroke while a fill row is active.
run(&mut s, "paint.setStroke", json!({"color": "#00ff00"}));
assert_eq!(stroke(&node(&s, id), 1).paint.color().unwrap().to_hex(), "#00ff00");
// The proxies show the active row for its kind and the top one for the other.
let (f, st) = s.proxy_paints();
assert_eq!((f.color().unwrap().to_hex(), st.color().unwrap().to_hex()), ("#ff0000".to_string(), "#00ff00".to_string()));
// A stroke row brings the Stroke proxy forward.
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
assert!(!s.fill_active);
+275
View File
@@ -0,0 +1,275 @@
//! 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<String> {
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");
}
#[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);
}
#[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();
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");
}
#[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);
}