Outer Glow: a colour swatch beside Color opens the Color Picker (#5365)

Effect dialogs that take a hex colour (Outer Glow, Inner Glow, Drop Shadow's fields) show a
swatch beside the colour field: it follows the field as it is typed, and a double-click opens
the Color Picker over the dialog, whose OK fills the field and whose Cancel comes back to the
dialog. While the picker is open it previews nothing itself: the effect dialog previews the
colour when it returns.

The swatch is part of the generic effect parameter grid, so every effect dialog with a colour
parameter gets it, and the Color Picker learns to fill one field of a dialog opened over
(`over_effect`, a parent like Recolor Artwork's).

Test: dialogs::effect::tests::outer_glow_colour_swatch_opens_the_color_picker (swatch drawn
on #ffff00, a typed hex recolours it, a double-click opens the picker on it, OK fills Color and
the effect applies #00ff00).
This commit is contained in:
Alex
2026-11-06 02:34:17 +07:00
committed by bflatastic
parent 9bd11a3202
commit 18c0ef06e2
3 changed files with 140 additions and 8 deletions
+29 -2
View File
@@ -249,6 +249,23 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
Ok(Value::Null)
}
/// The field of a Color Picker opened over a command dialog ([`over_effect`]): the dialog's colour
/// field its OK fills.
const COLOUR_FIELD: &str = "__colourField";
/// The Color Picker over the command dialog `parent`, on `colour`, that fills the dialog's `field`
/// with its OK and goes back to it, as Cancel does. It previews nothing itself: the dialog's own
/// preview shows the colour once it is back.
pub(super) fn over_effect(parent: &Dialog, field: &str, colour: Color) -> Dialog {
let mut d = Dialog::new(
KIND,
json!({"original": color_json(&colour), "channel": Channel::default().id(), "webOnly": false, "swatches": false, COLOUR_FIELD: field}),
);
d.fields.insert(super::PARENT.into(), serde_json::to_value(parent).unwrap_or_default());
store(&mut d, &Picked::of(colour));
d
}
/// The field of a Color Picker opened over Recolor Artwork ([`over_recolor`]): the row whose new
/// color it picks.
const RECOLOR_ROW: &str = "__recolorRow";
@@ -433,8 +450,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> DialogAction {
/// Opened for an artboard, the picker shows the color on that artboard's paper, with or without a
/// selection.
fn preview(app: &mut VectorcraftApp, d: &mut Dialog) {
// Over Recolor Artwork the art shows that dialog's preview, which this one must not replace.
if d.fields.contains_key(RECOLOR_ROW) {
// Over Recolor Artwork the art shows that dialog's preview, which this one must not replace;
// over a command dialog this picker only fills a field, and the dialog previews it.
if d.fields.contains_key(RECOLOR_ROW) || d.fields.contains_key(COLOUR_FIELD) {
return;
}
let (cmd, params) = paint_command(app, d);
@@ -632,6 +650,15 @@ fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color, side: f32) ->
/// OK with the starting color unchanged and nothing previewed closes the picker and leaves the
/// artboard as it was: without a background color, or with its color in its own color model.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if let Some(field) = d.fields.get(COLOUR_FIELD).and_then(Value::as_str) {
let mut parent = super::parent(d).ok_or("the dialog it was opened over is no longer open")?;
parent.fields.insert(field.to_string(), json!(picked(d).color.to_hex()));
// The dialog previews the new colour when it comes back: end this picker's interaction, as
// Cancel does, so the dialog starts its preview afresh.
let _ = app.session.cancel_interaction();
app.ui.dialog = Some(parent);
return Ok(Value::Null);
}
if let Some(row) = d.fields.get(RECOLOR_ROW).and_then(Value::as_u64) {
let parent = super::parent(d).ok_or("Recolor Artwork is no longer open")?;
let c = in_document_model(app, picked(d).color);
+56
View File
@@ -985,4 +985,60 @@ mod tests {
assert_eq!((&params["texture"], &params["textureImage"]), (&json!("loaded"), &json!(stored)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Effect > Stylize > Outer Glow: a swatch beside Color shows the effect's colour, typing a
/// hex recolours it, and a double-click opens the Color Picker over the dialog; its OK fills
/// Color, and the effect applies that colour.
#[test]
fn outer_glow_colour_swatch_opens_the_color_picker() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
app.run("effect.dialog", json!({"effect": "stylize.outerGlow"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
let frame = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
out
};
for n in 0..6 {
frame(&mut app, f64::from(n) * 0.01, vec![]);
}
let rect = ctx.read_response(form::colour_swatch_id("color")).expect("the colour swatch is drawn").rect;
// The colour of the swatch drawn at `rect`.
let swatch = |out: &egui::FullOutput| {
out.shapes.iter().find_map(|c| match &c.shape {
egui::Shape::Rect(r) if r.rect == rect => Some(r.fill),
_ => None,
})
};
let yellow = egui::Color32::from_rgb(255, 255, 0);
assert_eq!(swatch(&frame(&mut app, 0.2, vec![])), Some(yellow), "the swatch shows the effect's #ffff00");
// Typing a colour recolours the swatch.
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#ff0000"));
assert_eq!(swatch(&frame(&mut app, 0.25, vec![])), Some(egui::Color32::from_rgb(255, 0, 0)), "typing a hex recolours the swatch");
// A double-click opens the Color Picker on the field's colour.
let pos = rect.center();
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
frame(&mut app, 0.3, vec![egui::Event::PointerMoved(pos), button(true)]);
frame(&mut app, 0.35, vec![button(false)]);
frame(&mut app, 0.4, vec![button(true)]);
frame(&mut app, 0.45, vec![button(false)]);
frame(&mut app, 0.5, vec![]);
let d = app.ui.dialog.as_ref().expect("a dialog");
assert_eq!(d.kind, "colorPicker", "a double-click opens the Color Picker over the effect dialog");
assert_eq!(d.fields.get("color").and_then(vectorcraft_engine::cmd::color_value), Some(Color::rgb(1.0, 0.0, 0.0)), "on the field's colour");
// Its OK fills the effect's Color, and the effect applies it.
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#00ff00"));
super::super::confirm(&mut app).unwrap();
let d = app.ui.dialog.as_ref().expect("the effect dialog is back");
assert_eq!((d.kind.as_str(), d.fields.get("color")), ("effect", Some(&json!("#00ff00"))), "OK fills Color and comes back");
frame(&mut app, 0.6, vec![]);
super::super::confirm(&mut app).unwrap();
let st = app.session.doc().unwrap();
let fx = &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0];
assert_eq!(fx.params.get("color"), Some(&json!("#00ff00")), "Outer Glow takes the picked colour");
}
}
+55 -6
View File
@@ -3,6 +3,7 @@
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_doc::color::Color;
use vectorcraft_plugins::{Manifest, Param, ParamSpec};
use crate::state::Dialog;
@@ -168,6 +169,26 @@ fn choices(kind: &str, key: &str) -> Vec<(&'static str, &'static str)> {
}
}
/// The id of the colour swatch beside the field `key` (a test clicks it; [`colour_swatch`] draws it).
pub(super) fn colour_swatch_id(key: &str) -> egui::Id {
egui::Id::unique(("fx-colour-swatch", key))
}
/// The colour swatch beside the colour field `key`, showing `colour`: a double-click asks for the
/// Color Picker over this dialog, whose OK fills the field ([`super::color_picker::over_effect`]).
/// Returns whether the picker was asked for.
fn colour_swatch(ui: &mut egui::Ui, key: &str, colour: Color) -> bool {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(egui::vec2(20.0, 18.0), egui::Sense::hover());
let resp = ui.interact(rect, colour_swatch_id(key), egui::Sense::click());
let [r, g, b, _] = colour.to_rgba8(1.0);
ui.painter().rect_filled(rect, 2.0, egui::Color32::from_rgb(r, g, b));
ui.painter().rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
resp.double_clicked()
}
/// A text field `width` wide bound to `d.fields[key]`. Returns true when it changed.
pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> bool {
text_edit(ui, d, key, width).changed()
@@ -403,6 +424,7 @@ pub(super) fn param_fields(
) -> bool {
let t = Tokens::get(ui.ctx());
let mut changed = false;
let mut pick: Option<(String, Color)> = None;
egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
let mut keys: Vec<(String, Value)> =
d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect();
@@ -480,18 +502,42 @@ pub(super) fn param_fields(
}
}
Value::String(mut s) => {
if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(140.0)).changed() {
d.fields.insert(k.clone(), json!(s));
changed = true;
// A hex colour (#rrggbb or #rgb) shows its swatch beside the field; the swatch's
// double-click opens the Color Picker over this dialog.
let mut colour = None;
let mut ask = false;
ui.horizontal(|ui| {
if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(140.0)).changed() {
d.fields.insert(k.clone(), json!(s));
changed = true;
}
colour = crate::panels::color::parse_hex(&s);
if let Some(c) = colour {
ask = colour_swatch(ui, &k, c);
}
});
if ask && let Some(c) = colour {
pick = Some((k.clone(), c));
}
}
// A color object shows as its hex; typing in the field stores the typed text.
other => match color_object(&other) {
Some(c) => {
let mut s = c.to_hex();
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() {
d.fields.insert(k.clone(), json!(s));
changed = true;
let mut colour = None;
let mut ask = false;
ui.horizontal(|ui| {
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() {
d.fields.insert(k.clone(), json!(s));
changed = true;
}
colour = crate::panels::color::parse_hex(&s);
if let Some(c) = colour {
ask = colour_swatch(ui, &k, c);
}
});
if ask && let Some(c) = colour {
pick = Some((k.clone(), c));
}
}
None => {
@@ -503,6 +549,9 @@ pub(super) fn param_fields(
ui.end_row();
}
});
if let Some((key, colour)) = pick {
*d = super::color_picker::over_effect(d, &key, colour);
}
changed
}