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:
commit
6bd27a05da
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?,
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,510 @@
|
|||
//! The Color Picker (double-click a Fill/Stroke proxy, or `ui.colorPicker`): a colour field with a
|
||||
//! channel slider for H, S, B, R, G or B; HSB, RGB, Lab and CMYK fields and hex; Only Web Colors;
|
||||
//! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; and a Color
|
||||
//! Swatches list. OK applies the colour to its proxy through `paint.setFill` / `paint.setStroke`.
|
||||
//!
|
||||
//! Fields agents set with `ui.dialog.set`: `hex` (`"00FF00"`) or `color` (any colour the paint
|
||||
//! commands take), `channel` (`hue`, `saturation`, `brightness`, `red`, `green`, `blue`), `webOnly`
|
||||
//! and `swatches`. `stroke` and `original` come from the opener.
|
||||
|
||||
use egui::{Rect, Sense, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::cms::{self, Lab};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::ColorMode;
|
||||
use vectorcraft_engine::cmd::color_value;
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe};
|
||||
use crate::panels::{c32, color_json};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
pub(super) const KIND: &str = "colorPicker";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Color Picker".into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM };
|
||||
|
||||
/// Side of the colour field (and height of the channel slider).
|
||||
const FIELD: f32 = 240.0;
|
||||
/// Width of the value fields.
|
||||
const VALUE_W: f32 = 54.0;
|
||||
|
||||
/// The component the channel slider edits; the colour field shows the other two.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Channel {
|
||||
#[default]
|
||||
Hue,
|
||||
Saturation,
|
||||
Brightness,
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub const ALL: [Channel; 6] = [Channel::Hue, Channel::Saturation, Channel::Brightness, Channel::Red, Channel::Green, Channel::Blue];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Hue => "hue",
|
||||
Channel::Saturation => "saturation",
|
||||
Channel::Brightness => "brightness",
|
||||
Channel::Red => "red",
|
||||
Channel::Green => "green",
|
||||
Channel::Blue => "blue",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|c| c.id() == s)
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Hue => "H",
|
||||
Channel::Saturation => "S",
|
||||
Channel::Brightness | Channel::Blue => "B",
|
||||
Channel::Red => "R",
|
||||
Channel::Green => "G",
|
||||
}
|
||||
}
|
||||
fn is_hsb(self) -> bool {
|
||||
matches!(self, Channel::Hue | Channel::Saturation | Channel::Brightness)
|
||||
}
|
||||
/// Indices of (slider, field x, field y) into the channel's space: [h, s, b] or [r, g, b].
|
||||
fn axes(self) -> (usize, usize, usize) {
|
||||
match self {
|
||||
Channel::Hue => (0, 1, 2),
|
||||
Channel::Saturation => (1, 0, 2),
|
||||
Channel::Brightness => (2, 0, 1),
|
||||
Channel::Red => (0, 2, 1),
|
||||
Channel::Green => (1, 2, 0),
|
||||
Channel::Blue => (2, 0, 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The picked colour with its HSB (hue in degrees), kept so the hue survives grey colours.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Picked {
|
||||
pub color: Color,
|
||||
pub hsb: [f32; 3],
|
||||
}
|
||||
|
||||
impl Picked {
|
||||
pub fn of(color: Color) -> Self {
|
||||
Self { color, hsb: color.to_hsb() }
|
||||
}
|
||||
fn from_hsb(hsb: [f32; 3]) -> Self {
|
||||
Self { color: Color::from_hsb(hsb[0], hsb[1], hsb[2]), hsb }
|
||||
}
|
||||
/// The nearest web colour when Only Web Colors is on.
|
||||
pub fn snapped(self, web: bool) -> Self {
|
||||
if web && !is_web_safe(&self.color) { Self::of(web_safe(&self.color)) } else { self }
|
||||
}
|
||||
}
|
||||
|
||||
/// The slider value and the field point (x right, y down; 0..1 each) of a colour.
|
||||
pub fn field_pos(ch: Channel, p: &Picked) -> (f32, f32, f32) {
|
||||
let v = if ch.is_hsb() { [p.hsb[0] / 360.0, p.hsb[1], p.hsb[2]] } else { p.color.to_rgb() };
|
||||
let (z, x, y) = ch.axes();
|
||||
(v[z], v[x], 1.0 - v[y])
|
||||
}
|
||||
|
||||
/// The colour at slider value `z` and field point (x, y): the inverse of [`field_pos`].
|
||||
pub fn field_pick(ch: Channel, z: f32, x: f32, y: f32) -> Picked {
|
||||
let (zi, xi, yi) = ch.axes();
|
||||
let mut v = [0.0; 3];
|
||||
v[zi] = z;
|
||||
v[xi] = x;
|
||||
v[yi] = 1.0 - y;
|
||||
let v = v.map(|c| c.clamp(0.0, 1.0));
|
||||
if ch.is_hsb() { Picked::from_hsb([v[0] * 360.0, v[1], v[2]]) } else { Picked::of(Color::rgb(v[0], v[1], v[2])) }
|
||||
}
|
||||
|
||||
/// The channel slider's track at `z`: the full spectrum for hue, else the colour with only the
|
||||
/// slider's component changing.
|
||||
pub fn slider_color(ch: Channel, z: f32, x: f32, y: f32) -> Color {
|
||||
if ch == Channel::Hue { Color::from_hsb(z * 360.0, 1.0, 1.0) } else { field_pick(ch, z, x, y).color }
|
||||
}
|
||||
|
||||
/// The colour the dialog holds: an edited `hex` wins over `color` (agents set either one).
|
||||
fn picked(d: &Dialog) -> Picked {
|
||||
let hex = d.str("hex");
|
||||
let color = match parse_hex(&hex) {
|
||||
Some(c) if hex != d.str("__hex") => c,
|
||||
_ => d.fields.get("color").and_then(color_value).unwrap_or(Color::WHITE),
|
||||
};
|
||||
let hsb = d.fields.get("__hsb").and_then(|v| serde_json::from_value::<[f32; 3]>(v.clone()).ok());
|
||||
let p = match hsb {
|
||||
Some(h) if Picked::from_hsb(h).color.to_hex() == color.to_hex() => Picked { color, hsb: h },
|
||||
_ => Picked::of(color),
|
||||
};
|
||||
p.snapped(d.bool("webOnly"))
|
||||
}
|
||||
|
||||
fn store(d: &mut Dialog, p: &Picked) {
|
||||
let hex = hex_digits(&p.color);
|
||||
d.fields.insert("color".into(), color_json(&p.color));
|
||||
d.fields.insert("hex".into(), json!(hex));
|
||||
d.fields.insert("__hex".into(), json!(hex));
|
||||
d.fields.insert("__hsb".into(), json!(p.hsb));
|
||||
}
|
||||
|
||||
/// The colour the proxy shows (a gradient's first stop; the last colour for None and patterns).
|
||||
fn proxy_color(app: &VectorcraftApp, stroke: bool) -> Color {
|
||||
let (f, s) = crate::panels::current_paints(app);
|
||||
match if stroke { s } else { f } {
|
||||
Paint::Solid { color, .. } => color,
|
||||
Paint::Gradient(g) => g.gradient.stops.first().map_or(app.session.last_solid, |s| s.color),
|
||||
Paint::None | Paint::Pattern { .. } => app.session.last_solid,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ui.colorPicker {stroke?, color?}`: open the picker for the fill (or stroke) proxy, starting
|
||||
/// from `color` or the proxy's colour.
|
||||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!app.session.fill_active);
|
||||
let color = match p.get("color") {
|
||||
Some(v) => color_value(v).ok_or_else(|| format!("bad color {v}"))?,
|
||||
None => proxy_color(app, stroke),
|
||||
};
|
||||
let mut d = Dialog::new(
|
||||
KIND,
|
||||
json!({"stroke": stroke, "original": color_json(&color), "channel": Channel::default().id(), "webOnly": false, "swatches": false}),
|
||||
);
|
||||
store(&mut d, &Picked::of(color));
|
||||
app.ui.dialog = Some(d);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let channel = Channel::parse(&d.str("channel")).unwrap_or_default();
|
||||
let web = d.bool("webOnly");
|
||||
let swatches = d.bool("swatches");
|
||||
let p = picked(d);
|
||||
let original = d.fields.get("original").and_then(color_value).unwrap_or(p.color);
|
||||
let gamut = gamut_fix(app, ui.ctx(), "picker-gamut", &p.color);
|
||||
let mut pick: Option<Picked> = None;
|
||||
let mut new_channel = None;
|
||||
let mut toggle_swatches = false;
|
||||
ui.horizontal_top(|ui| {
|
||||
if swatches {
|
||||
pick = swatch_list(app, ui, &p.color).map(Picked::of);
|
||||
} else {
|
||||
let (z, x, y) = field_pos(channel, &p);
|
||||
let at = |x: f32, y: f32| c32(&field_pick(channel, z, x, y).snapped(web).color);
|
||||
if let (Some((x, y)), _) = widgets::color_field(ui, "cp-field", vec2(FIELD, FIELD), (x, y), &at) {
|
||||
pick = Some(field_pick(channel, z, x, y));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let track = |z: f32| c32(&Picked::of(slider_color(channel, z, x, y)).snapped(web).color);
|
||||
if let (Some(z), _) = widgets::channel_slider(ui, "cp-slider", z, vec2(22.0, FIELD), &track) {
|
||||
pick = Some(field_pick(channel, z, x, y));
|
||||
}
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
if new_original_chips(ui, &p.color, &original) {
|
||||
pick = Some(Picked::of(original));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
if let Some(fix) = gamut
|
||||
&& warning_chip(ui, GAMUT_WARNING, &fix)
|
||||
{
|
||||
pick = Some(Picked::of(fix));
|
||||
}
|
||||
let ws = web_safe(&p.color);
|
||||
if !web && !is_web_safe(&p.color) && warning_chip(ui, WEB_WARNING, &ws) {
|
||||
pick = Some(Picked::of(ws));
|
||||
}
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
toggle_swatches = widgets::flat_button(ui, if swatches { "Color Models" } else { "Color Swatches" }, 110.0).clicked();
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
let (edit, ch) = value_fields(ui, &p, channel);
|
||||
pick = pick.or(edit);
|
||||
new_channel = ch;
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if widgets::check(ui, "Only Web Colors", web, true) {
|
||||
d.fields.insert("webOnly".into(), json!(!web));
|
||||
}
|
||||
if let Some(ch) = new_channel {
|
||||
d.fields.insert("channel".into(), json!(ch.id()));
|
||||
}
|
||||
if toggle_swatches {
|
||||
d.fields.insert("swatches".into(), json!(!swatches));
|
||||
}
|
||||
store(d, &pick.unwrap_or(p).snapped(d.bool("webOnly")));
|
||||
false
|
||||
}
|
||||
|
||||
/// The New (top) and Original (bottom) chips. Returns true when Original is clicked.
|
||||
fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(64.0, 60.0), Sense::hover());
|
||||
let top = Rect::from_min_max(r.min, egui::pos2(r.right(), r.center().y));
|
||||
let bottom = Rect::from_min_max(egui::pos2(r.left(), r.center().y), r.max);
|
||||
widgets::paint_chip(ui, top, &Paint::solid(*new));
|
||||
widgets::paint_chip(ui, bottom, &Paint::solid(*original));
|
||||
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
|
||||
ui.interact(top, ui.id().with("cp-new"), Sense::hover()).on_hover_text("New");
|
||||
ui.interact(bottom, ui.id().with("cp-original"), Sense::click()).on_hover_text("Original: click to restore").clicked()
|
||||
}
|
||||
|
||||
/// The HSB and RGB fields with the channel radios, hex, Lab and CMYK. Returns the edited colour
|
||||
/// and the newly chosen channel.
|
||||
fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option<Picked>, Option<Channel>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let rgb = p.color.to_rgb();
|
||||
let lab = p.color.to_lab();
|
||||
let cms = cms::active();
|
||||
let cmyk = p.color.to_cmyk_managed(cms.settings().intent);
|
||||
let mut edit = None;
|
||||
let mut chosen = None;
|
||||
egui::Grid::new("cp-values").num_columns(4).spacing([6.0, 4.0]).show(ui, |ui| {
|
||||
for i in 0..7 {
|
||||
if let Some(&ch) = Channel::ALL.get(i) {
|
||||
if widgets::radio(ui, ch.label(), ch == channel, true) {
|
||||
chosen = Some(ch);
|
||||
}
|
||||
let (v, suffix) = match i {
|
||||
0 => (p.hsb[0], "°"),
|
||||
1 | 2 => (p.hsb[i] * 100.0, "%"),
|
||||
_ => (rgb[i - 3] * 255.0, ""),
|
||||
};
|
||||
if let Some(nv) = widgets::plain_field(ui, ("cp-left", i), v as f64, suffix, 0, VALUE_W) {
|
||||
let nv = nv as f32;
|
||||
edit = Some(match i {
|
||||
0..3 => {
|
||||
let mut hsb = p.hsb;
|
||||
hsb[i] = if i == 0 { nv.clamp(0.0, 360.0) } else { (nv / 100.0).clamp(0.0, 1.0) };
|
||||
Picked::from_hsb(hsb)
|
||||
}
|
||||
_ => {
|
||||
let mut c = rgb;
|
||||
c[i - 3] = (nv / 255.0).clamp(0.0, 1.0);
|
||||
Picked::of(Color::rgb(c[0], c[1], c[2]))
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Some(c) = widgets::hex_field(ui, "cp-hex", &hex_digits(&p.color)).as_deref().and_then(parse_hex) {
|
||||
edit = Some(Picked::of(c));
|
||||
}
|
||||
let (label, v, suffix) = match i {
|
||||
0 => ("L", lab.l, ""),
|
||||
1 => ("a", lab.a, ""),
|
||||
2 => ("b", lab.b, ""),
|
||||
_ => (["C", "M", "Y", "K"][i - 3], cmyk[i - 3] * 100.0, "%"),
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new(label).size(12.5).color(t.text));
|
||||
});
|
||||
if let Some(nv) = widgets::plain_field(ui, ("cp-right", i), v as f64, suffix, 0, VALUE_W) {
|
||||
let nv = nv as f32;
|
||||
edit = Some(Picked::of(if i < 3 {
|
||||
let mut l = [lab.l, lab.a, lab.b];
|
||||
l[i] = if i == 0 { nv.clamp(0.0, 100.0) } else { nv.clamp(-128.0, 127.0) };
|
||||
cms.from_lab(Lab::new(l[0], l[1], l[2]))
|
||||
} else {
|
||||
let mut k = cmyk;
|
||||
k[i - 3] = (nv / 100.0).clamp(0.0, 1.0);
|
||||
Color::cmyk(k[0], k[1], k[2], k[3])
|
||||
}));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
(edit, chosen)
|
||||
}
|
||||
|
||||
/// The document's colour swatches as a list; returns the clicked swatch's colour.
|
||||
fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color) -> Option<Color> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut chosen = None;
|
||||
ui.allocate_ui(vec2(FIELD + 28.0, FIELD), |ui| {
|
||||
widgets::list_box(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("cp-swatches").max_height(FIELD).show(ui, |ui| {
|
||||
ui.set_width(FIELD + 24.0);
|
||||
let Some(st) = app.session.active() else {
|
||||
widgets::dim_label(ui, "Open a document to see its swatches.");
|
||||
return;
|
||||
};
|
||||
let doc = &st.doc;
|
||||
for s in doc.swatches.iter().chain(doc.swatch_groups.iter().flat_map(|g| g.swatches.iter())) {
|
||||
let Paint::Solid { color, .. } = &s.paint else { continue };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
let chip = Rect::from_min_size(r.left_center() + vec2(4.0, -7.0), vec2(14.0, 14.0));
|
||||
widgets::swatch_tile(ui, chip, &s.paint, color == current, false);
|
||||
ui.painter().text(
|
||||
egui::pos2(chip.right() + 8.0, r.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&s.name,
|
||||
egui::FontId::proportional(12.0),
|
||||
t.text,
|
||||
);
|
||||
if resp.clicked() {
|
||||
chosen = Some(*color);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
chosen
|
||||
}
|
||||
|
||||
/// OK: apply the colour to the proxy (as CMYK in a CMYK document).
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut c = picked(d).color;
|
||||
if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) {
|
||||
let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent);
|
||||
c = Color::cmyk(cy, m, y, k);
|
||||
}
|
||||
let cmd = if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" };
|
||||
super::run_and_close(app, cmd, json!({ "color": color_json(&c) }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::panels::color::in_gamut;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn fill_hex(app: &VectorcraftApp) -> String {
|
||||
crate::panels::current_paints(app).0.color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_and_slider_map_back_per_channel() {
|
||||
let p = Picked::of(Color::rgb8(51, 153, 204));
|
||||
for ch in Channel::ALL {
|
||||
let (z, x, y) = field_pos(ch, &p);
|
||||
assert_eq!(field_pick(ch, z, x, y).color.to_hex(), "#3399cc", "{ch:?}");
|
||||
assert_eq!(Channel::parse(ch.id()), Some(ch));
|
||||
}
|
||||
// Hue: x is saturation, y brightness (white at the top left, black along the bottom).
|
||||
assert_eq!(field_pick(Channel::Hue, 0.0, 0.0, 0.0).color.to_hex(), "#ffffff");
|
||||
assert_eq!(field_pick(Channel::Hue, 0.0, 1.0, 0.0).color.to_hex(), "#ff0000");
|
||||
assert_eq!(field_pick(Channel::Hue, 0.5, 1.0, 1.0).color.to_hex(), "#000000");
|
||||
// Red: the slider is red, x blue and y green (green grows upwards).
|
||||
assert_eq!(field_pick(Channel::Red, 1.0, 1.0, 0.0).color.to_hex(), "#ffffff");
|
||||
assert_eq!(field_pick(Channel::Red, 0.0, 1.0, 1.0).color.to_hex(), "#0000ff");
|
||||
assert_eq!(field_pick(Channel::Green, 1.0, 0.0, 1.0).color.to_hex(), "#00ff00");
|
||||
assert_eq!(field_pick(Channel::Blue, 0.0, 1.0, 1.0).color.to_hex(), "#ff0000");
|
||||
// The hue slider shows the spectrum whatever the colour; the others vary one component.
|
||||
assert_eq!(slider_color(Channel::Hue, 1.0 / 3.0, 0.0, 1.0).to_hex(), "#00ff00");
|
||||
assert_eq!(slider_color(Channel::Saturation, 0.0, 0.0, 0.0).to_hex(), "#ffffff");
|
||||
// A grey keeps the hue it was picked with.
|
||||
let grey = field_pick(Channel::Hue, 0.5, 0.0, 0.5);
|
||||
assert_eq!(grey.hsb[0], 180.0);
|
||||
assert_eq!(field_pos(Channel::Hue, &grey).0, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_web_colors_snaps() {
|
||||
let p = Picked::of(Color::rgb8(230, 120, 40));
|
||||
assert_eq!(p.snapped(false), p);
|
||||
assert_eq!(p.snapped(true).color.to_hex(), "#ff6633");
|
||||
assert!(is_web_safe(&p.snapped(true).color));
|
||||
let mut d = Dialog::new(KIND, json!({"color": "#e67828", "webOnly": true}));
|
||||
assert_eq!(picked(&d).color.to_hex(), "#ff6633");
|
||||
d.fields.insert("webOnly".into(), json!(false));
|
||||
assert_eq!(picked(&d).color.to_hex(), "#e67828");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamut_correction_gives_a_printable_colour() {
|
||||
let mut app = app();
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
assert!(blue.out_of_gamut());
|
||||
let fix = in_gamut(&mut app, &blue).expect("pure blue can't be printed");
|
||||
assert!(!fix.out_of_gamut(), "{fix:?}");
|
||||
assert!(matches!(fix, Color::Rgb { .. }));
|
||||
assert_eq!(in_gamut(&mut app, &Color::rgb8(128, 128, 128)), None);
|
||||
// The dialog and the Color panel ask through a cache.
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), Some(fix));
|
||||
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), Some(fix));
|
||||
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_set_hex_and_confirm_fills() {
|
||||
let mut app = app();
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(super::super::DialogKind::of(&d.kind), Some(super::super::DialogKind::ColorPicker));
|
||||
assert_eq!(d.str("hex"), "FFFFFF", "starts from the fill");
|
||||
assert!(!d.bool("stroke"));
|
||||
frame(&mut app);
|
||||
// `ui.dialog.set {field: "hex"}` then `ui.dialog.confirm`.
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00"));
|
||||
frame(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
// Without a frame in between, a set hex still wins; `color` works as well.
|
||||
app.run("ui.colorPicker", json!({"stroke": true})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("#3366CC"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(crate::panels::current_paints(&app).1.color().unwrap().to_hex(), "#3366cc");
|
||||
// The stroke proxy is in front now: the opener follows it unless told otherwise.
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
assert!(app.ui.dialog.as_ref().unwrap().bool("stroke"));
|
||||
app.run("ui.colorPicker", json!({"stroke": false})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!({"c": 0, "m": 1, "y": 1, "k": 0}));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK is kept");
|
||||
assert!(app.run("ui.colorPicker", json!({"color": "nope"})).is_err());
|
||||
// Colours take every form the paint commands take (CMYK in percent too).
|
||||
app.run("ui.colorPicker", json!({"stroke": false, "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(crate::panels::current_paints(&app).0.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mode_draws_and_cmyk_documents_get_cmyk() {
|
||||
let mut app = app();
|
||||
for fields in
|
||||
[json!({"channel": "red"}), json!({"channel": "brightness", "webOnly": true}), json!({"swatches": true}), json!({"color": "#0000ff"})]
|
||||
{
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
for (k, v) in fields.as_object().unwrap() {
|
||||
d.fields.insert(k.clone(), v.clone());
|
||||
}
|
||||
frame(&mut app);
|
||||
assert!(app.ui.dialog.is_some());
|
||||
}
|
||||
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
|
||||
app.run("ui.colorPicker", json!({"color": "#ff0000"})).unwrap();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })));
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
mod about;
|
||||
mod all_tools;
|
||||
mod artboard_options;
|
||||
mod color_picker;
|
||||
mod command;
|
||||
pub mod confirm;
|
||||
mod document_setup;
|
||||
|
|
@ -29,6 +30,7 @@ mod transform;
|
|||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub use color_picker::open as open_color_picker;
|
||||
pub use tools::open_tool_dialog;
|
||||
|
||||
use crate::state::Dialog;
|
||||
|
|
@ -137,6 +139,7 @@ registry! {
|
|||
NewSwatch: [new_swatch::KIND] => new_swatch::SPEC,
|
||||
NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC,
|
||||
GradientStop: ["gradientStop"] => gradient_stop::SPEC,
|
||||
ColorPicker: [color_picker::KIND] => color_picker::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
Paint::Gradient(_) => {
|
||||
app.run(crate::panels::paint_target(app), json!({"swatch": name}))?;
|
||||
app.run(crate::panels::proxy_cmd(app, false), json!({"swatch": name}))?;
|
||||
app.ui.open_panel = Some("gradient".into());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,6 +183,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"",
|
||||
"{swatches?: [names]} open New Color Group, from those swatches or the selected artwork (dialog `newColorGroup`, engine: swatch.newGroup)",
|
||||
),
|
||||
(
|
||||
"ui.colorPicker",
|
||||
"Color Picker…",
|
||||
"",
|
||||
"{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke",
|
||||
),
|
||||
];
|
||||
|
||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||
|
|
@ -517,6 +523,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
crate::dialogs::new_color_group::open(app, names.unwrap_or_default())
|
||||
}
|
||||
"ui.colorPicker" => crate::dialogs::open_color_picker(app, p),
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
//! Color panel: Grayscale / RGB / HSB / CMYK / Web Safe RGB sliders with colour-gradient tracks,
|
||||
//! value fields, hex field, None/Black/White chips, spectrum ramp and the Fill/Stroke proxy.
|
||||
//! When the active paint is a gradient, the sliders edit the Gradient panel's selected stop.
|
||||
//!
|
||||
//! The mode follows the colour's own model (the last mode picked comes back for colours in its
|
||||
//! model). Alt-clicking the spectrum or a chip paints the inactive proxy; Shift-clicking the
|
||||
//! spectrum cycles the modes; Shift-dragging a slider moves the others in tandem; RGB and HSB show
|
||||
//! the out-of-gamut warning; Hide Options leaves just the proxy and the spectrum.
|
||||
|
||||
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
|
||||
use super::{active_paint, color_json, live_run, paint_target, pstate, push_recent, set_pstate};
|
||||
use super::{active_paint, color_json, live_run, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, Live, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
|
@ -108,8 +113,8 @@ pub fn is_web_safe(c: &Color) -> bool {
|
|||
web_safe(c).to_hex() == c.to_hex()
|
||||
}
|
||||
|
||||
/// Track colour of slider `i` at position `t` (0..1) with the other components fixed —
|
||||
/// Illustrator's dynamic colour sliders.
|
||||
/// Track colour of slider `i` at position `t` (0..1) with the other components fixed (dynamic
|
||||
/// colour sliders).
|
||||
pub fn track_color(mode: Mode, comps: &[f32], i: usize, t: f32) -> Color {
|
||||
let mut v = comps.to_vec();
|
||||
if i < v.len() {
|
||||
|
|
@ -140,6 +145,11 @@ pub fn spectrum_at(mode: Mode, x: f32, y: f32) -> Color {
|
|||
}
|
||||
}
|
||||
|
||||
/// The hex field text of a colour (`E67828`).
|
||||
pub fn hex_digits(c: &Color) -> String {
|
||||
c.to_hex().trim_start_matches('#').to_uppercase()
|
||||
}
|
||||
|
||||
/// Parse a hex field (`E67828`, `#e67828`, `fff`).
|
||||
pub fn parse_hex(s: &str) -> Option<Color> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
|
|
@ -149,6 +159,101 @@ pub fn parse_hex(s: &str) -> Option<Color> {
|
|||
Color::from_hex(s)
|
||||
}
|
||||
|
||||
/// The out-of-web-colour warning: its icon and tooltip.
|
||||
pub(crate) const WEB_WARNING: (&str, &str) = ("dc-cube", "Out of Web Color Warning");
|
||||
/// The out-of-gamut warning: its icon and tooltip.
|
||||
pub(crate) const GAMUT_WARNING: (&str, &str) = ("dc-gamut", "Out of Gamut Warning");
|
||||
|
||||
/// A warning icon with the corrected colour beside it. Returns true when the colour is clicked.
|
||||
pub(crate) fn warning_chip(ui: &mut Ui, (icon, warning): (&str, &str), fix: &Color) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 3.0;
|
||||
icons::icon(ui, icon, 16.0, t.icon).on_hover_text(warning);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
|
||||
widgets::swatch_tile(ui, r, &Paint::solid(*fix), false, resp.hovered());
|
||||
resp.on_hover_text(format!("{warning}: click to correct to the closest color ({})", fix.to_hex())).clicked()
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// The printable colour closest to `c` (its working-CMYK reproduction, back in RGB) when `c` is out
|
||||
/// of the CMYK gamut, through `color.convert`.
|
||||
pub(crate) fn in_gamut(app: &mut VectorcraftApp, c: &Color) -> Option<Color> {
|
||||
let r = app.run("color.convert", json!({"color": color_json(c), "to": "cmyk"})).ok()?;
|
||||
if !r["outOfGamut"].as_bool()? {
|
||||
return None;
|
||||
}
|
||||
let v = |r: &serde_json::Value, i: usize| r["values"][i].as_f64();
|
||||
let ink = json!({"c": v(&r, 0)?, "m": v(&r, 1)?, "y": v(&r, 2)?, "k": v(&r, 3)?});
|
||||
let rgb = app.run("color.convert", json!({"color": ink, "to": "rgb"})).ok()?;
|
||||
Some(Color::rgb(v(&rgb, 0)? as f32, v(&rgb, 1)? as f32, v(&rgb, 2)? as f32))
|
||||
}
|
||||
|
||||
/// [`in_gamut`], cached under `key` for the last colour asked (panels ask every frame).
|
||||
pub(crate) fn gamut_fix(app: &mut VectorcraftApp, ctx: &egui::Context, key: &str, c: &Color) -> Option<Color> {
|
||||
match pstate::<Option<(Color, Option<Color>)>>(ctx, key) {
|
||||
Some((k, fix)) if k == *c => fix,
|
||||
_ => {
|
||||
let fix = in_gamut(app, c);
|
||||
set_pstate(ctx, key, Some((*c, fix)));
|
||||
fix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel mode for `color`: the one picked last (`stored`) when it shows the colour's own
|
||||
/// model (HSB and Web Safe RGB show RGB), else the colour's model.
|
||||
pub fn display_mode(stored: Option<Mode>, color: Option<&Color>) -> Mode {
|
||||
match color {
|
||||
Some(c) => stored.filter(|m| m.holds(c)).unwrap_or_else(|| Mode::of(c)),
|
||||
None => stored.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// The colour model the mode edits.
|
||||
fn model(self) -> Mode {
|
||||
match self {
|
||||
Mode::Hsb | Mode::WebSafe => Mode::Rgb,
|
||||
m => m,
|
||||
}
|
||||
}
|
||||
/// Does the mode show `c` in its own model?
|
||||
pub fn holds(self, c: &Color) -> bool {
|
||||
self.model() == Mode::of(c)
|
||||
}
|
||||
/// The next mode (Shift-click on the spectrum).
|
||||
pub fn next(self) -> Mode {
|
||||
let i = Mode::ALL.iter().position(|(m, _)| *m == self).unwrap_or(0);
|
||||
Mode::ALL[(i + 1) % Mode::ALL.len()].0
|
||||
}
|
||||
/// Can Shift-drag move the sliders in tandem? (Not HSB, and Grayscale has one slider.)
|
||||
fn tandem(self) -> bool {
|
||||
matches!(self, Mode::Rgb | Mode::WebSafe | Mode::Cmyk)
|
||||
}
|
||||
}
|
||||
|
||||
/// `c` in `mode`'s model: picking CMYK or Grayscale converts the colour, Web Safe RGB snaps it.
|
||||
pub fn convert_to(mode: Mode, c: &Color) -> Color {
|
||||
match mode {
|
||||
Mode::WebSafe => web_safe(c),
|
||||
m if m.holds(c) => *c,
|
||||
m => from_components(m.model(), &components(m.model(), c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift-drag of slider `i` to `v`: the other sliders move with it keeping their ratios, stopping
|
||||
/// where one reaches its maximum. With slider `i` at 0 the others move by the same amount.
|
||||
pub fn tandem(mode: Mode, comps: &[f32], i: usize, v: f32) -> Vec<f32> {
|
||||
let old = comps.get(i).copied().unwrap_or(0.0);
|
||||
if old <= 0.0 {
|
||||
return comps.iter().enumerate().map(|(j, c)| (c + v - old).clamp(0.0, mode.max(j))).collect();
|
||||
}
|
||||
let f = comps.iter().enumerate().filter(|(_, c)| **c > 0.0).fold((v / old).max(0.0), |f, (j, c)| f.min(mode.max(j) / c));
|
||||
comps.iter().map(|c| c * f).collect()
|
||||
}
|
||||
|
||||
fn to32(c: &Color) -> Color32 {
|
||||
super::c32(c)
|
||||
}
|
||||
|
|
@ -159,7 +264,20 @@ enum Target {
|
|||
Stop { paint: Paint, index: usize, color: Color },
|
||||
}
|
||||
|
||||
fn target(app: &VectorcraftApp) -> Target {
|
||||
impl Target {
|
||||
fn color(&self) -> Option<Color> {
|
||||
match self {
|
||||
Target::Paint(c) => *c,
|
||||
Target::Stop { color, .. } => Some(*color),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The active proxy's colour or gradient stop; no colour for None, patterns and a "?" proxy.
|
||||
fn target(app: &VectorcraftApp, ui: &Ui) -> Target {
|
||||
if super::active_mixed(app, ui.ctx()) {
|
||||
return Target::Paint(None);
|
||||
}
|
||||
let p = active_paint(app);
|
||||
match &p {
|
||||
Paint::Gradient(g) => {
|
||||
|
|
@ -172,38 +290,81 @@ fn target(app: &VectorcraftApp) -> Target {
|
|||
}
|
||||
}
|
||||
|
||||
fn apply(app: &mut VectorcraftApp, ui: &Ui, tgt: &Target, c: Color, phase: Live) {
|
||||
/// Apply `c` to the target, or with `behind` (an Alt-click) to the inactive proxy as a solid
|
||||
/// colour, keeping the active proxy in front.
|
||||
fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: bool) {
|
||||
match tgt {
|
||||
Target::Paint(_) => {
|
||||
let cmd = paint_target(app);
|
||||
live_run(app, "Color", cmd, json!({"color": color_json(&c)}), phase);
|
||||
}
|
||||
Target::Stop { paint, index, .. } => {
|
||||
if let Paint::Gradient(g) = paint {
|
||||
let mut stops = g.gradient.stops.clone();
|
||||
if let Some(s) = stops.get_mut(*index) {
|
||||
s.color = c;
|
||||
}
|
||||
let params = json!({"stroke": !app.session.fill_active, "stops": super::gradient::stops_json(&stops)});
|
||||
live_run(app, "Gradient", "paint.editGradient", params, phase);
|
||||
Target::Stop { paint: Paint::Gradient(g), index, .. } if !behind => {
|
||||
let mut stops = g.gradient.stops.clone();
|
||||
if let Some(s) = stops.get_mut(*index) {
|
||||
s.color = c;
|
||||
}
|
||||
let params = json!({"stroke": !app.session.fill_active, "stops": super::gradient::stops_json(&stops)});
|
||||
live_run(app, "Gradient", "paint.editGradient", params, phase);
|
||||
}
|
||||
_ => {
|
||||
let cmd = super::proxy_cmd(app, behind);
|
||||
live_run(app, "Color", cmd, json!({"color": color_json(&c), "focus": !behind}), phase);
|
||||
}
|
||||
}
|
||||
if phase == Live::Released {
|
||||
push_recent(ui.ctx(), c);
|
||||
}
|
||||
|
||||
/// Switch the panel to `mode`, converting the colour to its model.
|
||||
fn set_mode(app: &mut VectorcraftApp, ctx: &egui::Context, tgt: &Target, mode: Mode) {
|
||||
set_pstate(ctx, "color-mode", Some(mode));
|
||||
if let Some(c) = tgt.color() {
|
||||
let conv = convert_to(mode, &c);
|
||||
if conv != c {
|
||||
apply(app, tgt, conv, Live::Released, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What happened on the spectrum ramp.
|
||||
enum SpectrumHit {
|
||||
Pick(Color, Live),
|
||||
/// Shift-click: the next colour mode.
|
||||
Cycle,
|
||||
}
|
||||
|
||||
/// The spectrum ramp: hue across, white to full colour to black down (a grey ramp in Grayscale).
|
||||
fn spectrum(ui: &mut Ui, mode: Mode, size: egui::Vec2) -> Option<SpectrumHit> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
|
||||
let resp = ui.interact(r, ui.id().with("color-spectrum"), Sense::click_and_drag());
|
||||
ui.painter().add(widgets::color_mesh(r, (72, 12), &|x, y| to32(&spectrum_at(mode, x, y))));
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
|
||||
if let Some(p) = resp.hover_pos() {
|
||||
ui.painter().rect_stroke(Rect::from_center_size(p, vec2(5.0, 5.0)), 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
|
||||
}
|
||||
if resp.clicked() && ui.input(|i| i.modifiers.shift) {
|
||||
return Some(SpectrumHit::Cycle);
|
||||
}
|
||||
let (p, phase) = widgets::pointer_phase(&resp)?;
|
||||
Some(SpectrumHit::Pick(spectrum_at(mode, (p.x - r.left()) / r.width(), (p.y - r.top()) / r.height()), phase))
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let tgt = target(app);
|
||||
let color = match &tgt {
|
||||
Target::Paint(c) => *c,
|
||||
Target::Stop { color, .. } => Some(*color),
|
||||
};
|
||||
let stored: Option<Mode> = pstate(ui.ctx(), "color-mode");
|
||||
let mode = stored.unwrap_or_else(|| color.as_ref().map(Mode::of).unwrap_or_default());
|
||||
let show_options: bool = !pstate::<bool>(ui.ctx(), "color-hide-options");
|
||||
let tgt = target(app, ui);
|
||||
let color = tgt.color();
|
||||
let mode = display_mode(pstate(ui.ctx(), "color-mode"), color.as_ref());
|
||||
let alt = super::alt_held(ui);
|
||||
// Hide Options: just the proxy and the spectrum.
|
||||
if pstate::<bool>(ui.ctx(), "color-hide-options") {
|
||||
let hit = ui
|
||||
.horizontal(|ui| {
|
||||
super::proxy(app, ui, 40.0);
|
||||
spectrum(ui, mode, vec2(ui.available_width(), 40.0))
|
||||
})
|
||||
.inner;
|
||||
match hit {
|
||||
Some(SpectrumHit::Pick(c, phase)) => apply(app, &tgt, c, phase, alt),
|
||||
Some(SpectrumHit::Cycle) => set_mode(app, ui.ctx(), &tgt, mode.next()),
|
||||
None => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Keep the displayed components while the colour is unchanged (hue survives S = 0 etc.).
|
||||
let key = color.map(|c| (c.to_hex(), mode as u8));
|
||||
let comps: Vec<f32> = match (color, pstate::<Option<((String, u8), Vec<f32>)>>(ui.ctx(), "color-comps")) {
|
||||
|
|
@ -211,45 +372,39 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
(Some(c), _) => components(mode, &c),
|
||||
(None, _) => vec![0.0; mode.labels().len()],
|
||||
};
|
||||
if show_options {
|
||||
super::recent_colors_row(app, ui);
|
||||
widgets::divider(ui);
|
||||
}
|
||||
super::recent_colors_row(app, ui);
|
||||
widgets::divider(ui);
|
||||
// An edit of the active colour with its displayed components (`new`), or a colour clicked with
|
||||
// Alt held for the inactive proxy (`behind`).
|
||||
let mut new: Option<(Color, Live, Vec<f32>)> = None;
|
||||
let mut behind: Option<(Color, Live)> = None;
|
||||
let gamut = color.filter(|_| matches!(mode, Mode::Rgb | Mode::Hsb)).and_then(|c| gamut_fix(app, ui.ctx(), "color-gamut", &c));
|
||||
ui.horizontal(|ui| {
|
||||
// Left column: proxy + web-safe warning.
|
||||
// Left column: proxy, out-of-gamut and out-of-web warnings.
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(44.0);
|
||||
super::proxy(app, ui, 40.0);
|
||||
if let Some(c) = color
|
||||
&& mode != Mode::WebSafe
|
||||
&& mode != Mode::Grayscale
|
||||
&& !is_web_safe(&c)
|
||||
{
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 3.0;
|
||||
icons::icon(ui, "dc-cube", 16.0, t.icon).on_hover_text("Out of Web Color Warning");
|
||||
let ws = web_safe(&c);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
|
||||
widgets::swatch_tile(ui, r, &Paint::solid(ws), false, resp.hovered());
|
||||
if resp.on_hover_text(format!("Click to correct to the closest web color ({})", ws.to_hex())).clicked() {
|
||||
new = Some((ws, Live::Released, components(mode, &ws)));
|
||||
let web = color.filter(|c| !matches!(mode, Mode::WebSafe | Mode::Grayscale) && !is_web_safe(c)).map(|c| web_safe(&c));
|
||||
for (warning, fix) in [(GAMUT_WARNING, gamut), (WEB_WARNING, web)] {
|
||||
if let Some(fix) = fix {
|
||||
ui.add_space(6.0);
|
||||
if warning_chip(ui, warning, &fix) {
|
||||
new = Some((fix, Live::Released, components(mode, &fix)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
// Sliders.
|
||||
// Sliders (Shift-drag moves them in tandem).
|
||||
ui.vertical(|ui| {
|
||||
let labels = mode.labels();
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let field_w = 48.0;
|
||||
let slider_w = (ui.available_width() - field_w - 24.0).max(60.0);
|
||||
for (i, lbl) in labels.iter().enumerate() {
|
||||
let enabled = color.is_some();
|
||||
for (i, lbl) in mode.labels().iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(*lbl).size(12.5).color(t.text)));
|
||||
let max = mode.max(i);
|
||||
let v = comps.get(i).copied().unwrap_or(0.0);
|
||||
let enabled = color.is_some();
|
||||
let track = |x: f32| to32(&track_color(mode, &comps, i, x));
|
||||
let grey = |_x: f32| t.input;
|
||||
let (nv, phase) = if enabled {
|
||||
|
|
@ -260,12 +415,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
if let Some(nv) = nv
|
||||
&& enabled
|
||||
{
|
||||
let mut c2 = comps.clone();
|
||||
c2[i] = if mode == Mode::WebSafe { ((nv * 5.0).round() / 5.0) * max } else { (nv * max).round() };
|
||||
let nv = if mode == Mode::WebSafe { ((nv * 5.0).round() / 5.0) * max } else { (nv * max).round() };
|
||||
let c2 = if shift && mode.tandem() {
|
||||
tandem(mode, &comps, i, nv)
|
||||
} else {
|
||||
let mut c2 = comps.clone();
|
||||
c2[i] = nv;
|
||||
c2
|
||||
};
|
||||
new = Some((from_components(mode, &c2), phase, c2));
|
||||
}
|
||||
let dec = 0;
|
||||
if let Some(fv) = widgets::plain_field(ui, ("color-field", i), v as f64, mode.suffix(i), dec, field_w)
|
||||
if let Some(fv) = widgets::plain_field(ui, ("color-field", i), v as f64, mode.suffix(i), 0, field_w)
|
||||
&& enabled
|
||||
{
|
||||
let mut c2 = comps.clone();
|
||||
|
|
@ -277,6 +437,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
// A clicked chip or spectrum colour: the active colour, or with Alt the inactive proxy's.
|
||||
let mut pick = |c: Color, phase: Live, new: &mut Option<(Color, Live, Vec<f32>)>| {
|
||||
if alt {
|
||||
behind = Some((c, phase));
|
||||
} else {
|
||||
*new = Some((c, phase, components(mode, &c)));
|
||||
}
|
||||
};
|
||||
// None / Black / White chips and the hex field.
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
|
|
@ -285,88 +453,33 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
widgets::swatch_tile(ui, r, &p, false, resp.hovered());
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
match p.color() {
|
||||
Some(c) => new = Some((c, Live::Released, components(mode, &c))),
|
||||
None => {
|
||||
app.run(paint_target(app), json!({"none": true})).ok();
|
||||
}
|
||||
Some(c) => pick(c, Live::Released, &mut new),
|
||||
None => super::apply_click(app, ui, json!({"none": true})),
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
if matches!(mode, Mode::Rgb | Mode::WebSafe | Mode::Hsb) {
|
||||
ui.add_space((ui.available_width() - 96.0).max(4.0));
|
||||
ui.label(egui::RichText::new("#").size(15.0).color(t.text));
|
||||
let hex = color.map(|c| c.to_hex().trim_start_matches('#').to_uppercase()).unwrap_or_default();
|
||||
let id = ui.id().with("hex");
|
||||
let editing = ui.memory(|m| m.has_focus(id));
|
||||
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| hex.clone()) } else { hex.clone() };
|
||||
let resp = egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||||
.corner_radius(2)
|
||||
.inner_margin(egui::Margin::symmetric(6, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut buf)
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(62.0)
|
||||
.char_limit(7)
|
||||
.font(egui::FontId::proportional(12.5))
|
||||
.text_color(t.text_strong),
|
||||
)
|
||||
})
|
||||
.inner;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
if resp.lost_focus()
|
||||
&& buf != hex
|
||||
&& let Some(c) = parse_hex(&buf)
|
||||
{
|
||||
let hex = color.map(|c| hex_digits(&c)).unwrap_or_default();
|
||||
if let Some(c) = widgets::hex_field(ui, "hex", &hex).as_deref().and_then(parse_hex) {
|
||||
new = Some((c, Live::Released, components(mode, &c)));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
// Spectrum ramp.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click_and_drag());
|
||||
let cols = 72;
|
||||
let rows = 12;
|
||||
let (cw, rh) = (r.width() / cols as f32, r.height() / rows as f32);
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for j in 0..=rows {
|
||||
for i in 0..=cols {
|
||||
let c = spectrum_at(mode, i as f32 / cols as f32, j as f32 / rows as f32);
|
||||
mesh.colored_vertex(pos2(r.left() + i as f32 * cw, r.top() + j as f32 * rh), to32(&c));
|
||||
}
|
||||
match spectrum(ui, mode, vec2(ui.available_width(), 46.0)) {
|
||||
Some(SpectrumHit::Pick(c, phase)) => pick(c, phase, &mut new),
|
||||
Some(SpectrumHit::Cycle) => set_mode(app, ui.ctx(), &tgt, mode.next()),
|
||||
None => {}
|
||||
}
|
||||
for j in 0..rows {
|
||||
for i in 0..cols {
|
||||
let a = (j * (cols + 1) + i) as u32;
|
||||
let b = a + 1;
|
||||
let c = a + (cols + 1) as u32;
|
||||
let d = c + 1;
|
||||
mesh.add_triangle(a, b, c);
|
||||
mesh.add_triangle(b, d, c);
|
||||
}
|
||||
}
|
||||
ui.painter().add(egui::Shape::mesh(mesh));
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
|
||||
if (resp.dragged() || resp.clicked() || resp.drag_stopped())
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let c = spectrum_at(mode, (p.x - r.left()) / r.width(), (p.y - r.top()) / r.height());
|
||||
let phase = if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released };
|
||||
new = Some((c, phase, components(mode, &c)));
|
||||
}
|
||||
if resp.hovered()
|
||||
&& let Some(p) = resp.hover_pos()
|
||||
{
|
||||
ui.painter().rect_stroke(Rect::from_center_size(p, vec2(5.0, 5.0)), 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
|
||||
if let Some((c, phase)) = behind {
|
||||
apply(app, &tgt, c, phase, true);
|
||||
}
|
||||
if let Some((c, phase, comps2)) = new {
|
||||
let key = (c.to_hex(), mode as u8);
|
||||
set_pstate(ui.ctx(), "color-comps", Some((key, comps2)));
|
||||
apply(app, ui, &tgt, c, phase);
|
||||
apply(app, &tgt, c, phase, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -376,35 +489,30 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
set_pstate(ui.ctx(), "color-hide-options", !hidden);
|
||||
}
|
||||
ui.separator();
|
||||
let tgt = target(app);
|
||||
let color = match &tgt {
|
||||
Target::Paint(c) => *c,
|
||||
Target::Stop { color, .. } => Some(*color),
|
||||
};
|
||||
let cur: Option<Mode> = pstate(ui.ctx(), "color-mode");
|
||||
let cur = cur.unwrap_or_else(|| color.as_ref().map(Mode::of).unwrap_or_default());
|
||||
let tgt = target(app, ui);
|
||||
let color = tgt.color();
|
||||
let cur = display_mode(pstate(ui.ctx(), "color-mode"), color.as_ref());
|
||||
for (m, label) in Mode::ALL {
|
||||
if menu_item(ui, label, true, m == cur) {
|
||||
set_pstate(ui.ctx(), "color-mode", Some(m));
|
||||
// Converting the colour's model follows Illustrator (picking CMYK converts the colour).
|
||||
if let Some(c) = color {
|
||||
let conv = from_components(if m == Mode::Hsb { Mode::Rgb } else { m }, &components(if m == Mode::Hsb { Mode::Rgb } else { m }, &c));
|
||||
if conv != c {
|
||||
apply(app, ui, &tgt, conv, Live::Released);
|
||||
}
|
||||
}
|
||||
set_mode(app, ui.ctx(), &tgt, m);
|
||||
}
|
||||
}
|
||||
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 (and a "?" proxy) go through the proxy commands (each selected object keeps
|
||||
// its colour model); a gradient stop is recoloured in place.
|
||||
let recolor = color.is_some() || super::active_mixed(app, ui.ctx());
|
||||
for (label, cmd, f) in [
|
||||
("Invert", "paint.invert", Color::invert_keep_model as fn(&Color) -> Color),
|
||||
("Complement", "paint.complement", Color::complement_keep_model),
|
||||
] {
|
||||
if menu_item(ui, label, recolor, false) {
|
||||
match (&tgt, color) {
|
||||
(Target::Stop { .. }, Some(c)) => apply(app, &tgt, f(&c), Live::Released, false),
|
||||
_ => {
|
||||
app.run(cmd, json!({})).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "Create New Swatch…", color.is_some(), false)
|
||||
|
|
@ -422,6 +530,179 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// Run the panel for one frame with `events` and `modifiers`; returns the spectrum's rect (when
|
||||
/// the options are shown) and the panel's height.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, modifiers: egui::Modifiers) -> (Option<Rect>, f32) {
|
||||
let mut out = (None, 0.0);
|
||||
let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect();
|
||||
let input = egui::RawInput { events, ..Default::default() };
|
||||
let mut full = ctx.run_ui(input, |ui| {
|
||||
show(app, ui);
|
||||
out = (ctx.read_response(ui.id().with("color-spectrum")).map(|r| r.rect), ui.min_rect().height());
|
||||
});
|
||||
full.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
/// Click the spectrum at (x, y) (0..1 each) with `modifiers` held.
|
||||
fn click_spectrum(app: &mut VectorcraftApp, ctx: &egui::Context, (x, y): (f32, f32), modifiers: egui::Modifiers) {
|
||||
let r = frame(app, ctx, vec![], modifiers).0.expect("the spectrum is drawn");
|
||||
let pos = egui::pos2(r.left() + x * r.width(), r.top() + y * r.height());
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||||
frame(app, ctx, vec![egui::Event::PointerMoved(pos), button(true)], modifiers);
|
||||
frame(app, ctx, vec![button(false)], modifiers);
|
||||
frame(app, ctx, vec![], egui::Modifiers::NONE);
|
||||
}
|
||||
|
||||
fn paints_hex(app: &VectorcraftApp) -> (String, String) {
|
||||
let (f, s) = crate::panels::current_paints(app);
|
||||
(f.color().map(|c| c.to_hex()).unwrap_or_default(), s.color().map(|c| c.to_hex()).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tandem_keeps_ratios() {
|
||||
let t = tandem(Mode::Rgb, &[100.0, 50.0, 25.0], 0, 200.0);
|
||||
assert_eq!(t, vec![200.0, 100.0, 50.0]);
|
||||
// It stops where a component reaches its maximum.
|
||||
let t = tandem(Mode::Rgb, &[200.0, 100.0, 0.0], 1, 200.0);
|
||||
assert_eq!(t, vec![255.0, 127.5, 0.0]);
|
||||
let t = tandem(Mode::Cmyk, &[10.0, 40.0, 20.0, 5.0], 1, 20.0);
|
||||
assert_eq!(t, vec![5.0, 20.0, 10.0, 2.5]);
|
||||
// From 0 the others move by the same amount.
|
||||
assert_eq!(tandem(Mode::Rgb, &[0.0, 50.0, 250.0], 0, 10.0), vec![10.0, 60.0, 255.0]);
|
||||
assert!(Mode::Rgb.tandem() && Mode::Cmyk.tandem() && !Mode::Hsb.tandem() && !Mode::Grayscale.tandem());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mode_follows_the_colours_model() {
|
||||
let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4);
|
||||
let rgb = Color::rgb(0.2, 0.4, 0.6);
|
||||
assert_eq!(display_mode(Some(Mode::Rgb), Some(&cmyk)), Mode::Cmyk);
|
||||
assert_eq!(display_mode(Some(Mode::Hsb), Some(&rgb)), Mode::Hsb, "HSB is remembered for RGB colours");
|
||||
assert_eq!(display_mode(Some(Mode::Cmyk), Some(&rgb)), Mode::Rgb);
|
||||
assert_eq!(display_mode(Some(Mode::Rgb), Some(&Color::gray(0.5))), Mode::Grayscale);
|
||||
assert_eq!(display_mode(Some(Mode::Cmyk), None), Mode::Cmyk);
|
||||
assert_eq!(convert_to(Mode::Hsb, &rgb), rgb);
|
||||
assert!(matches!(convert_to(Mode::Cmyk, &rgb), Color::Cmyk { .. }));
|
||||
assert_eq!(convert_to(Mode::WebSafe, &rgb).to_hex(), "#336699");
|
||||
assert_eq!(Mode::Rgb.next(), Mode::Hsb);
|
||||
assert_eq!(Mode::WebSafe.next(), Mode::Grayscale);
|
||||
// In the panel: an RGB pick, then a CMYK object shows its 4 sliders.
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
set_pstate(&ctx, "color-mode", Some(Mode::Rgb));
|
||||
app.run("paint.setFill", json!({"color": {"c": 0.1, "m": 0.2, "y": 0.3, "k": 0.4}})).unwrap();
|
||||
frame(&mut app, &ctx, vec![], egui::Modifiers::NONE);
|
||||
let _ = ctx.run_ui(Default::default(), |ui| {
|
||||
let mode = display_mode(pstate(ui.ctx(), "color-mode"), target(&app, ui).color().as_ref());
|
||||
assert_eq!(mode.labels(), ["C", "M", "Y", "K"]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_blue_is_out_of_gamut_in_rgb() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("paint.setFill", json!({"color": "#0000ff"})).unwrap();
|
||||
frame(&mut app, &ctx, vec![], egui::Modifiers::NONE);
|
||||
let (of, fix) = pstate::<Option<(Color, Option<Color>)>>(&ctx, "color-gamut").expect("RGB mode checks the gamut");
|
||||
assert_eq!(of.to_hex(), "#0000ff");
|
||||
assert!(fix.is_some_and(|f| !f.out_of_gamut()));
|
||||
app.run("paint.setFill", json!({"color": "#808080"})).unwrap();
|
||||
frame(&mut app, &ctx, vec![], egui::Modifiers::NONE);
|
||||
assert_eq!(pstate::<Option<(Color, Option<Color>)>>(&ctx, "color-gamut").unwrap().1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_on_the_spectrum_paints_the_stroke() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("paint.setFill", json!({"color": "#123456"})).unwrap();
|
||||
app.run("paint.setStroke", json!({"color": "#000000", "focus": false})).unwrap();
|
||||
assert!(app.session.fill_active);
|
||||
click_spectrum(&mut app, &ctx, (0.0, 0.5), egui::Modifiers::ALT);
|
||||
assert_eq!(paints_hex(&app), ("#123456".into(), "#ff0000".into()), "the stroke changes");
|
||||
assert!(app.session.fill_active, "the fill stays in front");
|
||||
// A plain click paints the fill.
|
||||
click_spectrum(&mut app, &ctx, (0.0, 0.5), egui::Modifiers::NONE);
|
||||
assert_eq!(paints_hex(&app).0, "#ff0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_on_the_spectrum_cycles_modes() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("paint.setFill", json!({"color": "#336699"})).unwrap();
|
||||
click_spectrum(&mut app, &ctx, (0.5, 0.5), egui::Modifiers::SHIFT);
|
||||
assert_eq!(pstate::<Option<Mode>>(&ctx, "color-mode"), Some(Mode::Hsb));
|
||||
assert_eq!(paints_hex(&app).0, "#336699", "HSB keeps the RGB colour");
|
||||
click_spectrum(&mut app, &ctx, (0.5, 0.5), egui::Modifiers::SHIFT);
|
||||
assert_eq!(pstate::<Option<Mode>>(&ctx, "color-mode"), Some(Mode::Cmyk));
|
||||
assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK converts the colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_proxy_opens_the_color_picker_for_it() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
// One frame of the proxy alone; returns the stroke square's rect.
|
||||
let run = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput { time: Some(time), events, ..Default::default() };
|
||||
let mut rect = None;
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
crate::panels::proxy(app, ui, 40.0);
|
||||
rect = ctx.read_response(ui.id().with("stroke-proxy")).map(|r| r.rect);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
rect
|
||||
};
|
||||
let pos = run(&mut app, 0.0, vec![]).expect("the stroke square is drawn").right_bottom() - vec2(3.0, 3.0);
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
run(&mut app, 0.1, vec![egui::Event::PointerMoved(pos), button(true)]);
|
||||
run(&mut app, 0.15, vec![button(false)]);
|
||||
assert!(!app.session.fill_active, "a click brings the stroke to the front");
|
||||
assert!(app.ui.dialog.is_none());
|
||||
run(&mut app, 0.2, vec![button(true)]);
|
||||
run(&mut app, 0.25, vec![button(false)]);
|
||||
let d = app.ui.dialog.as_ref().expect("a double-click opens the Color Picker");
|
||||
assert_eq!(d.kind, "colorPicker");
|
||||
assert!(d.bool("stroke"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_options_leaves_the_proxy_and_spectrum() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
let (spectrum, full) = frame(&mut app, &ctx, vec![], egui::Modifiers::NONE);
|
||||
assert!(spectrum.is_some());
|
||||
set_pstate(&ctx, "color-hide-options", true);
|
||||
let (_, hidden) = frame(&mut app, &ctx, vec![], egui::Modifiers::NONE);
|
||||
assert!(hidden <= 44.0 && full > 150.0, "{hidden} / {full}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mixed_selection_has_no_colour_to_edit() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
let b = app.run("shape.rectangle", json!({"x": 80, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("paint.setFill", json!({"color": "#00ff00", "ids": [b]})).unwrap();
|
||||
frame(&mut app, &ctx, vec![], egui::Modifiers::NONE);
|
||||
assert_eq!(crate::panels::mixed_paints(&app, &ctx), (true, false));
|
||||
let _ = ctx.run_ui(Default::default(), |ui| assert_eq!(target(&app, ui).color(), None));
|
||||
app.run("paint.setFill", json!({"color": "#0000ff"})).unwrap();
|
||||
assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false), "a new revision recomputes it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_roundtrip() {
|
||||
|
|
|
|||
|
|
@ -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, apply_click, color_json, 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 {
|
||||
apply_click(app, ui, json!({"color": color_json(&c)}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ pub mod transparency;
|
|||
use egui::{Rect, Sense, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Node, StrokeLayer};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{Live, dim_label};
|
||||
|
|
@ -158,9 +158,9 @@ pub fn libraries(_app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
|
||||
// ---------- shared helpers ----------
|
||||
|
||||
/// The paint command for the active proxy (Fill or Stroke).
|
||||
pub(crate) fn paint_target(app: &VectorcraftApp) -> &'static str {
|
||||
if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" }
|
||||
/// The paint command of the active proxy (Fill or Stroke), or of the inactive one.
|
||||
pub(crate) fn proxy_cmd(app: &VectorcraftApp, inactive: bool) -> &'static str {
|
||||
if app.session.fill_active != inactive { "paint.setFill" } else { "paint.setStroke" }
|
||||
}
|
||||
|
||||
/// The first selected node, borrowed (for per-frame reads that need no copy of it).
|
||||
|
|
@ -169,25 +169,39 @@ fn first_node(app: &VectorcraftApp) -> Option<&Node> {
|
|||
st.selection.objects.first().and_then(|id| st.doc.node(*id))
|
||||
}
|
||||
|
||||
/// Fill and stroke as the proxies show them: the first selected object's, where the Appearance
|
||||
/// panel's active item stands in for the top fill or stroke of its kind (text without one shows
|
||||
/// its first run's style); else, and for a group without its own fill or stroke, the defaults
|
||||
/// for new art.
|
||||
/// Is Alt held? A click on a colour then paints the inactive proxy.
|
||||
pub(crate) fn alt_held(ui: &Ui) -> bool {
|
||||
ui.input(|i| i.modifiers.alt)
|
||||
}
|
||||
|
||||
/// Apply a clicked colour, swatch or None (`params`: `{color}`, `{swatch}` or `{none}`) to the
|
||||
/// active proxy, or with Alt held to the inactive one, which stays behind.
|
||||
pub(crate) fn apply_click(app: &mut VectorcraftApp, ui: &Ui, mut params: Value) {
|
||||
let alt = alt_held(ui);
|
||||
params["focus"] = json!(!alt);
|
||||
app.run(proxy_cmd(app, alt), params).ok();
|
||||
}
|
||||
|
||||
/// Fill and stroke as the proxies show them ([`vectorcraft_engine::Session::proxy_paints`]: 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, else the defaults for new art).
|
||||
pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) {
|
||||
let defaults = || (app.session.paint.fill.clone(), app.session.paint.stroke.clone());
|
||||
let Some(n) = first_node(app) else { return defaults() };
|
||||
let item = app.session.appearance_item();
|
||||
if item.is_none() {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let s = t.first_style();
|
||||
return (s.fill, s.stroke);
|
||||
}
|
||||
if n.is_container() && n.appearance.items.is_empty() {
|
||||
return defaults();
|
||||
app.session.proxy_paints()
|
||||
}
|
||||
|
||||
/// Whether the selected objects' fills and strokes differ (the proxies show "?"), cached per
|
||||
/// document revision.
|
||||
pub(crate) fn mixed_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (bool, bool) {
|
||||
let Some(st) = app.session.active() else { return (false, false) };
|
||||
let key = (st.uid, st.revision);
|
||||
match pstate::<Option<((u64, u64), (bool, bool))>>(ctx, "proxy-mixed") {
|
||||
Some((k, mixed)) if k == key => mixed,
|
||||
_ => {
|
||||
let mixed = st.proxy_mixed();
|
||||
set_pstate(ctx, "proxy-mixed", Some((key, mixed)));
|
||||
mixed
|
||||
}
|
||||
}
|
||||
let ap = &n.appearance;
|
||||
(ap.fill_for(item).map_or(Paint::None, |f| f.paint.clone()), ap.stroke_for(item).map_or(Paint::None, |s| s.paint.clone()))
|
||||
}
|
||||
|
||||
/// The stroke the Stroke panel, Control bar and Properties show: the Appearance panel's active
|
||||
|
|
@ -206,6 +220,12 @@ pub(crate) fn current_transparency(app: &VectorcraftApp) -> Option<(f32, BlendMo
|
|||
})
|
||||
}
|
||||
|
||||
/// Is the active proxy "?" (the selected objects' paints differ)?
|
||||
pub(crate) fn active_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
let (f, s) = mixed_paints(app, ctx);
|
||||
if app.session.fill_active { f } else { s }
|
||||
}
|
||||
|
||||
/// The paint behind the active proxy.
|
||||
pub(crate) fn active_paint(app: &VectorcraftApp) -> Paint {
|
||||
let (f, s) = current_paints(app);
|
||||
|
|
@ -215,16 +235,20 @@ pub(crate) fn active_paint(app: &VectorcraftApp) -> Paint {
|
|||
/// Draw the Fill/Stroke proxy and handle its clicks through commands.
|
||||
pub(crate) fn proxy(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
let (f, s) = current_paints(app);
|
||||
let (a, b, swap, def) = crate::widgets::fill_stroke_proxy(ui, &f, &s, app.session.fill_active, size);
|
||||
if (a && !app.session.fill_active) || (b && app.session.fill_active) {
|
||||
let mixed = mixed_paints(app, ui.ctx());
|
||||
let c = crate::widgets::fill_stroke_proxy(ui, &f, &s, mixed, app.session.fill_active, size);
|
||||
if (c.fill && !app.session.fill_active) || (c.stroke && app.session.fill_active) {
|
||||
app.run("paint.toggleActive", json!({})).ok();
|
||||
}
|
||||
if swap {
|
||||
if c.swap {
|
||||
app.run("paint.swap", json!({})).ok();
|
||||
}
|
||||
if def {
|
||||
if c.default {
|
||||
app.run("paint.default", json!({})).ok();
|
||||
}
|
||||
if let Some(stroke) = c.pick {
|
||||
app.run("ui.colorPicker", json!({ "stroke": stroke })).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `cmd` live: while dragging, preview on top of an interaction snapshot; on release, commit
|
||||
|
|
@ -256,27 +280,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 (Alt: the inactive one).
|
||||
pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
crate::widgets::subheader(ui, "Recent Colors");
|
||||
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;
|
||||
|
|
@ -288,7 +300,7 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
if let Some(c) = chosen {
|
||||
app.run(paint_target(app), json!({"color": color_json(&c)})).ok();
|
||||
apply_click(app, ui, json!({"color": color_json(&c)}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -278,7 +278,9 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
ui.painter().rect_filled(r.shrink(7.0), 0.0, t.panel);
|
||||
}
|
||||
ui.painter().rect_stroke(r.shrink(2.0), 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside);
|
||||
if resp.clicked() {
|
||||
if resp.double_clicked() {
|
||||
app.run("ui.colorPicker", json!({ "stroke": !is_fill })).ok();
|
||||
} else if resp.clicked() {
|
||||
app.session.fill_active = is_fill;
|
||||
app.ui.open_panel = Some("swatches".into());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item, swatch_tile};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
|
@ -121,22 +121,15 @@ fn entries(app: &VectorcraftApp, kind: Kind) -> Vec<Entry> {
|
|||
out
|
||||
}
|
||||
|
||||
/// Apply a clicked swatch to the active proxy (Alt: the inactive one).
|
||||
fn apply(app: &mut VectorcraftApp, ui: &Ui, 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);
|
||||
}
|
||||
}
|
||||
Entry::Folder(_) => {}
|
||||
}
|
||||
let params = match e {
|
||||
Entry::Registration => json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}}),
|
||||
Entry::Swatch { paint, .. } if paint.is_none() => json!({"none": true}),
|
||||
Entry::Swatch { name, .. } => json!({"swatch": name}),
|
||||
Entry::Folder(_) => return,
|
||||
};
|
||||
super::apply_click(app, ui, params);
|
||||
}
|
||||
|
||||
/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size).
|
||||
|
|
|
|||
|
|
@ -158,9 +158,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) {
|
||||
|
|
@ -254,6 +255,20 @@ mod tests {
|
|||
assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comma_and_period_reapply_the_last_colour_and_gradient() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app.session.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||||
app.session.execute("paint.setFill", &json!({"color": "#336699"})).unwrap();
|
||||
let fill = |app: &VectorcraftApp| crate::panels::current_paints(app).0;
|
||||
frame(&mut app, vec![egui::Event::Text(".".into())]);
|
||||
assert!(matches!(fill(&app), vectorcraft_color::Paint::Gradient(_)), "`.` applies the last gradient");
|
||||
frame(&mut app, vec![egui::Event::Text(",".into())]);
|
||||
assert_eq!(fill(&app).color().unwrap().to_hex(), "#336699", "`,` applies the last colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses() {
|
||||
let s = parse("Cmd+Shift+]").unwrap();
|
||||
|
|
|
|||
|
|
@ -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,43 +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) = crate::panels::current_paints(app);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -197,30 +197,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 {
|
||||
|
|
@ -231,8 +208,48 @@ pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the user did on a [`fill_stroke_proxy`].
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProxyClicks {
|
||||
pub fill: bool,
|
||||
pub stroke: bool,
|
||||
pub swap: bool,
|
||||
pub default: bool,
|
||||
/// Double-clicked a square: open the Color Picker for it (`true` = the stroke).
|
||||
pub pick: Option<bool>,
|
||||
}
|
||||
|
||||
/// The Fill/Stroke proxy pair (two overlapping squares). `mixed` (fill, stroke) draws a "?" square
|
||||
/// for a selection whose objects differ.
|
||||
pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool, bool), fill_active: bool, size: f32) -> ProxyClicks {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
|
||||
let s = size * 0.62;
|
||||
|
|
@ -240,16 +257,31 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active:
|
|||
let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s));
|
||||
let stroke_resp = ui.interact(stroke_r, ui.id().with("stroke-proxy"), Sense::click());
|
||||
let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click());
|
||||
let question = |ui: &Ui, r: Rect, color: Color32| {
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(r.height() * 0.8), color);
|
||||
};
|
||||
let draw_fill = |ui: &Ui| {
|
||||
paint_chip(ui, fill_r, fill);
|
||||
if mixed.0 {
|
||||
ui.painter().rect_filled(fill_r, 0.0, Color32::WHITE);
|
||||
question(ui, fill_r, Color32::BLACK);
|
||||
} else {
|
||||
paint_chip(ui, fill_r, fill);
|
||||
}
|
||||
ui.painter().rect_stroke(fill_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside);
|
||||
ui.painter().rect_stroke(fill_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
|
||||
};
|
||||
let draw_stroke = |ui: &Ui| {
|
||||
let w = s * 0.28;
|
||||
paint_chip(ui, stroke_r, stroke);
|
||||
if mixed.1 {
|
||||
ui.painter().rect_filled(stroke_r, 0.0, Color32::WHITE);
|
||||
} else {
|
||||
paint_chip(ui, stroke_r, stroke);
|
||||
}
|
||||
let inner = stroke_r.shrink(w);
|
||||
ui.painter().rect_filled(inner, 0.0, t.panel);
|
||||
if mixed.1 {
|
||||
question(ui, inner, t.text_strong);
|
||||
}
|
||||
ui.painter().rect_stroke(stroke_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside);
|
||||
ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Outside);
|
||||
ui.painter().rect_stroke(stroke_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
|
||||
|
|
@ -273,12 +305,20 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active:
|
|||
ui.painter().rect_stroke(b, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside);
|
||||
ui.painter().rect_filled(a, 0.0, Color32::WHITE);
|
||||
ui.painter().rect_stroke(a, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside);
|
||||
(
|
||||
fill_resp.on_hover_text("Fill (X)").clicked(),
|
||||
stroke_resp.on_hover_text("Stroke (X)").clicked(),
|
||||
swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(),
|
||||
def.on_hover_text("Default Fill and Stroke (D)").clicked(),
|
||||
)
|
||||
let pick = if fill_resp.double_clicked() {
|
||||
Some(false)
|
||||
} else if stroke_resp.double_clicked() {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
ProxyClicks {
|
||||
fill: fill_resp.on_hover_text("Fill (X), double-click for the Color Picker").clicked(),
|
||||
stroke: stroke_resp.on_hover_text("Stroke (X), double-click for the Color Picker").clicked(),
|
||||
swap: swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(),
|
||||
default: def.on_hover_text("Default Fill and Stroke (D)").clicked(),
|
||||
pick,
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact dropdown. Returns the chosen index.
|
||||
|
|
@ -648,12 +688,7 @@ pub fn color_slider(
|
|||
let (rect, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover());
|
||||
let bar = Rect::from_min_size(pos2(rect.left() + 4.0, rect.top() + 4.0), vec2(width - 8.0, 7.0));
|
||||
let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag());
|
||||
let n = (bar.width() / 2.0).ceil().max(2.0) as usize;
|
||||
let seg = bar.width() / n as f32;
|
||||
for i in 0..n {
|
||||
let r = Rect::from_min_size(pos2(bar.left() + i as f32 * seg, bar.top()), vec2(seg + 0.6, bar.height()));
|
||||
ui.painter().rect_filled(r, 0.0, track((i as f32 + 0.5) / n as f32));
|
||||
}
|
||||
gradient_strip(ui, bar, (bar.width() / 2.0).ceil().max(2.0) as usize, false, track);
|
||||
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
|
||||
let v = value.clamp(0.0, 1.0);
|
||||
let x = bar.left() + v * bar.width();
|
||||
|
|
@ -661,15 +696,141 @@ pub fn color_slider(
|
|||
let thumb = vec![tip, pos2(x + 5.5, tip.y + 6.0), pos2(x + 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 6.0)];
|
||||
let fill = if resp.dragged() || resp.hovered() { Color32::WHITE } else { t.icon };
|
||||
ui.painter().add(egui::Shape::convex_polygon(thumb, fill, Stroke::new(1.0, t.border)));
|
||||
let mut out = None;
|
||||
let mut phase = Live::Idle;
|
||||
if (resp.dragged() || resp.clicked() || resp.drag_stopped())
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
out = Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0));
|
||||
phase = if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released };
|
||||
match pointer_phase(&resp) {
|
||||
Some((p, phase)) => (Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)), phase),
|
||||
None => (None, Live::Idle),
|
||||
}
|
||||
(out, phase)
|
||||
}
|
||||
|
||||
/// The pointer of a click or drag on `resp` and its phase (dragging, or released on click/drop).
|
||||
pub(crate) fn pointer_phase(resp: &Response) -> Option<(Pos2, Live)> {
|
||||
if !(resp.dragged() || resp.clicked() || resp.drag_stopped()) {
|
||||
return None;
|
||||
}
|
||||
let p = resp.interact_pointer_pos()?;
|
||||
Some((p, if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released }))
|
||||
}
|
||||
|
||||
/// A gradient strip into `rect`: `n` bands along x (or up y when `vertical`), each filled with
|
||||
/// `track(t)` at its centre.
|
||||
fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn(f32) -> Color32) {
|
||||
let len = if vertical { rect.height() } else { rect.width() };
|
||||
let seg = len / n as f32;
|
||||
for i in 0..n {
|
||||
let r = if vertical {
|
||||
Rect::from_min_size(pos2(rect.left(), rect.bottom() - (i + 1) as f32 * seg), vec2(rect.width(), seg + 0.6))
|
||||
} else {
|
||||
Rect::from_min_size(pos2(rect.left() + i as f32 * seg, rect.top()), vec2(seg + 0.6, rect.height()))
|
||||
};
|
||||
ui.painter().rect_filled(r, 0.0, track((i as f32 + 0.5) / n as f32));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Color Picker's 2D colour field: `color_at(x, y)` gives the colour at a point (0..1 each, y
|
||||
/// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase.
|
||||
pub fn color_field(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash + std::fmt::Debug,
|
||||
size: Vec2,
|
||||
pos: (f32, f32),
|
||||
color_at: &dyn Fn(f32, f32) -> Color32,
|
||||
) -> (Option<(f32, f32)>, Live) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
|
||||
let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag());
|
||||
let painter = ui.painter_at(rect.expand(6.0));
|
||||
painter.add(color_mesh(rect, (32, 32), color_at));
|
||||
painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
|
||||
let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height());
|
||||
painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK));
|
||||
painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE));
|
||||
match pointer_phase(&resp) {
|
||||
Some((p, phase)) => {
|
||||
let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0);
|
||||
(Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase)
|
||||
}
|
||||
None => (None, Live::Idle),
|
||||
}
|
||||
}
|
||||
|
||||
/// A smoothly shaded colour area: a `cols` × `rows` grid over `rect` with `color_at(x, y)` (0..1
|
||||
/// each, y down) at its vertices.
|
||||
pub fn color_mesh(rect: Rect, (cols, rows): (u32, u32), color_at: &dyn Fn(f32, f32) -> Color32) -> egui::Shape {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let row = cols + 1;
|
||||
mesh.reserve_vertices((row * (rows + 1)) as usize);
|
||||
mesh.reserve_triangles((cols * rows * 2) as usize);
|
||||
for j in 0..=rows {
|
||||
for i in 0..=cols {
|
||||
let (x, y) = (i as f32 / cols as f32, j as f32 / rows as f32);
|
||||
mesh.colored_vertex(pos2(rect.left() + x * rect.width(), rect.top() + y * rect.height()), color_at(x, y));
|
||||
}
|
||||
}
|
||||
for j in 0..rows {
|
||||
for i in 0..cols {
|
||||
let a = j * row + i;
|
||||
mesh.add_triangle(a, a + 1, a + row);
|
||||
mesh.add_triangle(a + 1, a + row + 1, a + row);
|
||||
}
|
||||
}
|
||||
egui::Shape::mesh(mesh)
|
||||
}
|
||||
|
||||
/// The Color Picker's vertical channel slider: `track(t)` gives the colour at 0..1 (bottom to top)
|
||||
/// and arrows on both sides mark `value`. Returns the new value and the phase.
|
||||
pub fn channel_slider(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash + std::fmt::Debug,
|
||||
value: f32,
|
||||
size: Vec2,
|
||||
track: &dyn Fn(f32) -> Color32,
|
||||
) -> (Option<f32>, Live) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
|
||||
let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag());
|
||||
let bar = rect.shrink2(vec2(6.0, 0.0));
|
||||
gradient_strip(ui, bar, (bar.height() / 2.0).ceil().max(2.0) as usize, true, track);
|
||||
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
|
||||
let y = bar.bottom() - value.clamp(0.0, 1.0) * bar.height();
|
||||
let fill = if resp.dragged() || resp.hovered() { t.text_strong } else { t.icon };
|
||||
for (x, d) in [(rect.left(), 1.0), (rect.right(), -1.0)] {
|
||||
let tip = pos2(x + d * 5.0, y);
|
||||
ui.painter().add(egui::Shape::convex_polygon(vec![tip, pos2(x, y + 4.0 * d), pos2(x, y - 4.0 * d)], fill, Stroke::NONE));
|
||||
}
|
||||
match pointer_phase(&resp) {
|
||||
Some((p, phase)) => (Some(((bar.bottom() - p.y) / bar.height()).clamp(0.0, 1.0)), phase),
|
||||
None => (None, Live::Idle),
|
||||
}
|
||||
}
|
||||
|
||||
/// A hex colour field (`E67828`, no `#`) after a `#` label. Returns the text on commit when it
|
||||
/// changed.
|
||||
pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, hex: &str) -> Option<String> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new("#").size(15.0).color(t.text));
|
||||
let id = ui.id().with(id);
|
||||
let editing = ui.memory(|m| m.has_focus(id));
|
||||
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() };
|
||||
let resp = egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||||
.corner_radius(2)
|
||||
.inner_margin(egui::Margin::symmetric(6, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut buf)
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(62.0)
|
||||
.char_limit(7)
|
||||
.font(egui::FontId::proportional(12.5))
|
||||
.text_color(t.text_strong),
|
||||
)
|
||||
})
|
||||
.inner;
|
||||
let commit = resp.lost_focus() && buf != hex;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
commit.then_some(buf)
|
||||
}
|
||||
|
||||
/// One swatch tile (Illustrator: 1 px dark frame, white inset on hover/selection).
|
||||
|
|
|
|||
|
|
@ -34,3 +34,5 @@ New swatches and colour groups: `ui.newSwatch {spot?, group?}` opens the `newSwa
|
|||
fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern only takes `name`) and
|
||||
`ui.newColorGroup {swatches?}` opens `newColorGroup` (fields `name`, `fromArtwork`, `toGlobal`, `includeTints`,
|
||||
`swatches`); `ui.dialog.confirm` runs `swatch.new` / `swatch.newGroup`.
|
||||
|
||||
The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`.
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ 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.
|
||||
`paint.proxies` returns what the proxies show: the fill and stroke, which one is active, and whether the selected
|
||||
objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" proxy).
|
||||
|
||||
| Tool | Arguments | Notes |
|
||||
|---|---|---|
|
||||
|
|
@ -72,7 +77,8 @@ Appearance stacks: an object can carry several fills and strokes (`appearance.ad
|
|||
in paint order (0 is painted first, the bottom row of the Appearance panel). `paint.setFill`, `paint.setStroke`,
|
||||
`stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and `transparency.set` take
|
||||
`item` to edit one of them; `run_command appearance.setActiveItem {"index": n}` makes that row the target of later
|
||||
calls that omit `item` (as clicking the row in the Appearance panel does) until the selection changes.
|
||||
calls that omit `item` (as clicking the row in the Appearance panel does) until the selection changes; the proxy of
|
||||
its kind (`paint.proxies`) and the Gradient tool's annotator then show that row.
|
||||
`inspect_document` reports it as `paint.appearanceItem`. Live effects take the same `item` to apply to one fill or
|
||||
stroke instead of the whole object: `run_command effect.apply {"effect": "path.offsetPath", "item": 0}`, and
|
||||
`effect.remove`, `effect.setParams` (`visible` toggles one) and `effect.duplicate` address that item's effects;
|
||||
|
|
|
|||
Loading…
Reference in New Issue