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…",
|
"Adjust Color Balance…",
|
||||||
["Edit", "Edit Colors"],
|
["Edit", "Edit Colors"],
|
||||||
None,
|
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,
|
has_selection,
|
||||||
adjust_balance
|
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> {
|
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 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 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| {
|
recolor(s, p, "Adjust Colors", &move |c| {
|
||||||
let out = if cmyk {
|
let out = adjust(c);
|
||||||
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))
|
|
||||||
};
|
|
||||||
if convert { out } else { keep_model(c, out) }
|
if convert { out } else { keep_model(c, out) }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -758,6 +758,8 @@ mod tests_distort;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_draw2;
|
mod tests_draw2;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_editcolors;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_effectedit;
|
mod tests_effectedit;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_file;
|
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;
|
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,
|
/// 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(
|
pub(super) fn slider(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
d: &mut Dialog,
|
d: &mut Dialog,
|
||||||
|
|
@ -152,7 +152,7 @@ pub(super) fn slider(
|
||||||
range: std::ops::RangeInclusive<f64>,
|
range: std::ops::RangeInclusive<f64>,
|
||||||
suffix: &str,
|
suffix: &str,
|
||||||
track: &dyn Fn(f32) -> egui::Color32,
|
track: &dyn Fn(f32) -> egui::Color32,
|
||||||
) -> bool {
|
) {
|
||||||
let t = Tokens::get(ui.ctx());
|
let t = Tokens::get(ui.ctx());
|
||||||
let (min, max) = (*range.start(), *range.end());
|
let (min, max) = (*range.start(), *range.end());
|
||||||
let v = d.f64(key, 0.0).clamp(min, max);
|
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));
|
new = Some(x.round().clamp(min, max));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
match new {
|
if let Some(n) = new.filter(|n| *n != v) {
|
||||||
Some(n) if n != v => {
|
d.fields.insert(key.into(), json!(n));
|
||||||
d.fields.insert(key.into(), json!(n));
|
|
||||||
true
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 about;
|
||||||
mod all_tools;
|
mod all_tools;
|
||||||
mod artboard_options;
|
mod artboard_options;
|
||||||
|
pub mod color_balance;
|
||||||
pub mod color_guide_options;
|
pub mod color_guide_options;
|
||||||
mod color_picker;
|
mod color_picker;
|
||||||
mod command;
|
mod command;
|
||||||
|
|
@ -24,6 +25,7 @@ mod new_document;
|
||||||
pub mod new_swatch;
|
pub mod new_swatch;
|
||||||
mod path_ops;
|
mod path_ops;
|
||||||
mod recolor;
|
mod recolor;
|
||||||
|
pub mod saturate;
|
||||||
mod save_changes;
|
mod save_changes;
|
||||||
mod shapes;
|
mod shapes;
|
||||||
pub mod swatch_options;
|
pub mod swatch_options;
|
||||||
|
|
@ -146,6 +148,8 @@ registry! {
|
||||||
GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC,
|
GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC,
|
||||||
EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC,
|
EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC,
|
||||||
ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::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).
|
/// 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)",
|
"{} 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).
|
/// 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);
|
crate::dialogs::color_guide_options::open(app);
|
||||||
Ok(Value::Null)
|
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,
|
_ => return None,
|
||||||
};
|
};
|
||||||
Some(r)
|
Some(r)
|
||||||
|
|
@ -696,6 +716,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||||
"file.export.pdf" => app.session.active().is_some(),
|
"file.export.pdf" => app.session.active().is_some(),
|
||||||
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
|
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
|
||||||
"ui.graphicStyleOptions" => 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,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -790,7 +811,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||||
"Edit Colors",
|
"Edit Colors",
|
||||||
vec![
|
vec![
|
||||||
c("Recolor Artwork…", "ui.recolorDialog"),
|
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 Front to Back", "edit.colors.blendFrontToBack"),
|
||||||
c("Blend Horizontally", "edit.colors.blendHorizontally"),
|
c("Blend Horizontally", "edit.colors.blendHorizontally"),
|
||||||
c("Blend Vertically", "edit.colors.blendVertically"),
|
c("Blend Vertically", "edit.colors.blendVertically"),
|
||||||
|
|
@ -803,7 +824,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||||
"edit.colors.overprintBlack",
|
"edit.colors.overprintBlack",
|
||||||
json!({"remove": false, "percentage": 100, "fill": true, "stroke": true, "includeCmyBlacks": false, "includeSpotBlacks": false}),
|
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"),
|
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
|
(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: [...]}`
|
as `ui.color_guide` (`variation`, `steps`, `amount`). Save Colors as Swatches runs `swatch.new {colors: [...]}`
|
||||||
(one swatch per colour, one undo step).
|
(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