From 18c0ef06e27e99aea78a556d263f151d707ff571 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 5 Nov 2026 22:09:36 +0300 Subject: [PATCH] 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). --- crates/ui-egui/src/dialogs/color_picker.rs | 31 ++++++++++- crates/ui-egui/src/dialogs/effect.rs | 56 ++++++++++++++++++++ crates/ui-egui/src/dialogs/form.rs | 61 +++++++++++++++++++--- 3 files changed, 140 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index 8d58f21b4..fdbf335cb 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -249,6 +249,23 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { 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 { + 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); diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 273c0291e..d305be1ff 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -985,4 +985,60 @@ mod tests { assert_eq!((¶ms["texture"], ¶ms["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| { + 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"); + } } diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 1ac4c7888..7238e191e 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -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 }