diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 8d0708b5..967e5073 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "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 { } fn adjust_balance(s: &mut Session, p: &Value) -> Result { + 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 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) } }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 885f0f9a..436db81c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests_editcolors.rs new file mode 100644 index 00000000..23f6ac5e --- /dev/null +++ b/crates/engine/src/tests_editcolors.rs @@ -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"); +} diff --git a/crates/ui-egui/src/dialogs/color_balance.rs b/crates/ui-egui/src/dialogs/color_balance.rs new file mode 100644 index 00000000..b99a3523 --- /dev/null +++ b/crates/ui-egui/src/dialogs/color_balance.rs @@ -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 { + 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()); + } +} diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 2a206e15..d2f03d49 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -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, 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(¶ms) || !app.session.in_interaction() { + let _ = app.session.begin_interaction(label); + if let Err(e) = app.session.preview(cmd, ¶ms) { + 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 { + let r = if app.session.in_interaction() { + match app.session.preview(cmd, ¶ms) { + 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 +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 88c09f79..51e9077e 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -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). diff --git a/crates/ui-egui/src/dialogs/saturate.rs b/crates/ui-egui/src/dialogs/saturate.rs new file mode 100644 index 00000000..37f47230 --- /dev/null +++ b/crates/ui-egui/src/dialogs/saturate.rs @@ -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 { + 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"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9b8f1436..ef51a5cc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { + 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)> { "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)> { "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"), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 63b59eb9..f648f91c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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.