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:
parent
bfebf66404
commit
1de1c6446f
|
|
@ -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) }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¶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<Value, String> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue