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:
parent
8f9d6ef2bd
commit
8a871d98a9
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -229,27 +229,15 @@ pub(crate) fn set_pstate<T: Clone + Send + Sync + 'static>(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<Color> {
|
||||
pstate::<Vec<Color>>(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;
|
||||
|
|
|
|||
|
|
@ -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<Entry> {
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|---|---|---|
|
||||
|
|
|
|||
Loading…
Reference in New Issue