Files
vectorcraft/crates/ui-egui/src/dialogs/swatch_options.rs
T
el-j 752bbb050d Fix #4567: vertically center numbers and text in input fields
In single-line text and numeric inputs across panels and dialogs, egui
0.37 defaulted TextEdit alignment to Align2::LEFT_TOP, placing the text
galley at the very top of the allocated input box (e.g. 1pt top margin,
11pt bottom margin in 26pt rows).

Align single-line TextEdit instances to Align2::LEFT_CENTER across
recessed_text, opt_field_named (dash/gap cells), hex fields, search
fields, and dialog inputs, matching DesignCraft's vertical centering.
Add regression assertions in look_tests verifying balanced vertical
spacing.
2026-10-29 19:21:33 +07:00

333 lines
15 KiB
Rust

//! Swatch Options: name, colour type (process or spot), Global, colour mode with sliders and hex,
//! previewed live on the canvas (Cancel rolls the preview back). Double-clicking a swatch opens it
//! for a solid colour; for a gradient it shows the name and the gradient; patterns open pattern
//! editing ([`open`]).
//!
//! Fields: `__swatch` (the swatch being edited), `name`, `spot`, `global`, `mode` (`gray`, `rgb`,
//! `hsb`, `lab`, `cmyk` or `web`), `color` (`"#rrggbb"` or a colour object such as
//! `{"model": "cmyk", "c": 0.1, "m": 0.2, "y": 0.3, "k": 0}`) and `preview`; a gradient swatch has
//! `name`, `preview` and `__gradient` (the gradient shown) only.
use egui::vec2;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use super::{DialogSpec, form, run_and_close};
use crate::VectorcraftApp;
use crate::panels::color::{Mode, components, from_components, parse_hex, track_color, web_safe};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets;
/// The dialog kind of Swatch Options.
pub const KIND: &str = "swatchOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Swatch Options").into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
/// Colour modes, in menu order, with their `mode` field / `swatch.edit` ids.
const MODES: [(Mode, &str); 6] =
[(Mode::Grayscale, "gray"), (Mode::Rgb, "rgb"), (Mode::Hsb, "hsb"), (Mode::Lab, "lab"), (Mode::Cmyk, "cmyk"), (Mode::WebSafe, "web")];
const TYPES: [&str; 2] = ["Process Color", "Spot Color"];
pub(super) fn mode_id(m: Mode) -> &'static str {
MODES.iter().find(|x| x.0 == m).map_or("rgb", |x| x.1)
}
fn mode_of(d: &Dialog) -> Mode {
let id = d.str("mode");
MODES.iter().find(|x| x.1 == id).map_or(Mode::Rgb, |x| x.0)
}
/// The dialog's colour: a hex string or a serialized colour.
pub(super) fn color_of(d: &Dialog) -> Color {
match d.fields.get("color") {
Some(Value::String(s)) => Color::from_hex(s),
Some(v) => serde_json::from_value(v.clone()).ok(),
None => None,
}
.unwrap_or(Color::BLACK)
}
fn set_color(d: &mut Dialog, c: Color) {
d.fields.insert("color".into(), json!(c));
}
/// Open the editor of swatch `name`: Swatch Options for a colour (a tint swatch's base) or a
/// gradient (its name only), pattern editing for a pattern.
pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
let d = app.session.active().map(|st| &st.doc);
let sw = d.and_then(|d| d.swatch(name)).ok_or_else(|| format!("no swatch `{name}`"))?;
if let Some((base, _)) = sw.tint_of()
&& d.is_some_and(|d| d.swatch(base).is_some())
{
let base = base.to_string();
return open(app, &base);
}
let sw = sw.clone();
match &sw.paint {
Paint::Solid { color, .. } => {
let fields = json!({
"__swatch": name, "name": name, "spot": sw.spot, "global": sw.global,
"mode": mode_id(Mode::of(color)), "color": color, "preview": true,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
Paint::Gradient(g) => {
let fields = json!({"__swatch": name, "name": name, "__gradient": g, "preview": true});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
Paint::Pattern { pattern, .. } => {
app.run("object.pattern.edit", json!({"name": pattern}))?;
app.ui.open_panel = Some("patternOptions".into());
Ok(Value::Null)
}
Paint::None => Err(format!("`{name}` has no options")),
}
}
/// `swatch.edit` parameters from the fields (a gradient swatch only renames).
fn edit_params(d: &Dialog) -> Value {
if d.fields.contains_key("__gradient") {
return json!({"name": d.str("__swatch"), "newName": d.str("name")});
}
json!({
"name": d.str("__swatch"),
"newName": d.str("name"),
"color": color_of(d),
"mode": d.str("mode"),
"global": d.bool("global"),
"spot": d.bool("spot"),
})
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let changed = match d.fields.get("__gradient").and_then(|g| serde_json::from_value::<vectorcraft_color::GradientPaint>(g.clone()).ok()) {
Some(g) => {
let changed = grid(ui, |ui| name_row(ui, d));
ui.add_space(6.0);
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), egui::Sense::hover());
widgets::gradient_chip(ui, r, &g);
changed
}
None => editor(ui, d),
};
ui.add_space(8.0);
let mut pv = d.bool("preview");
let pv_changed = widgets::check(ui, tl!("Preview"), pv, true);
if pv_changed {
pv = !pv;
d.fields.insert("preview".into(), json!(pv));
}
if pv && (changed || pv_changed || !app.session.in_interaction()) {
let _ = app.session.begin_interaction("Swatch Options");
if let Err(e) = app.session.preview("swatch.edit", &edit_params(d)) {
app.status(e.to_string());
}
} else if !pv && pv_changed {
let _ = app.session.cancel_interaction();
}
false
}
/// Drop the preview and apply the edit as one undo step (which also updates the default paints).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let _ = app.session.cancel_interaction();
run_and_close(app, "swatch.edit", edit_params(d))
}
/// The fields' grid of the swatch dialogs.
pub(super) fn grid<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
egui::Grid::new("swatch-options").num_columns(2).spacing([10.0, 8.0]).show(ui, add).inner
}
/// A dimmed field label in the swatch dialogs' grid.
pub(super) fn label(ui: &mut egui::Ui, s: &str) {
crate::widgets::field_label(ui, egui::RichText::new(s).color(Tokens::get(ui.ctx()).text_dim));
}
/// The Swatch Name row of the grid. Returns true when it changed.
pub(super) fn name_row(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
label(ui, tl!("Swatch Name:"));
let changed = form::text(ui, d, "name", 190.0);
ui.end_row();
changed
}
/// The shared fields of Swatch Options and New Swatch: name, colour type, Global, mode, sliders and
/// hex. Returns true when anything changed.
pub(super) fn editor(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let mut changed = false;
grid(ui, |ui| {
changed |= name_row(ui, d);
label(ui, tl!("Color Type:"));
let spot = d.bool("spot");
if let Some(i) = widgets::dropdown(ui, "swatch-type", TYPES[usize::from(spot)], &TYPES, 200.0) {
d.fields.insert("spot".into(), json!(i == 1));
changed = true;
}
ui.end_row();
ui.label("");
// Spot colours are always global.
let global = spot || d.bool("global");
if widgets::check(ui, tl!("Global"), global, !spot) {
d.fields.insert("global".into(), json!(!global));
changed = true;
}
ui.end_row();
label(ui, tl!("Color Mode:"));
let labels = MODES.map(|m| m.0.label());
if let Some(i) = widgets::dropdown(ui, "swatch-mode", mode_of(d).label(), &labels, 200.0) {
let m = MODES[i].0;
// Switching modes converts the colour (HSB is a view of RGB), like the Color panel.
let base = if m == Mode::Hsb { Mode::Rgb } else { m };
set_color(d, from_components(base, &components(base, &color_of(d))));
d.fields.insert("mode".into(), json!(mode_id(m)));
changed = true;
}
ui.end_row();
});
ui.add_space(6.0);
changed | sliders(ui, d)
}
/// One slider and value field per component of the dialog's mode, the hex field (RGB, HSB and Web)
/// and a chip of the colour. Returns true when the colour changed.
fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let mode = mode_of(d);
let color = color_of(d);
// Keep the displayed components while the colour is unchanged (hue survives S = 0 etc.).
let key = format!("{}|{}", mode_id(mode), serde_json::to_string(&color).unwrap_or_default());
let comps: Vec<f32> = match d.fields.get("__comps") {
Some(v) if d.str("__compsKey") == key => serde_json::from_value(v.clone()).unwrap_or_else(|_| components(mode, &color)),
_ => components(mode, &color),
};
let mut new: Option<(Color, Vec<f32>)> = None;
ui.horizontal(|ui| {
ui.vertical(|ui| {
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 v = comps.get(i).copied().unwrap_or(0.0);
let track = |x: f32| crate::panels::c32(&track_color(mode, &comps, i, x));
let shown = widgets::SliderValue::field(v as f64, mode.min(i) as f64..=mode.max(i) as f64, mode.suffix(i), 0);
if let (Some(nv), _) = widgets::color_slider_valued(ui, ("swatch-slider", i), lbl, Some(shown), mode.unit(i, v), 200.0, &track) {
let mut c2 = comps.clone();
c2[i] = mode.value_at(i, nv);
new = Some((from_components(mode, &c2), c2));
}
let bounds = f64::from(mode.min(i))..=f64::from(mode.max(i));
if let Some(fv) = widgets::range_field(ui, ("swatch-field", i), f64::from(v), bounds, mode.suffix(i), 0, 52.0) {
let mut c2 = comps.clone();
c2[i] = (fv as f32).clamp(mode.min(i), mode.max(i));
new = Some((from_components(mode, &c2), c2));
}
});
}
if matches!(mode, Mode::Rgb | Mode::Hsb | Mode::WebSafe) {
ui.horizontal(|ui| {
ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new("#").size(12.5).color(t.text)));
let hex = color.to_hex().trim_start_matches('#').to_uppercase();
if let Some(c) = hex_field(ui, &hex) {
let c = if mode == Mode::WebSafe { web_safe(&c) } else { c };
new = Some((c, components(mode, &c)));
}
});
}
});
let (r, _) = ui.allocate_exact_size(vec2(44.0, 44.0), egui::Sense::hover());
widgets::swatch_tile(ui, r, &Paint::solid(color), false, false);
});
let Some((c, comps)) = new else { return false };
set_color(d, c);
d.fields.insert("__compsKey".into(), json!(format!("{}|{}", mode_id(mode), serde_json::to_string(&c).unwrap_or_default())));
d.fields.insert("__comps".into(), json!(comps));
true
}
/// A hex field showing `hex`; returns the colour typed into it once committed.
fn hex_field(ui: &mut egui::Ui, hex: &str) -> Option<Color> {
let t = Tokens::get(ui.ctx());
let id = ui.scope_id().with("swatch-hex");
let editing = ui.memory(|m| m.has_focus(id));
// As in `widgets::hex_field`: until the user types into it, the field shows `hex` as it is now
// (#1692).
let edited_id = id.with("edited");
let edited = editing && ui.data_mut(|m| m.get_temp::<bool>(edited_id)).unwrap_or(false);
let mut buf: String = if edited { ui.data_mut(|m| m.get_temp::<String>(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() };
let resp = egui::Frame::NONE
.fill(t.input)
.stroke(egui::Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
.corner_radius(2)
.inner_margin(egui::Margin::symmetric(6, 4))
.show(ui, |ui| {
crate::theme::strong_selected_text(ui);
ui.add(
egui::TextEdit::singleline(&mut buf)
.id(id)
.frame(egui::Frame::NONE)
.align(egui::Align2::LEFT_CENTER)
.desired_width(70.0)
.char_limit(7),
)
})
.inner;
let focused = ui.memory(|m| m.has_focus(id));
ui.data_mut(|m| {
m.insert_temp(id, buf.clone());
m.insert_temp(edited_id, (edited || resp.changed()) && focused);
});
(resp.lost_focus() && buf != hex).then(|| parse_hex(&buf)).flatten()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fields_round_trip_modes_and_colours() {
for (m, id) in MODES {
assert_eq!(mode_of(&Dialog::new(KIND, json!({"mode": id}))), m);
}
let d = Dialog::new(KIND, json!({"color": "#ff8000"}));
assert_eq!(color_of(&d).to_hex(), "#ff8000");
let mut d = Dialog::new(KIND, json!({}));
set_color(&mut d, Color::cmyk(0.1, 0.2, 0.3, 0.4));
assert_eq!(color_of(&d), Color::cmyk(0.1, 0.2, 0.3, 0.4));
let p = edit_params(&Dialog::new(KIND, json!({"__swatch": "A", "name": "B", "spot": true, "color": "#000000", "mode": "cmyk"})));
assert_eq!(
(p["name"].as_str(), p["newName"].as_str(), p["spot"].as_bool(), p["mode"].as_str()),
(Some("A"), Some("B"), Some(true), Some("cmyk"))
);
}
#[test]
fn lab_mode_defines_a_spot_colour_in_lab() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 80, "y": 60, "k": 0}, "spot": true})).unwrap();
app.run("ui.swatchOptions", json!({"name": "Ink"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("lab"));
// Drawn headlessly: three Lab sliders (a and b signed) preview the edit.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(&mut app, ui.ctx()));
out.textures_delta.clear();
assert_eq!(mode_of(app.ui.dialog.as_ref().unwrap()).labels(), ["L", "a", "b"]);
super::super::confirm(&mut app).unwrap();
let ink = app.session.active().unwrap().doc.swatch("Ink").unwrap().paint.color().unwrap();
assert_eq!(ink.model(), vectorcraft_color::cms::Model::Lab);
assert_eq!(ink.to_hex(), Color::cmyk(0.0, 0.8, 0.6, 0.0).to_hex(), "the same colour, now in Lab");
// Lab values typed into the fields.
app.run("ui.swatchOptions", json!({"name": "Ink"})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("mode"), "lab", "opens in the colour's mode");
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!(Color::lab(40.0, -30.0, 20.0)));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.active().unwrap().doc.swatch("Ink").unwrap().paint.color(), Some(Color::lab(40.0, -30.0, 20.0)));
}
}