M3.28: Color panel behaviour — the mode follows the colour's own model (the last mode picked returns for colours in its model), Alt-clicking the spectrum, None/Black/White, swatches, recent colours or Color Guide chips paints the inactive proxy, Hide Options leaves the proxy and spectrum, RGB/HSB show the out-of-gamut warning (click to correct), Shift-clicking the spectrum cycles modes, Shift-dragging a slider moves the others in tandem, and a mixed selection draws "?" proxies (paint.proxies reports fillMixed/strokeMixed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f21161ce8c
commit
6d2dc7cbbc
|
|
@ -5,7 +5,7 @@ use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
|
|||
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind};
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
use crate::{DocState, EngineError};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
|
@ -118,6 +118,15 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
|||
}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "paint.proxies",
|
||||
"Fill and Stroke",
|
||||
[],
|
||||
None,
|
||||
"{} → {fill, stroke: the paints the Fill/Stroke proxies show (the first selected object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' fills (strokes) differ, shown as a \"?\" proxy}",
|
||||
always,
|
||||
proxies
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +170,53 @@ pub(crate) fn proxy_paint(n: &Node, stroke: bool) -> Paint {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
/// Whether the selected objects' fills and strokes differ as the proxies show them (a "?"
|
||||
/// proxy). 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.len() < 2 {
|
||||
return (false, false);
|
||||
}
|
||||
let ids: std::collections::HashSet<NodeId> = self.selection.objects.iter().copied().collect();
|
||||
let mut first: Option<(Paint, Paint)> = None;
|
||||
let mut mixed = (false, false);
|
||||
self.doc.walk(|n| {
|
||||
if (mixed.0 && mixed.1) || !ids.contains(&n.id) {
|
||||
return;
|
||||
}
|
||||
let (f, s) = (proxy_paint(n, false), proxy_paint(n, true));
|
||||
match &first {
|
||||
Some((f0, s0)) => {
|
||||
mixed.0 |= !same_in_proxy(f0, &f);
|
||||
mixed.1 |= !same_in_proxy(s0, &s);
|
||||
}
|
||||
None => first = Some((f, s)),
|
||||
}
|
||||
});
|
||||
mixed
|
||||
}
|
||||
}
|
||||
|
||||
fn proxies(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc().ok();
|
||||
let (fill, stroke) = match st.and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))) {
|
||||
Some(n) => (proxy_paint(n, false), proxy_paint(n, true)),
|
||||
None => (s.paint.fill.clone(), s.paint.stroke.clone()),
|
||||
};
|
||||
let (fill_mixed, stroke_mixed) = st.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)
|
||||
|
|
|
|||
|
|
@ -170,3 +170,28 @@ fn focus_false_keeps_the_active_proxy() {
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use vectorcraft_color::{Color, Paint};
|
|||
use vectorcraft_doc::ColorMode;
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, hex_digits, in_gamut, is_web_safe, parse_hex, warning_chip, web_safe};
|
||||
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_from_json, color_json};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
|
|
@ -177,24 +177,13 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The cached out-of-gamut correction of `c` (`color.convert` runs once per colour).
|
||||
fn gamut_fix(app: &mut VectorcraftApp, d: &mut Dialog, c: &Color) -> Option<Color> {
|
||||
let key = color_json(c);
|
||||
if d.fields.get("__gamutOf") != Some(&key) {
|
||||
let fix = in_gamut(app, c).map_or(Value::Null, |f| color_json(&f));
|
||||
d.fields.insert("__gamut".into(), fix);
|
||||
d.fields.insert("__gamutOf".into(), key);
|
||||
}
|
||||
d.fields.get("__gamut").and_then(color_from_json)
|
||||
}
|
||||
|
||||
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_from_json).unwrap_or(p.color);
|
||||
let gamut = gamut_fix(app, d, &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;
|
||||
|
|
@ -389,6 +378,7 @@ 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());
|
||||
|
|
@ -455,10 +445,11 @@ mod tests {
|
|||
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 offers it and clicking it would pick it.
|
||||
app.run("ui.colorPicker", json!({"color": "#0000ff"})).unwrap();
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!(gamut_fix(&mut app, &mut d, &blue), Some(fix));
|
||||
// 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]
|
||||
|
|
|
|||
|
|
@ -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, 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() {
|
||||
|
|
@ -185,6 +190,70 @@ pub(crate) fn in_gamut(app: &mut VectorcraftApp, c: &Color) -> Option<Color> {
|
|||
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)
|
||||
}
|
||||
|
|
@ -195,7 +264,20 @@ enum Target {
|
|||
Stop { paint: Paint, index: usize, color: Color },
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
|
@ -208,35 +290,81 @@ fn target(app: &VectorcraftApp, ui: &Ui) -> Target {
|
|||
}
|
||||
}
|
||||
|
||||
fn apply(app: &mut VectorcraftApp, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, ui);
|
||||
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 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")) {
|
||||
|
|
@ -244,39 +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);
|
||||
let ws = web_safe(&c);
|
||||
if warning_chip(ui, WEB_WARNING, &ws) {
|
||||
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 {
|
||||
|
|
@ -287,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();
|
||||
|
|
@ -304,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;
|
||||
|
|
@ -312,10 +453,8 @@ 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})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -329,46 +468,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
});
|
||||
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, &tgt, c, phase);
|
||||
apply(app, &tgt, c, phase, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,39 +490,27 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
ui.separator();
|
||||
let tgt = target(app, ui);
|
||||
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 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, &tgt, conv, Live::Released);
|
||||
}
|
||||
}
|
||||
set_mode(app, ui.ctx(), &tgt, m);
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
// Solid colours go through the proxy commands (each selected object keeps its colour model);
|
||||
// a gradient stop is recoloured in place.
|
||||
// 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, color.is_some(), false)
|
||||
&& let Some(c) = color
|
||||
{
|
||||
match &tgt {
|
||||
Target::Paint(_) => {
|
||||
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();
|
||||
}
|
||||
Target::Stop { .. } => apply(app, &tgt, f(&c), Live::Released),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -431,6 +530,151 @@ 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 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, set_pstate};
|
||||
use super::{active_paint, apply_click, color_json, pstate, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::widgets::{self, menu_item};
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ pub fn show(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)}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -159,9 +159,22 @@ 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" }
|
||||
}
|
||||
|
||||
/// 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: the first selected object's (text uses its first
|
||||
|
|
@ -179,6 +192,27 @@ pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
|
@ -188,7 +222,8 @@ 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 c = crate::widgets::fill_stroke_proxy(ui, &f, &s, app.session.fill_active, size);
|
||||
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();
|
||||
}
|
||||
|
|
@ -233,7 +268,7 @@ pub(crate) fn set_pstate<T: Clone + Send + Sync + 'static>(ctx: &egui::Context,
|
|||
}
|
||||
|
||||
/// "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.
|
||||
/// 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");
|
||||
|
|
@ -252,7 +287,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)}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, 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};
|
||||
|
|
@ -112,18 +112,15 @@ fn entries(app: &VectorcraftApp, kind: Kind) -> Vec<Entry> {
|
|||
out
|
||||
}
|
||||
|
||||
fn apply(app: &mut VectorcraftApp, e: &Entry) {
|
||||
let target = paint_target(app);
|
||||
match e {
|
||||
Entry::Registration => {
|
||||
app.run(target, json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}})).ok();
|
||||
}
|
||||
Entry::Swatch { name, paint, .. } => {
|
||||
let params = if paint.is_none() { json!({"none": true}) } else { json!({"swatch": name}) };
|
||||
app.run(target, params).ok();
|
||||
}
|
||||
Entry::Folder(_) => {}
|
||||
}
|
||||
/// Apply a clicked swatch to the active proxy (Alt: the inactive one).
|
||||
fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) {
|
||||
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).
|
||||
|
|
@ -339,7 +336,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
Entry::Folder(n) => n.clone(),
|
||||
};
|
||||
set_pstate(ui.ctx(), "swatch-selected", Some(name));
|
||||
apply(app, &e);
|
||||
apply(app, ui, &e);
|
||||
}
|
||||
bottom(app, ui);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,8 +233,9 @@ pub struct ProxyClicks {
|
|||
pub pick: Option<bool>,
|
||||
}
|
||||
|
||||
/// The Fill/Stroke proxy pair (two overlapping squares).
|
||||
pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: bool, size: f32) -> ProxyClicks {
|
||||
/// 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;
|
||||
|
|
@ -242,16 +243,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);
|
||||
|
|
@ -553,7 +569,7 @@ pub fn color_slider(
|
|||
}
|
||||
|
||||
/// The pointer of a click or drag on `resp` and its phase (dragging, or released on click/drop).
|
||||
fn pointer_phase(resp: &Response) -> Option<(Pos2, Live)> {
|
||||
pub(crate) fn pointer_phase(resp: &Response) -> Option<(Pos2, Live)> {
|
||||
if !(resp.dragged() || resp.clicked() || resp.drag_stopped()) {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -585,29 +601,11 @@ pub fn color_field(
|
|||
pos: (f32, f32),
|
||||
color_at: &dyn Fn(f32, f32) -> Color32,
|
||||
) -> (Option<(f32, f32)>, Live) {
|
||||
const CELLS: u32 = 32;
|
||||
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 mut mesh = egui::Mesh::default();
|
||||
let row = CELLS + 1;
|
||||
mesh.reserve_vertices((row * row) as usize);
|
||||
mesh.reserve_triangles((CELLS * CELLS * 2) as usize);
|
||||
for j in 0..row {
|
||||
for i in 0..row {
|
||||
let (x, y) = (i as f32 / CELLS as f32, j as f32 / CELLS as f32);
|
||||
mesh.colored_vertex(pos2(rect.left() + x * rect.width(), rect.top() + y * rect.height()), color_at(x, y));
|
||||
}
|
||||
}
|
||||
for j in 0..CELLS {
|
||||
for i in 0..CELLS {
|
||||
let a = j * row + i;
|
||||
mesh.add_triangle(a, a + 1, a + row);
|
||||
mesh.add_triangle(a + 1, a + row + 1, a + row);
|
||||
}
|
||||
}
|
||||
let painter = ui.painter_at(rect.expand(6.0));
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
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));
|
||||
|
|
@ -621,6 +619,29 @@ pub fn color_field(
|
|||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ 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 |
|
||||
|---|---|---|
|
||||
|
|
|
|||
Loading…
Reference in New Issue