mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
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:
co-authored by
Claude Opus 5.5
parent
8f9d6ef2bd
commit
8a871d98a9
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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<Color>,
|
||||
/// A paint applied by a live preview: remembered when the interaction commits.
|
||||
pub(crate) pending_paint: Option<Paint>,
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<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");
|
||||
}
|
||||
Reference in New Issue
Block a user