Merge pull request #4532 from dotCooCoo/fix/issue-3137

Part of #3137: Color Guide takes its base from colors chosen in Color, Swatches and the Color Picker
This commit is contained in:
BFlat
2026-10-29 15:11:46 +07:00
committed by GitHub
12 changed files with 219 additions and 27 deletions
@@ -45,7 +45,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(10.0);
// Preview: the base colour's row of variations.
let o = options(app, d);
let base = base_color(app, ui.ctx());
let base = base_color(app);
let n = o.steps as i32;
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
let w = row.width() / (2 * n + 1) as f32;
+9 -2
View File
@@ -3,7 +3,8 @@
//! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; an eyedropper
//! button that samples a color anywhere on the screen ([`crate::screen_color`]); and a Color
//! Swatches list. With art selected the colour shows on it live as it is picked; OK keeps it as one
//! undo step through `paint.setFill` / `paint.setStroke`, Cancel puts the art back.
//! undo step through `paint.setFill` / `paint.setStroke` and makes the color the Color Guide's
//! base color; Cancel puts the art back.
//! Opened for an artboard (Properties › Artboards › Fill), it shows the color on the artboard's
//! paper live, and OK keeps it as one undo step through `artboard.setProps {index, color}`.
//! Opened over Recolor Artwork (double-click a row's new color), its OK sets that row's new color
@@ -636,7 +637,13 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
return Ok(Value::Null);
}
let (cmd, params) = paint_command(app, d);
super::form::commit_preview(app, cmd, params)
let c = in_document_model(app, picked(d).color);
let r = super::form::commit_preview(app, cmd, params)?;
// A color picked for the fill or stroke also becomes the Color Guide's base color.
if !d.fields.contains_key("artboard") {
crate::panels::color_guide::choose_base(app, c);
}
Ok(r)
}
#[cfg(test)]
@@ -275,6 +275,30 @@ fn open_set_hex_and_confirm_fills() {
assert_eq!(crate::panels::current_paints(&app).0.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0)));
}
/// OK makes the picked fill or stroke color the Color Guide's base color (#3137); Cancel and a
/// color picked for an artboard leave the base as it is.
#[test]
fn ok_makes_the_picked_color_the_color_guides_base() {
let mut app = app();
let base = |app: &VectorcraftApp| crate::panels::color_guide::base_color(app).to_hex();
app.ui.color_guide_base = Some(Color::WHITE);
app.run("ui.colorPicker", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("2050a0"));
super::super::confirm(&mut app).unwrap();
assert_eq!(base(&app), "#2050a0");
app.run("ui.colorPicker", json!({"stroke": true})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("a02050"));
frame(&mut app);
super::super::cancel(&mut app);
assert_eq!(base(&app), "#2050a0", "Cancel keeps the base");
app.run("select.set", json!({"ids": []})).unwrap();
app.run("artboard.new", json!({})).unwrap();
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00"));
super::super::confirm(&mut app).unwrap();
assert_eq!(base(&app), "#2050a0", "an artboard's color leaves the base");
}
/// #1692: Tab goes through one color model's fields before the next: H, S, B, then R, G, B, the
/// hex field, then L, a, b, then C, M, Y, K. Each Lab or CMYK field is level with the field on
/// its left.
+15 -3
View File
@@ -432,6 +432,15 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind:
}
}
/// [`apply`] a color chosen in the panel; once the pick ends, it also becomes the Color Guide's
/// base color.
fn choose(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: bool) {
if phase == Live::Released {
super::color_guide::choose_base(app, c);
}
apply(app, tgt, c, phase, behind);
}
/// Set the target's tint of its swatch to `t` (0..1), keeping the link (the T slider).
fn apply_tint(app: &mut VectorcraftApp, tgt: &Target, tint: &Tint, t: f32, phase: Live) {
match tgt {
@@ -580,7 +589,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
})
.inner;
match hit {
Some(SpectrumHit::Pick(c, phase)) => apply(app, &tgt, c, phase, alt),
Some(SpectrumHit::Pick(c, phase)) => choose(app, &tgt, c, phase, alt),
Some(SpectrumHit::Cycle) => set_mode(app, &tgt, mode.next()),
Some(SpectrumHit::Recolor { invert }) => recolor(app, ui, &tgt, invert, alt),
None => {}
@@ -718,15 +727,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
None => {}
}
if let Some((c, phase)) = behind {
apply(app, &tgt, c, phase, true);
choose(app, &tgt, c, phase, true);
}
if let (Some((t, phase)), Some(tint)) = (tinted, tgt.tint()) {
if phase == Live::Released {
super::color_guide::choose_base(app, tint.base.tinted(t));
}
apply_tint(app, &tgt, tint, t, phase);
}
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, false);
choose(app, &tgt, c, phase, false);
}
}
+16
View File
@@ -318,6 +318,22 @@ fn alt_click_on_the_spectrum_paints_the_stroke() {
assert_eq!(paints_hex(&app).0, "#ff0000");
}
/// A color picked in the panel, for either proxy, becomes the Color Guide's base color (#3137).
#[test]
fn a_picked_color_becomes_the_color_guides_base() {
let mut app = app();
let ctx = egui::Context::default();
app.run("paint.setFill", json!({"color": "#ffffff"})).unwrap();
app.ui.color_guide_base = Some(Color::WHITE);
click_spectrum(&mut app, &ctx, (0.0, 0.5), egui::Modifiers::NONE);
assert_eq!(crate::panels::color_guide::base_color(&app).to_hex(), "#ff0000");
// Alt: the stroke takes it, and it is still the base.
click_spectrum(&mut app, &ctx, (0.5, 0.5), egui::Modifiers::ALT);
let stroke = paints_hex(&app).1;
assert_ne!(stroke, "#ff0000");
assert_eq!(crate::panels::color_guide::base_color(&app).to_hex(), stroke);
}
#[test]
fn shift_click_on_the_spectrum_cycles_modes() {
let mut app = app();
+93 -18
View File
@@ -1,8 +1,11 @@
//! Color Guide panel: the base colour, the harmony-rule dropdown with the harmony strip, and the
//! variation grid: a row per harmony colour with the colour itself in the centre column,
//! shades/cool/muted to the left and tints/warm/vivid to the right (steps and reach from Color
//! Guide Options). The base colour stays put until "Set base color" takes the current colour. A
//! click on a cell applies it to the active proxy and selects it; Shift- or Cmd/Ctrl-click adds
//! Guide Options). The base color is the color last chosen in the Color panel, the Swatches panel
//! or the Color Picker ([`choose_base`]); selecting artwork leaves it as it is, and the base chip
//! ("Set base color to the current color") takes the current color, such as a cell applied from
//! the guide or the selected artwork's color. A click on a cell applies it to the active proxy,
//! without changing the base, and selects it; Shift- or Cmd/Ctrl-click adds
//! cells to the selection that Save Colors as Swatches saves. Limit to Library (the library button,
//! `ui.colorGuideLimit`) snaps every colour to a swatch library or the document's swatches; Edit or
//! Apply Colors opens Recolor Artwork with the harmony colours, limited the same way.
@@ -21,14 +24,16 @@ use crate::widgets::{self, menu_item};
/// The base colour before one is set: an orange.
const DEFAULT_BASE: Color = Color::Rgb { r: 230.0 / 255.0, g: 120.0 / 255.0, b: 40.0 / 255.0 };
/// The guide's base colour: fixed once set; the current colour when the panel first shows.
pub(crate) fn base_color(app: &VectorcraftApp, ctx: &egui::Context) -> Color {
if let Some(c) = pstate::<Option<Color>>(ctx, "cg-base") {
return c;
}
let c = active_paint(app).color().unwrap_or(DEFAULT_BASE);
set_pstate(ctx, "cg-base", Some(c));
c
/// The guide's base color: the one last chosen or set with the base chip; before that, the current
/// color.
pub(crate) fn base_color(app: &VectorcraftApp) -> Color {
app.ui.color_guide_base.unwrap_or_else(|| active_paint(app).color().unwrap_or(DEFAULT_BASE))
}
/// Make `c` the guide's base color: a color chosen in the Color panel, the Swatches panel or the
/// Color Picker. A color applied from the guide itself leaves the base as it is.
pub(crate) fn choose_base(app: &mut VectorcraftApp, c: Color) {
app.ui.color_guide_base = Some(c);
}
fn rule(ctx: &egui::Context) -> Harmony {
@@ -59,7 +64,7 @@ struct GuideKey {
/// The guide the panel shows, limited to the Limit to Library colours.
fn guide(app: &VectorcraftApp, ctx: &egui::Context) -> Guide {
let (base, rule, opts) = (base_color(app, ctx), rule(ctx), app.ui.color_guide);
let (base, rule, opts) = (base_color(app), rule(ctx), app.ui.color_guide);
let limit = &app.ui.color_guide_limit;
if limit.is_empty() {
return Guide::new(base, rule, &opts);
@@ -132,6 +137,15 @@ fn save_selected(app: &mut VectorcraftApp, ctx: &egui::Context) {
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
let ctx = ui.ctx().clone();
// The current color becomes the base when the panel first shows; selecting artwork later
// leaves it as it is.
let first = base_color(app);
let set = *app.ui.color_guide_base.get_or_insert(first);
// Selected cells belong to the guide they were picked from: a new base clears them.
if pstate::<Option<Color>>(&ctx, "cg-sel-base") != Some(set) {
set_pstate(&ctx, "cg-sel-base", Some(set));
clear_selection(&ctx);
}
let h = rule(&ctx);
let g = guide(app, &ctx);
let base = g.colors[0];
@@ -142,7 +156,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if resp.on_hover_text(tl!("Set base color to the current color")).clicked()
&& let Some(c) = active_paint(app).color()
{
set_pstate(&ctx, "cg-base", Some(c));
app.ui.color_guide_base = Some(c);
clear_selection(&ctx);
}
let labels: Vec<&str> = Harmony::ALL.iter().map(|h| h.label()).collect();
@@ -294,22 +308,83 @@ mod tests {
assert_eq!(role_and_state(&shown, "Limit colors to swatch library: Earth Tones"), (Role::Button, Some(Toggled::True)));
}
/// The first show takes the current color; a color chosen in the Color panel, Swatches or the
/// Color Picker becomes the base; selecting artwork with another color leaves it.
#[test]
fn base_stays_until_set_and_the_grid_follows_the_options() {
fn a_chosen_color_becomes_the_base_and_selecting_art_keeps_it() {
let mut app = app();
let red = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
let blue = app.run("shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"color": "#3366cc"})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx);
assert_eq!(base_color(&app, &ctx).to_hex(), "#3366cc", "the first show takes the current colour");
// Another current colour leaves the base alone.
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
assert_eq!(base_color(&app).to_hex(), "#3366cc", "the first show takes the current color");
app.run("select.set", json!({"ids": [red]})).unwrap();
frame(&mut app, &ctx);
assert_eq!(base_color(&app).to_hex(), "#3366cc", "selecting art keeps the base");
choose_base(&mut app, Color::from_hex("#20a060").unwrap());
frame(&mut app, &ctx);
assert_eq!(base_color(&app, &ctx).to_hex(), "#3366cc");
app.ui.color_guide.steps = 6;
frame(&mut app, &ctx);
let g = guide(&app, &ctx);
assert_eq!(g.grid[0].len(), 13);
assert_eq!(g.grid[0][6].to_hex(), "#3366cc", "the base sits in the centre of the first row");
assert_eq!(g.grid[0][6].to_hex(), "#20a060", "the chosen base sits in the centre of the first row");
app.run("select.set", json!({"ids": [blue]})).unwrap();
frame(&mut app, &ctx);
assert_eq!(base_color(&app).to_hex(), "#20a060");
}
/// The rect of the panel widget `id` after one frame.
fn rect_of(app: &mut VectorcraftApp, ctx: &egui::Context, id: impl std::hash::Hash + std::fmt::Debug) -> Rect {
let mut r = None;
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
show(app, ui);
r = ctx.read_response(ui.scope_id().with(&id)).map(|r| r.rect);
});
out.textures_delta.clear();
r.expect("the widget is drawn")
}
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2) {
let button =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
for events in [vec![egui::Event::PointerMoved(at), button(true)], vec![button(false)]] {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
}
}
/// A harmony color or a variation clicked in the guide paints the art without changing the
/// base; the base chip then makes the current color the base.
#[test]
fn a_guide_color_applies_without_moving_the_base_until_set_as_base() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
app.run("paint.setFill", json!({"color": "#3366cc"})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx);
let g = guide(&app, &ctx);
let fill = |app: &VectorcraftApp| crate::panels::current_paints(app).0.color().map(|c| c.to_hex());
let at = rect_of(&mut app, &ctx, ("cg-h", 1)).center();
click(&mut app, &ctx, at);
assert_eq!(fill(&app), Some(g.colors[1].to_hex()), "the harmony color paints the art");
assert_eq!(base_color(&app).to_hex(), "#3366cc", "and leaves the base");
let at = rect_of(&mut app, &ctx, ("cg-v", 0usize, 2usize)).center();
click(&mut app, &ctx, at);
let variation = g.grid[0][2].to_hex();
assert_eq!(fill(&app), Some(variation.clone()), "a variation paints the art");
assert_eq!(base_color(&app).to_hex(), "#3366cc");
assert_eq!(guide(&app, &ctx).grid, g.grid, "the guide stays as it was");
// Set base color to the current color: the applied variation becomes the base.
let mut chip = None;
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
show(&mut app, ui);
chip = Some(ui.min_rect().left_top() + egui::vec2(8.0, 8.0));
});
out.textures_delta.clear();
click(&mut app, &ctx, chip.expect("the panel is drawn"));
assert_eq!(base_color(&app).to_hex(), variation, "the base chip adopts the current color");
}
#[test]
@@ -70,6 +70,18 @@ fn clicking_a_library_swatch_adds_and_applies_it_in_one_step() {
assert!(last_open(&app.ui).is_none());
}
/// A color clicked in a swatch library becomes the Color Guide's base color (#3137).
#[test]
fn a_clicked_library_color_becomes_the_color_guides_base() {
let mut app = app();
let ctx = context();
app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap();
app.ui.color_guide_base = Some(vectorcraft_color::Color::WHITE);
click(&mut app, &ctx, "Pastel Teal", Modifiers::NONE, 0.0);
let teal = app.session.doc().unwrap().doc.swatch("Pastel Teal").and_then(|w| w.paint.color()).unwrap();
assert_eq!(crate::panels::color_guide::base_color(&app), teal);
}
#[test]
fn modifier_clicks_select_and_add_to_swatches_adds_the_selection() {
let mut app = app();
+7 -1
View File
@@ -187,9 +187,15 @@ fn swatch_params(e: &Entry) -> Option<Value> {
/// Apply a clicked swatch to the active proxy (Alt: the inactive one). An Alt-click on a color
/// swatch with a gradient stop selected recolors the stop as dropping the swatch on it does,
/// and with a freeform gradient point selected it recolors the point.
/// and with a freeform gradient point selected it recolors the point. A color swatch also becomes
/// the Color Guide's base color.
fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) {
let Some(params) = swatch_params(e) else { return };
if let Entry::Swatch { paint, .. } = e
&& let Some(c) = paint.color()
{
super::color_guide::choose_base(app, c);
}
if super::alt_held(ui)
&& let Some(c) = drag_of(e, vec![]).color()
&& super::gradient::recolor_selected(app, c, &params)
+3 -1
View File
@@ -30,8 +30,10 @@ pub(super) fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
}
});
ui.add_space(4.0);
// A recent colour paints the active proxy (Alt: the inactive one), as a swatch does.
// A recent colour paints the active proxy (Alt: the inactive one), as a swatch does, and
// becomes the Color Guide's base color.
if let Some(c) = crate::panels::recent_colors_row(app, ui) {
crate::panels::color_guide::choose_base(app, c);
crate::panels::apply_click(app, ui, json!({"color": crate::panels::color_json(&c)}));
}
widgets::divider(ui);
@@ -91,8 +91,12 @@ impl LibraryKind for SwatchLibraries {
fn describe(w: &Swatch) -> String {
describe(&w.paint, w.global, w.spot)
}
/// Adds the swatch and applies it to the active proxy (Alt: the inactive one), as one step.
/// Adds the swatch and applies it to the active proxy (Alt: the inactive one), as one step. A
/// color swatch also becomes the Color Guide's base color.
fn click(app: &mut VectorcraftApp, ui: &Ui, id: &str, name: &str) {
if let Some(c) = swatchlib::library(&app.session, id).and_then(|(_, lib)| lib.iter().find(|w| w.name == name).and_then(|w| w.paint.color())) {
crate::panels::color_guide::choose_base(app, c);
}
let alt = crate::panels::alt_held(ui);
let apply = if crate::panels::proxy_cmd(app, alt) == "paint.setFill" { "fill" } else { "stroke" };
add_from_library(app, json!({"library": id, "names": [name], "apply": apply, "focus": !alt}));
@@ -133,6 +133,34 @@ fn modifier_clicks_select_several_swatches_and_groups() {
assert_eq!(selected(&ctx), ["Grays"], "a plain click on a group selects it alone");
}
/// A color swatch or a recent color clicked in the panel becomes the Color Guide's base color
/// (#3137); a modifier click only selects.
#[test]
fn a_clicked_color_swatch_becomes_the_color_guides_base() {
let mut app = app();
let ctx = context();
app.ui.color_guide_base = Some(Color::WHITE);
click_tile(&mut app, &ctx, "Red", Modifiers::NONE, 0.0);
let base = |app: &VectorcraftApp| crate::panels::color_guide::base_color(app).to_hex();
assert_eq!(base(&app), "#ed1c24");
click_tile(&mut app, &ctx, "Amber", Modifiers::SHIFT, 2.0);
assert_eq!(base(&app), "#ed1c24", "a modifier click only selects");
// A recent color: the panel's first recent chip.
app.run("paint.setFill", json!({"color": "#2050a0"})).unwrap();
app.ui.color_guide_base = Some(Color::WHITE);
let mut chip = None;
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0)));
let mut out = ctx.run_ui(egui::RawInput { time: Some(4.0), screen_rect, ..Default::default() }, |ui| {
show(&mut app, ui);
chip = ctx.read_response(ui.scope_id().with(("recent", 0usize))).map(|r| r.rect.center());
});
out.textures_delta.clear();
let at = chip.expect("the recent chip is drawn");
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
frame(&mut app, &ctx, vec![Event::PointerMoved(at), button(true), button(false)], 5.0, show);
assert_eq!(base(&app), "#2050a0");
}
/// The hex colors of the stops of the selection's gradient fill, and its stroke's color.
fn stops_and_stroke(app: &VectorcraftApp) -> (Vec<String>, String) {
let (fill, stroke) = crate::panels::current_paints(app);
+6
View File
@@ -483,6 +483,11 @@ pub struct UiState {
/// swatches) or "" (none) (`ui.colorGuideLimit`).
#[serde(default)]
pub color_guide_limit: String,
/// Color Guide panel: the base color, set by a color chosen in the Color panel, the Swatches
/// panel or the Color Picker and by the panel's base chip (none: the current color). Not
/// saved.
#[serde(skip)]
pub color_guide_base: Option<vectorcraft_color::Color>,
/// Align panel: the Align To choice, Selection or Artboard, which Object → Align and the align
/// buttons follow ([`crate::panels::align::with_align_to`]).
#[serde(default, deserialize_with = "crate::panels::align::deserialize_align_to")]
@@ -855,6 +860,7 @@ impl Default for UiState {
library_panels: vec![],
flattener_preview: Default::default(),
color_guide_limit: String::new(),
color_guide_base: None,
align_to: Default::default(),
align_spacing: 0.0,
color_mode: None,