Part of #3137: Color Guide takes its base from colors chosen in Color, Swatches and the Color Picker

The Color Guide kept the base color it took when the panel first showed,
and only the base chip changed it. In a new document the current fill is
white, so every harmony and variation was white or gray until the base
chip was clicked with another current color.

A color chosen in the Color panel (spectrum, sliders, fields, chips and
Recent Colors), in the Swatches panel or a swatch library panel, or with
OK in the Color Picker for a fill or stroke now becomes the guide's base
color, and the guide shows its harmonies. A color applied from the guide
itself leaves the base as it is, and so does selecting artwork. The base
chip (Set base color to the current color) still makes the current color
the base. A new base clears the cells selected for Save Colors as
Swatches.

The base moves from egui's temporary memory to UiState::color_guide_base,
which is not saved, so the Color panel, the Swatches panels and the Color
Picker can set it.
This commit is contained in:
dotCooCoo
2026-10-29 14:36:35 +07:00
committed by bflatastic
parent 520f390c9c
commit e31e04e545
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,