M3.54: Adjust Color Balance and Saturate open live-preview dialogs, committed as one undo step

- Edit > Edit Colors > Adjust Color Balance... opens Adjust Colors (`ui.colorBalanceDialog`, kind
  `colorBalance`): colour mode Grayscale, RGB, CMYK or Global, a slider and field per channel
  (-100..100 %), Convert, Fill and Stroke, and Preview.
- Saturate... opens Saturate (`ui.saturateDialog`, kind `saturate`): Intensity -100..100 % and
  Preview.
- Both preview through the session interaction (fields changed by `ui.dialog.set` preview on the
  next frame too); OK keeps the result as one undo step, Cancel rolls it back.
- `edit.colors.adjustBalance` takes `mode` (rgb|cmyk|gray|global; default from the channels
  given). Global mode needs tints of global and spot colours and answers with a clear error until
  they exist; the dialog stays open and the colours keep their links.
- Shared dialog helpers: `form::slider`, `form::preview`, `form::commit_preview`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 23:50:30 +07:00
parent bfebf66404
commit 1de1c6446f
9 changed files with 446 additions and 31 deletions

View File

@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Adjust Color Balance…",
["Edit", "Edit Colors"],
None,
"{r?, g?, b? | c?, m?, y?, k? | gray?: -100..100 (% added per channel), convert?: false (keep the result in the adjusted model), fill?, stroke?, ids?} → {changed}",
"{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray?: -100..100 (% added per channel, default 0), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, ids?} → {changed}. Global mode (tints of global and spot colours) isn't available yet",
has_selection,
adjust_balance
),
@ -214,29 +214,47 @@ fn saturate(s: &mut Session, p: &Value) -> Result<Value> {
}
fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.colors.adjustBalance";
let given = |keys: &[&str]| keys.iter().any(|k| p.get(*k).is_some());
let mode = match str_param(p, "mode") {
Some(m) => m.to_ascii_lowercase(),
None if given(&["c", "m", "y", "k"]) => "cmyk".into(),
None if given(&["gray"]) => "gray".into(),
None if given(&["r", "g", "b"]) => "rgb".into(),
None => return Err(bad(C, "give a mode or r/g/b, c/m/y/k or gray adjustments")),
};
let g = |k: &str| (f64_or(p, k, 0.0).clamp(-100.0, 100.0) / 100.0) as f32;
let convert = bool_or(p, "convert", false);
let cmyk = ["c", "m", "y", "k"].iter().any(|k| p.get(*k).is_some());
let gray = p.get("gray").is_some();
let rgb = ["r", "g", "b"].iter().any(|k| p.get(*k).is_some());
if !(cmyk || gray || rgb) {
return Err(bad("edit.colors.adjustBalance", "give r/g/b, c/m/y/k or gray adjustments"));
}
let (dr, dg, db) = (g("r"), g("g"), g("b"));
let (dc, dm, dy, dk) = (g("c"), g("m"), g("y"), g("k"));
let dgray = g("gray");
let cl = |v: f32| v.clamp(0.0, 1.0);
let adjust: Box<dyn Fn(Color) -> Color> = match mode.as_str() {
"rgb" => {
let (dr, dg, db) = (g("r"), g("g"), g("b"));
Box::new(move |c| {
let [r, gg, b] = c.to_rgb();
Color::rgb(cl(r + dr), cl(gg + dg), cl(b + db))
})
}
"cmyk" => {
let (dc, dm, dy, dk) = (g("c"), g("m"), g("y"), g("k"));
Box::new(move |c| {
let [cc, m, y, k] = c.to_cmyk();
Color::cmyk(cl(cc + dc), cl(m + dm), cl(y + dy), cl(k + dk))
})
}
"gray" => {
let dgray = g("gray");
Box::new(move |c| {
let Color::Gray { k } = to_gray(c) else { unreachable!() };
Color::gray(cl(k + dgray))
})
}
"global" => {
return Err(bad(C, "mode `global` shifts the tints of global and spot colours, which aren't supported yet: use rgb, cmyk or gray"));
}
other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))),
};
let convert = bool_or(p, "convert", false);
recolor(s, p, "Adjust Colors", &move |c| {
let out = if cmyk {
let [cc, m, y, k] = c.to_cmyk();
Color::cmyk(cl(cc + dc), cl(m + dm), cl(y + dy), cl(k + dk))
} else if gray {
let Color::Gray { k } = to_gray(c) else { unreachable!() };
Color::gray(cl(k + dgray))
} else {
let [r, gg, b] = c.to_rgb();
Color::rgb(cl(r + dr), cl(gg + dg), cl(b + db))
};
let out = adjust(c);
if convert { out } else { keep_model(c, out) }
})
}

View File

@ -758,6 +758,8 @@ mod tests_distort;
#[cfg(test)]
mod tests_draw2;
#[cfg(test)]
mod tests_editcolors;
#[cfg(test)]
mod tests_effectedit;
#[cfg(test)]
mod tests_file;

View File

@ -0,0 +1,83 @@
//! Edit → Edit Colors: Adjust Color Balance modes and the live preview.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// A selected rectangle filled with `fill`.
fn rect(s: &mut Session, fill: Value) -> NodeId {
let id = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap());
s.execute("paint.setFill", &json!({"ids": [id.0], "color": fill})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
id
}
fn fill_of(s: &Session, id: NodeId) -> Color {
s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().color().unwrap()
}
#[test]
fn adjust_balance_modes() {
let mut s = session();
let a = rect(&mut s, json!({"c": 20, "m": 40, "y": 0, "k": 10}));
// CMYK channels on a CMYK colour stay CMYK.
s.execute("edit.colors.adjustBalance", &json!({"mode": "cmyk", "c": 10, "k": -10})).unwrap();
let Color::Cmyk { c, m, k, .. } = fill_of(&s, a) else { panic!("kept CMYK") };
assert!((c - 0.3).abs() < 1e-5 && (m - 0.4).abs() < 1e-5 && k.abs() < 1e-5, "{c} {m} {k}");
// RGB channels keep the colour's model unless Convert is on.
s.execute("edit.colors.adjustBalance", &json!({"mode": "rgb", "r": 10})).unwrap();
assert!(matches!(fill_of(&s, a), Color::Cmyk { .. }));
s.execute("edit.colors.adjustBalance", &json!({"mode": "rgb", "convert": true})).unwrap();
assert!(matches!(fill_of(&s, a), Color::Rgb { .. }), "converted to RGB");
// Gray with Convert makes a grey of the colour darkened by the shift.
let b = rect(&mut s, json!("#808080"));
s.execute("edit.colors.adjustBalance", &json!({"mode": "gray", "gray": 20, "convert": true})).unwrap();
let Color::Gray { k } = fill_of(&s, b) else { panic!("converted to grey") };
assert!((k - 0.698).abs() < 0.01, "{k}");
// Mode follows the channels given when it is left out.
s.execute("edit.colors.adjustBalance", &json!({"gray": -20})).unwrap();
assert!(matches!(fill_of(&s, b), Color::Gray { .. }));
assert!(s.execute("edit.colors.adjustBalance", &json!({"mode": "lab"})).is_err());
}
#[test]
fn global_mode_needs_tints_and_says_so() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Brand", "color": "#3366cc", "global": true})).unwrap();
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "Brand"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let before = s.doc().unwrap().doc.clone();
let e = s.execute("edit.colors.adjustBalance", &json!({"mode": "global"})).unwrap_err().to_string();
assert!(e.contains("global and spot"), "{e}");
assert_eq!(*s.doc().unwrap().doc, *before, "nothing changed");
let p = s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint();
assert!(matches!(p, Paint::Solid { swatch: Some(ref n), .. } if n == "Brand"), "the link is kept");
}
#[test]
fn balance_previews_and_commits_one_step() {
let mut s = session();
let a = rect(&mut s, json!("#808080"));
let undo = s.doc().unwrap().history.undo.len();
s.begin_interaction("Adjust Colors").unwrap();
for r in [10, 30, 20] {
s.preview("edit.colors.adjustBalance", &json!({"mode": "rgb", "r": r})).unwrap();
}
assert_eq!(fill_of(&s, a).to_hex(), "#b38080", "each preview starts from the original");
s.commit_interaction().unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(fill_of(&s, a).to_hex(), "#808080");
}

View File

@ -0,0 +1,180 @@
//! Edit → Edit Colors → Adjust Color Balance: the colour mode (Gray, RGB, CMYK or Global), a slider
//! per channel (−100..100 %), Convert, Fill and Stroke, previewed live on the canvas; OK keeps the
//! preview as one undo step (`edit.colors.adjustBalance`).
//!
//! Fields: `mode` (`gray`, `rgb`, `cmyk` or `global`), the channels `r`, `g`, `b`, `c`, `m`, `y`,
//! `k`, `gray`, then `convert`, `fill`, `stroke` and `preview`.
use serde_json::{Value, json};
use vectorcraft_color::Color;
use super::{DialogSpec, form};
use crate::VectorcraftApp;
use crate::panels::c32;
use crate::state::Dialog;
use crate::widgets;
/// The dialog kind of Adjust Color Balance.
pub const KIND: &str = "colorBalance";
const CMD: &str = "edit.colors.adjustBalance";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Adjust Colors".into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
/// A colour mode: its `mode` id, label and channels as (field, label).
struct Mode {
id: &'static str,
label: &'static str,
channels: &'static [(&'static str, &'static str)],
}
const MODES: [Mode; 4] = [
Mode { id: "gray", label: "Grayscale", channels: &[("gray", "Black")] },
Mode { id: "rgb", label: "RGB", channels: &[("r", "Red"), ("g", "Green"), ("b", "Blue")] },
Mode { id: "cmyk", label: "CMYK", channels: &[("c", "Cyan"), ("m", "Magenta"), ("y", "Yellow"), ("k", "Black")] },
Mode { id: "global", label: "Global", channels: &[] },
];
/// Open the dialog with every channel at 0, adjusting fills and strokes.
pub fn open(app: &mut VectorcraftApp) {
let fields = json!({
"mode": "rgb", "r": 0, "g": 0, "b": 0, "c": 0, "m": 0, "y": 0, "k": 0, "gray": 0,
"convert": false, "fill": true, "stroke": true, "preview": true,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
}
fn mode(d: &Dialog) -> &'static Mode {
let id = d.str("mode");
MODES.iter().find(|m| m.id == id).unwrap_or(&MODES[1])
}
/// `edit.colors.adjustBalance` parameters: the mode, its channels and the options.
fn params(d: &Dialog) -> Value {
let m = mode(d);
let mut p = json!({"mode": m.id, "convert": d.bool("convert"), "fill": d.bool("fill"), "stroke": d.bool("stroke")});
for (k, _) in m.channels {
p[*k] = json!(d.f64(k, 0.0));
}
p
}
/// The rail of channel `key`: the channel from −100 % to +100 % over a mid colour.
fn track(key: &str, x: f32) -> egui::Color32 {
let v = x.clamp(0.0, 1.0);
let c = match key {
"r" => Color::rgb(v, 0.5, 0.5),
"g" => Color::rgb(0.5, v, 0.5),
"b" => Color::rgb(0.5, 0.5, v),
"c" => Color::cmyk(v, 0.0, 0.0, 0.0),
"m" => Color::cmyk(0.0, v, 0.0, 0.0),
"y" => Color::cmyk(0.0, 0.0, v, 0.0),
"k" => Color::cmyk(0.0, 0.0, 0.0, v),
_ => Color::gray(v),
};
c32(&c)
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let m = mode(d);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Color Mode:");
let labels: Vec<&str> = MODES.iter().map(|m| m.label).collect();
if let Some(i) = widgets::dropdown(ui, "balance-mode", m.label, &labels, 120.0) {
d.fields.insert("mode".into(), json!(MODES[i].id));
}
ui.add_space(12.0);
if widgets::check(ui, "Convert", d.bool("convert"), !m.channels.is_empty()) {
d.fields.insert("convert".into(), json!(!d.bool("convert")));
}
});
ui.add_space(8.0);
let channels = mode(d).channels;
if channels.is_empty() {
widgets::dim_label(ui, "Global shifts the tints of global and spot colors (not available yet).");
}
for (k, label) in channels {
form::slider(ui, d, k, label, -100.0..=100.0, "%", &|x| track(k, x));
}
ui.add_space(8.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Adjust Options:");
for (k, label) in [("fill", "Fill"), ("stroke", "Stroke")] {
if widgets::check(ui, label, d.bool(k), true) {
d.fields.insert(k.into(), json!(!d.bool(k)));
}
}
});
let p = params(d);
form::preview(app, ui, d, "Adjust Colors", CMD, p);
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
form::commit_preview(app, CMD, params(d))
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn fill_hex(app: &VectorcraftApp, id: u64) -> String {
let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
n.appearance.fill_paint().color().unwrap().to_hex()
}
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
#[test]
fn previews_live_and_commits_one_undo_step() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("paint.setFill", json!({"color": "#808080"})).unwrap();
let undo = app.session.doc().unwrap().history.undo.len();
app.run("ui.colorBalanceDialog", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND));
app.ui.dialog.as_mut().unwrap().fields.insert("r".into(), json!(20));
frame(&mut app);
assert!(app.session.in_interaction(), "the preview runs");
assert_eq!(fill_hex(&app, id), "#b38080", "previewed on the canvas");
// A field set from outside (`ui.dialog.set`) previews on the next frame.
app.ui.dialog.as_mut().unwrap().fields.insert("r".into(), json!(40));
frame(&mut app);
assert_eq!(fill_hex(&app, id), "#e68080");
// Cancel rolls the preview back.
super::super::cancel(&mut app);
assert_eq!(fill_hex(&app, id), "#808080");
// OK keeps it as one undo step.
app.run("ui.colorBalanceDialog", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("mode".into(), json!("cmyk"));
d.fields.insert("k".into(), json!(-50));
frame(&mut app);
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
let lighter = vectorcraft_color::Color::from_hex(&fill_hex(&app, id)).unwrap().to_rgb()[0];
assert!(lighter > 0.6, "{lighter}");
}
#[test]
fn global_mode_reports_why_and_stays_open() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
app.run("ui.colorBalanceDialog", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("global"));
frame(&mut app);
let e = super::super::confirm(&mut app).unwrap_err();
assert!(e.contains("global"), "{e}");
assert!(app.ui.dialog.is_some());
}
}

View File

@ -143,7 +143,7 @@ pub(super) const SLIDER_LABEL: f32 = 64.0;
pub(super) const SLIDER_WIDTH: f32 = 180.0;
/// A labelled slider with a value field for the number `d.fields[key]` in `range` (whole numbers,
/// `suffix` after the value); `track(t)` colours the rail at 0..1. Returns true when it changed.
/// `suffix` after the value); `track(t)` colours the rail at 0..1.
pub(super) fn slider(
ui: &mut egui::Ui,
d: &mut Dialog,
@ -152,7 +152,7 @@ pub(super) fn slider(
range: std::ops::RangeInclusive<f64>,
suffix: &str,
track: &dyn Fn(f32) -> egui::Color32,
) -> bool {
) {
let t = Tokens::get(ui.ctx());
let (min, max) = (*range.start(), *range.end());
let v = d.f64(key, 0.0).clamp(min, max);
@ -166,11 +166,50 @@ pub(super) fn slider(
new = Some(x.round().clamp(min, max));
}
});
match new {
Some(n) if n != v => {
d.fields.insert(key.into(), json!(n));
true
}
_ => false,
if let Some(n) = new.filter(|n| *n != v) {
d.fields.insert(key.into(), json!(n));
}
}
/// The Preview checkbox of a dialog that previews `cmd` on the canvas: while it is on, `params` run
/// again on the interaction's snapshot whenever they differ from the last preview (whether a widget
/// or `ui.dialog.set` changed them); turning it off rolls back.
pub(super) fn preview(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, label: &str, cmd: &str, params: Value) {
const LAST: &str = "__previewed";
ui.add_space(8.0);
let mut on = d.bool("preview");
if crate::widgets::check(ui, "Preview", on, true) {
on = !on;
d.fields.insert("preview".into(), json!(on));
}
if !on {
if d.fields.remove(LAST).is_some() {
let _ = app.session.cancel_interaction();
}
return;
}
if d.fields.get(LAST) != Some(&params) || !app.session.in_interaction() {
let _ = app.session.begin_interaction(label);
if let Err(e) = app.session.preview(cmd, &params) {
app.status(e.to_string());
}
d.fields.insert(LAST.into(), params);
}
}
/// OK of a dialog using [`preview`]: keep the previewed `cmd` as one undo step (or run it when no
/// preview runs) and close. On an error the dialog stays open.
pub(super) fn commit_preview(app: &mut crate::VectorcraftApp, cmd: &str, params: Value) -> Result<Value, String> {
let r = if app.session.in_interaction() {
match app.session.preview(cmd, &params) {
Ok(_) => app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()),
Err(e) => Err(e.to_string()),
}
} else {
app.run(cmd, params)
};
if r.is_ok() {
app.ui.dialog = None;
}
r
}

View File

@ -9,6 +9,7 @@
mod about;
mod all_tools;
mod artboard_options;
pub mod color_balance;
pub mod color_guide_options;
mod color_picker;
mod command;
@ -24,6 +25,7 @@ mod new_document;
pub mod new_swatch;
mod path_ops;
mod recolor;
pub mod saturate;
mod save_changes;
mod shapes;
pub mod swatch_options;
@ -146,6 +148,8 @@ registry! {
GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC,
EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC,
ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC,
ColorBalance: [color_balance::KIND] => color_balance::SPEC,
Saturate: [saturate::KIND] => saturate::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).

View File

@ -0,0 +1,61 @@
//! Edit → Edit Colors → Saturate: an intensity slider (−100..100 %) previewed live on the canvas;
//! OK keeps the preview as one undo step (`edit.colors.saturate`).
//!
//! Fields: `intensity` and `preview`.
use serde_json::{Value, json};
use vectorcraft_color::Color;
use super::{DialogSpec, form};
use crate::VectorcraftApp;
use crate::panels::c32;
use crate::state::Dialog;
/// The dialog kind of Saturate.
pub const KIND: &str = "saturate";
const CMD: &str = "edit.colors.saturate";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Saturate".into(), body, confirm, preview: true, ..DialogSpec::FORM };
pub fn open(app: &mut VectorcraftApp) {
app.ui.dialog = Some(Dialog::new(KIND, json!({"intensity": 0, "preview": true})));
}
fn params(d: &Dialog) -> Value {
json!({"intensity": d.f64("intensity", 0.0)})
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
// The rail runs from grey to a fully saturated colour.
form::slider(ui, d, "intensity", "Intensity:", -100.0..=100.0, "%", &|x| c32(&Color::from_hsb(12.0, x, 0.85)));
let p = params(d);
form::preview(app, ui, d, "Saturate", CMD, p);
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
form::commit_preview(app, CMD, params(d))
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
#[test]
fn commits_without_a_drawn_frame_as_one_step() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("paint.setFill", json!({"color": "#cc6666"})).unwrap();
let undo = app.session.doc().unwrap().history.undo.len();
app.run("ui.saturateDialog", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("intensity".into(), json!(-100));
super::super::confirm(&mut app).unwrap();
let st = app.session.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1);
let c = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint().color().unwrap();
assert!(c.to_hsb()[1] < 1e-3, "fully desaturated");
}
}

View File

@ -207,6 +207,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{} open Color Guide Options (dialog `colorGuideOptions`, fields `steps` 1–20 and `amount` 0–100): OK sets the Color Guide panel's variation grid (read back with `ui.inspect`: ui.color_guide; engine: color.harmony)",
),
(
"ui.colorBalanceDialog",
"Adjust Color Balance…",
"",
"{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step",
),
(
"ui.saturateDialog",
"Saturate…",
"",
"{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step",
),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@ -532,6 +544,14 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
crate::dialogs::color_guide_options::open(app);
Ok(Value::Null)
}
"ui.colorBalanceDialog" => {
crate::dialogs::color_balance::open(app);
Ok(Value::Null)
}
"ui.saturateDialog" => {
crate::dialogs::saturate::open(app);
Ok(Value::Null)
}
_ => return None,
};
Some(r)
@ -696,6 +716,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"file.export.pdf" => app.session.active().is_some(),
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
"ui.graphicStyleOptions" => app.session.active().is_some(),
"ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
_ => true,
}
}
@ -790,7 +811,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
"Edit Colors",
vec![
c("Recolor Artwork…", "ui.recolorDialog"),
cp("Adjust Color Balance…", "edit.colors.adjustBalance", json!({"r": 0, "g": 0, "b": 0})),
c("Adjust Color Balance…", "ui.colorBalanceDialog"),
c("Blend Front to Back", "edit.colors.blendFrontToBack"),
c("Blend Horizontally", "edit.colors.blendHorizontally"),
c("Blend Vertically", "edit.colors.blendVertically"),
@ -803,7 +824,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
"edit.colors.overprintBlack",
json!({"remove": false, "percentage": 100, "fill": true, "stroke": true, "includeCmyBlacks": false, "includeSpotBlacks": false}),
),
cp("Saturate…", "edit.colors.saturate", json!({"intensity": 20})),
c("Saturate…", "ui.saturateDialog"),
],
),
todo("Edit Original"),

View File

@ -70,3 +70,10 @@ its variation grid; the panel draws the same grid. `ui.colorGuideOptions` opens
(fields `steps` 1–20, `amount` 0–100) and `ui.dialog.confirm` sets the panel's options, which `ui.inspect` reports
as `ui.color_guide` (`variation`, `steps`, `amount`). Save Colors as Swatches runs `swatch.new {colors: [...]}`
(one swatch per colour, one undo step).
Edit Colors dialogs: `ui.colorBalanceDialog` opens Adjust Colors (`colorBalance`: fields `mode` `gray`/`rgb`/`cmyk`/
`global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` from −100 to 100, `convert`, `fill`, `stroke`,
`preview`) and `ui.saturateDialog` opens Saturate (`saturate`: `intensity` −100..100, `preview`). Both preview on the
canvas while open; `ui.dialog.confirm` keeps the result as one undo step (`edit.colors.adjustBalance` /
`edit.colors.saturate`) and `ui.dialog.cancel` rolls it back. Global mode answers with an error until tints of global
and spot colours exist; the dialog then stays open.