From f8ccff24704997da3dfec5268bc57d88195ea1a1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 08:45:39 -0400 Subject: [PATCH] F: Color panel, Color Picker, unnamed colours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - color: Swatch.hidden — unnamed colours (applied, not listed in Swatches until added) - engine: object.color (reuses a swatch with the same colour, else an unnamed one; fill or stroke), swatch.addToSwatches (optionally named; users follow the rename), swatch.addUnnamed (+ test) - UI: Color panel with fill/stroke proxy, CMYK/RGB/Lab, channel sliders with gradient tracks and value fields, tint slider for named swatches, spectrum ramp, Add to Swatches; Color Picker dialog (double-click a proxy chip); Swatches hides unnamed colours Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + apps/designcraft-cli/src/perf.rs | 1 + crates/color/src/swatch.rs | 27 +++- crates/engine/src/cmd/style.rs | 165 +++++++++++++++++++++ crates/engine/src/sample.rs | 1 + crates/idml/src/import.rs | 11 +- crates/idml/src/tests.rs | 8 +- crates/pdf/src/tests.rs | 1 + crates/ui-egui/src/dialogs.rs | 47 ++++++ crates/ui-egui/src/panels/mod.rs | 6 + crates/ui-egui/src/panels/swatches.rs | 197 ++++++++++++++++++++++++-- docs/parity.md | 6 +- 12 files changed, 445 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c61975e8..da8d6d89 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,6 +28,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). - Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). +- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. - Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. - Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather. diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 083cd6dc..48402278 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -172,6 +172,7 @@ pub fn synthetic(spec: &Spec) -> Document { }, locked: false, named: true, + hidden: false, }); let mut rng = Rng(0x5eed); let (aw, ah) = (800u32, 600u32); diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs index e0b32b1f..42c30c3d 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -53,15 +53,31 @@ pub struct Swatch { /// User-named (false = the name is derived from the colour values and tracks edits). #[serde(default)] pub named: bool, + /// An unnamed colour (mixed in the Color panel or picker): applied to objects but not + /// listed in the Swatches panel until Add to Swatches. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hidden: bool, } impl Swatch { pub fn color(name: &str, c: Color) -> Self { - Swatch { name: name.into(), value: SwatchValue::Color { color: c, color_type: ColorType::Process }, locked: false, named: true } + Swatch { + name: name.into(), + value: SwatchValue::Color { color: c, color_type: ColorType::Process }, + locked: false, + named: true, + hidden: false, + } } pub fn cmyk(c: f32, m: f32, y: f32, k: f32) -> Self { let col = Color::cmyk(c, m, y, k); - Swatch { name: cmyk_name(c, m, y, k), value: SwatchValue::Color { color: col, color_type: ColorType::Process }, locked: false, named: false } + Swatch { + name: cmyk_name(c, m, y, k), + value: SwatchValue::Color { color: col, color_type: ColorType::Process }, + locked: false, + named: false, + hidden: false, + } } pub fn is_special(&self) -> bool { matches!(self.value, SwatchValue::None | SwatchValue::Paper { .. } | SwatchValue::Registration) || self.name == BLACK @@ -118,7 +134,7 @@ pub fn apply_tint(c: Color, tint: f32) -> Color { /// A new document's swatches, in panel order. pub fn default_swatches() -> Vec { - let special = |name: &str, value: SwatchValue| Swatch { name: name.into(), value, locked: true, named: true }; + let special = |name: &str, value: SwatchValue| Swatch { name: name.into(), value, locked: true, named: true, hidden: false }; let mut v = vec![ special(NONE, SwatchValue::None), special(REGISTRATION, SwatchValue::Registration), @@ -162,6 +178,7 @@ mod tests { value: SwatchValue::Tint { base: "C=100 M=0 Y=0 K=0".into(), tint: 0.5 }, locked: false, named: true, + hidden: false, }); assert_eq!(resolve(&s, NONE, 1.0), None); assert_eq!(resolve(&s, PAPER, 1.0), Some(Color::WHITE)); @@ -173,8 +190,8 @@ mod tests { #[test] fn tint_cycles_terminate() { let s = vec![ - Swatch { name: "a".into(), value: SwatchValue::Tint { base: "b".into(), tint: 0.5 }, locked: false, named: true }, - Swatch { name: "b".into(), value: SwatchValue::Tint { base: "a".into(), tint: 0.5 }, locked: false, named: true }, + Swatch { name: "a".into(), value: SwatchValue::Tint { base: "b".into(), tint: 0.5 }, locked: false, named: true, hidden: false }, + Swatch { name: "b".into(), value: SwatchValue::Tint { base: "a".into(), tint: 0.5 }, locked: false, named: true, hidden: false }, ]; assert_eq!(resolve(&s, "a", 1.0), None); } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 8949bb30..7d0582eb 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -51,6 +51,36 @@ pub fn specs() -> Vec { has_doc, create_swatch ), + cmd!( + "object.color", + "Apply Color", + [], + None, + "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection", + super::has_selection, + apply_color + ), + cmd!( + "swatch.addToSwatches", + "Add to Swatches", + [], + None, + "{swatch?: name (default: the selection's fill), target?: fill|stroke, name?: new name} — makes an unnamed colour a swatch", + has_doc, + add_to_swatches + ), + cmd!("swatch.addUnnamed", "Add Unnamed Colors", [], None, "{} — every unnamed colour used becomes a swatch", has_doc, |s, _| { + s.edit(|d, _| { + let mut n = 0; + for w in &mut d.swatches { + if w.hidden { + w.hidden = false; + n += 1; + } + } + Ok(json!({"added": n})) + }) + }), cmd!("swatch.delete", "Delete Swatch", [], None, "{name}", has_doc, |s, p| { let name = str_param(p, "name").unwrap_or("").to_string(); s.edit(|d, _| { @@ -259,6 +289,7 @@ fn create_swatch(s: &mut Session, p: &Value) -> Result { }, locked: false, named: true, + hidden: false, }); Ok(json!({"name": name})) }) @@ -337,3 +368,137 @@ fn create_object(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": name})) }) } + +/// The swatch name of a colour value ("C=… M=… Y=… K=…" / "R=… G=… B=…"). +fn value_name(color: designcraft_color::Color) -> String { + match color { + designcraft_color::Color::Cmyk { c, m, y, k } => designcraft_color::swatch::cmyk_name(c, m, y, k), + other => { + let [r, g, b] = other.to_rgb(); + format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round()) + } + } +} + +fn apply_color(s: &mut Session, p: &Value) -> Result { + let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?; + let stroke = str_param(p, "target") == Some("stroke"); + let ids = super::targets(s, p)?; + s.edit(|d, _| { + // Reuse a swatch holding exactly this colour, else add an unnamed one. + let existing = d + .swatches + .iter() + .find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color)) + .map(|w| w.name.clone()); + let name = match existing { + Some(n) => n, + None => { + let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color)); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, + locked: false, + named: false, + hidden: true, + }); + name + } + }; + for id in &ids { + if let Some(it) = d.item_mut(*id) { + if stroke { + it.stroke.swatch = name.clone(); + it.stroke.tint = 1.0; + } else { + it.fill.swatch = name.clone(); + it.fill.tint = 1.0; + } + } + } + Ok(json!({"swatch": name})) + }) +} + +fn add_to_swatches(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let name = match str_param(p, "swatch") { + Some(n) => n.to_string(), + None => { + let id = st.selection.items.first().copied().ok_or_else(|| bad("swatch.addToSwatches", "select an object or give `swatch`"))?; + let it = st.doc.item(id).ok_or_else(|| bad("swatch.addToSwatches", "no such item"))?; + if str_param(p, "target") == Some("stroke") { it.stroke.swatch.clone() } else { it.fill.swatch.clone() } + } + }; + let rename = str_param(p, "name").map(str::to_string); + s.edit(|d, _| { + let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; + if w.locked { + return Err(bad("swatch.addToSwatches", "special swatches are already listed")); + } + w.hidden = false; + let Some(new) = rename.filter(|n| *n != name) else { return Ok(json!({"name": name})) }; + if d.swatches.iter().any(|w| w.name == new) { + return Err(bad("swatch.addToSwatches", format!("a swatch named `{new}` exists"))); + } + let w = d.swatches.iter_mut().find(|w| w.name == name).expect("found above"); + w.name = new.clone(); + w.named = true; + // Everything using the colour follows the rename. + for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { + let sp = std::sync::Arc::make_mut(sp); + for top in &mut sp.items { + rename_in(std::sync::Arc::make_mut(top), &name, &new); + } + } + Ok(json!({"name": new})) + }) +} + +/// Point fills and strokes of an item (and its children) at a renamed swatch. +fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) { + if it.fill.swatch == from { + it.fill.swatch = to.to_string(); + } + if it.stroke.swatch == from { + it.stroke.swatch = to.to_string(); + } + if let Some(kids) = it.children_mut() { + for k in kids { + rename_in(std::sync::Arc::make_mut(k), from, to); + } + } +} + +#[cfg(test)] +mod color_tests { + use super::*; + + #[test] + fn unnamed_colors_then_add_to_swatches() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let listed = |s: &Session| s.doc().unwrap().doc.swatches.iter().filter(|w| !w.hidden).count(); + let before = listed(&s); + let r = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); + assert_eq!(r["swatch"], "C=10 M=20 Y=30 K=0"); + assert_eq!(listed(&s), before, "unnamed: not in the Swatches panel"); + // The same colour again reuses it; the stroke target works too. + let n = s.doc().unwrap().doc.swatches.len(); + s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}, "target": "stroke"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.swatches.len(), n); + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(it.fill.swatch, it.stroke.swatch); + // Add to Swatches with a name: listed, and the object follows the rename. + s.execute("swatch.addToSwatches", &json!({"name": "Sand"})).unwrap(); + assert_eq!(listed(&s), before + 1); + let d = &s.doc().unwrap().doc; + assert_eq!(d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); + // A colour equal to an existing swatch applies that swatch. + s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); + } +} diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 467fc1f9..fea90bcb 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -120,6 +120,7 @@ pub fn magazine() -> Document { }, locked: false, named: true, + hidden: false, }); // Styles. let serif = |style: &str, size: f64| CharAttrs { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 14bbe5a6..07beb2fd 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -469,7 +469,7 @@ impl<'r> Importer<'r> { self.swatch_names.insert(id.to_string(), name.to_string()); order.push(( id.to_string(), - Swatch { name: name.to_string(), value: SwatchValue::Color { color: c, color_type: ty }, locked: false, named }, + Swatch { name: name.to_string(), value: SwatchValue::Color { color: c, color_type: ty }, locked: false, named, hidden: false }, )); } // Tints (after colours: they reference them). @@ -482,7 +482,10 @@ impl<'r> Importer<'r> { _ => format!("{base} {}%", names::num(t)), }; self.swatch_names.insert(id.to_string(), name.clone()); - order.push((id.to_string(), Swatch { name, value: SwatchValue::Tint { base, tint: (t / 100.0) as f32 }, locked: false, named: true })); + order.push(( + id.to_string(), + Swatch { name, value: SwatchValue::Tint { base, tint: (t / 100.0) as f32 }, locked: false, named: true, hidden: false }, + )); } for e in top.iter().filter(|e| e.local() == "Gradient") { let Some(id) = e.get("Self") else { continue }; @@ -498,7 +501,8 @@ impl<'r> Importer<'r> { let kind = if e.get("Type") == Some("Radial") { GradientKind::Radial } else { GradientKind::Linear }; let unnamed = e.get("Visible") == Some("false") || e.get("Name").is_none_or(|n| n.is_empty() || n == "$ID/"); let name = if unnamed { "Gradient".to_string() } else { e.get("Name").unwrap_or_default().to_string() }; - let sw = Swatch { name, value: SwatchValue::Gradient { gradient: Gradient { kind, stops } }, locked: false, named: !unnamed }; + let sw = + Swatch { name, value: SwatchValue::Gradient { gradient: Gradient { kind, stops } }, locked: false, named: !unnamed, hidden: false }; if unnamed { // Unnamed gradients become swatches only when something uses them. self.hidden_swatches.insert(id.to_string(), sw); @@ -579,6 +583,7 @@ impl<'r> Importer<'r> { value: SwatchValue::Color { color: c, color_type: ColorType::Process }, locked: false, named: false, + hidden: false, }); } self.swatch_names.insert(r.to_string(), name.clone()); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index f4f75b54..d61ebdba 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -279,7 +279,13 @@ fn rejects_non_idml() { fn small_doc() -> Document { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); d.swatches.push(Swatch::color("Brand", Color::rgb(1.0, 0.0, 0.0))); - d.swatches.push(Swatch { name: "Brand 30".into(), value: SwatchValue::Tint { base: "Brand".into(), tint: 0.3 }, locked: false, named: true }); + d.swatches.push(Swatch { + name: "Brand 30".into(), + value: SwatchValue::Tint { base: "Brand".into(), tint: 0.3 }, + locked: false, + named: true, + hidden: false, + }); let lid = d.default_layer(); let (f1, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 300.0, 200.0), lid, "One\ttwo\nThree", ParaFormat::default()).unwrap(); let (f2, _) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(700.0, 40.0, 900.0, 200.0), lid, "", ParaFormat::default()).unwrap(); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index bcffba2e..10363bb2 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -102,6 +102,7 @@ fn colours_keep_their_space() { value: SwatchValue::Color { color: Color::cmyk(0.0, 0.5, 1.0, 0.0), color_type: ColorType::Spot }, locked: false, named: true, + hidden: false, }); add_box(&mut d, Rect::new(320.0, 400.0, 420.0, 500.0), "PANTONE Test"); let s = uncompressed(&d, PdfOptions::default()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 828941af..816fc78c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -125,6 +125,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "insertXref" => "New Cross-Reference", "findFont" => "Find/Replace Font", "polygonSettings" => "Polygon Settings", + "colorPicker" => "Color Picker", id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) { Some(c) => c.label.trim_end_matches('…'), None => "Dialog", @@ -259,6 +260,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "footnoteOptions" => footnote_options(app, ui, &mut d), "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), + "colorPicker" => color_picker(ui, &mut d), "polygonSettings" => { egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { ui.label("Number of Sides:"); @@ -421,6 +423,10 @@ pub fn confirm(app: &mut DesignApp) -> Result { } app.run("style.paragraph.edit", params) } + "colorPicker" => { + let hex = d.s("hex"); + app.run("object.color", json!({"color": hex, "target": d.s("target")})) + } "polygonSettings" => app.run("tool.polygonSettings", json!({"sides": d.n("sides").unwrap_or(6.0) as u64, "starInset": d.n("starInset").unwrap_or(0.0)})), "findFont" => { let (f, st) = (d.s("family"), d.s("style")); @@ -1056,6 +1062,47 @@ fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } +/// Color Picker: saturation/brightness field and hue slider, with RGB, CMYK and hex fields. +fn color_picker(ui: &mut egui::Ui, d: &mut Dialog) { + let hex = d.s("hex"); + let mut c = designcraft_color::Color::from_hex(&hex).map(|c| c.to_rgb()).map_or(egui::Color32::BLACK, |[r, g, b]| { + egui::Color32::from_rgb((r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8) + }); + let before = c; + ui.horizontal_top(|ui| { + egui::color_picker::color_picker_color32(ui, &mut c, egui::color_picker::Alpha::Opaque); + ui.vertical(|ui| { + let mut rgb = [c.r() as f32, c.g() as f32, c.b() as f32]; + egui::Grid::new("cp_rgb").num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| { + for (k, l) in ["R", "G", "B"].iter().enumerate() { + ui.label(*l); + ui.add(egui::DragValue::new(&mut rgb[k]).range(0.0..=255.0)); + ui.end_row(); + } + }); + c = egui::Color32::from_rgb(rgb[0] as u8, rgb[1] as u8, rgb[2] as u8); + let col = designcraft_color::Color::rgb8(c.r(), c.g(), c.b()); + let [cc, m, y, k] = col.to_cmyk(); + ui.add_space(6.0); + ui.label(format!("C {:.0}% M {:.0}% Y {:.0}% K {:.0}%", cc * 100.0, m * 100.0, y * 100.0, k * 100.0)); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("#"); + let mut h = format!("{:02x}{:02x}{:02x}", c.r(), c.g(), c.b()); + if ui.add(egui::TextEdit::singleline(&mut h).desired_width(70.0)).changed() + && let Some(n) = designcraft_color::Color::from_hex(&h) + { + let [r, g, b] = n.to_rgb(); + c = egui::Color32::from_rgb((r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8); + } + }); + }); + }); + if c != before { + d.fields.insert("hex".into(), json!(format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b()))); + } +} + /// Find/Replace Font: the document's fonts (missing ones first, flagged) and a replacement. fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { // Listed once when the dialog opens (it scans every story). diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 45d2e9d0..b9133144 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -40,6 +40,12 @@ pub struct SelInfo { pub locked: bool, } +/// Fill (or stroke) tint of the first selected item (1 = 100%). +pub fn sel_tint(app: &DesignApp, stroke: bool) -> f32 { + let Some(st) = app.session.active() else { return 1.0 }; + st.selection.items.first().and_then(|id| st.doc.item(*id)).map_or(1.0, |it| if stroke { it.stroke.tint } else { it.fill.tint }) +} + pub fn sel_info(app: &DesignApp) -> Option { let st = app.session.active()?; if st.selection.items.is_empty() { diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 4b5c9e30..dbcfd22f 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -16,7 +16,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label(egui::RichText::new(if text { "Applies to text" } else { "Applies to fill" }).size(11.0).color(t.text_dim)); }); - for sw in &doc.swatches { + // Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches. + for sw in doc.swatches.iter().filter(|w| !w.hidden) { let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); if fill_cur.as_deref() == Some(sw.name.as_str()) { ui.painter().rect_filled(row, 0.0, t.row_selected); @@ -69,29 +70,197 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// Colour models of the Color panel (its panel menu: Lab, CMYK, RGB). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ColorMode { + Cmyk, + Rgb, + Lab, +} + +/// Color panel: fill/stroke proxy, sliders in the chosen model (or a tint slider for a named +/// swatch), the spectrum ramp, Add to Swatches. Edits apply unnamed colours (`object.color`). pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); let Some(i) = super::sel_info(app) else { - ui.label("Select an object to edit its fill."); + ui.label(egui::RichText::new("Select an object to color its fill or stroke.").color(t.text_dim)); return; }; let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; - let Some(c) = doc.resolve_color(&i.fill, 1.0) else { - ui.label(format!("Fill: {}", i.fill)); + let stroke_id = egui::Id::new("color_panel_stroke"); + let mode_id = egui::Id::new("color_panel_mode"); + let mut stroke: bool = ui.data(|d| d.get_temp(stroke_id)).unwrap_or(false); + let default_mode = if doc.settings.intent == designcraft_doc::Intent::Print { ColorMode::Cmyk } else { ColorMode::Rgb }; + let mut mode: ColorMode = ui.data(|d| d.get_temp(mode_id)).unwrap_or(default_mode); + let target = if stroke { "stroke" } else { "fill" }; + let name = if stroke { i.stroke.clone() } else { i.fill.clone() }; + // Proxy: fill (front) and stroke chips; click to choose, double-click for the Color Picker. + ui.horizontal(|ui| { + for (k, label) in [(false, "Fill"), (true, "Stroke")] { + let n = if k { &i.stroke } else { &i.fill }; + let (c, g) = crate::widgets::swatch_colors(&doc, n, 1.0); + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::paint_chip(ui.painter(), r.shrink(2.0), c, g); + if k == stroke { + ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } + let resp = resp.on_hover_text(format!("{label}: {n} (double-click: Color Picker)")); + if resp.clicked() { + stroke = k; + } + if resp.double_clicked() { + let rgb = doc.resolve_color(n, 1.0).map(|c| c.to_rgb()).unwrap_or([0.0; 3]); + let hex = + format!("#{:02x}{:02x}{:02x}", (rgb[0] * 255.0).round() as u8, (rgb[1] * 255.0).round() as u8, (rgb[2] * 255.0).round() as u8); + app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", json!({"target": if k { "stroke" } else { "fill" }, "hex": hex}))); + } + } + ui.label(egui::RichText::new(&name).size(11.0).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + egui::ComboBox::from_id_salt("color_mode") + .width(64.0) + .selected_text(match mode { + ColorMode::Cmyk => "CMYK", + ColorMode::Rgb => "RGB", + ColorMode::Lab => "Lab", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut mode, ColorMode::Lab, "Lab"); + ui.selectable_value(&mut mode, ColorMode::Cmyk, "CMYK"); + ui.selectable_value(&mut mode, ColorMode::Rgb, "RGB"); + }); + }); + }); + ui.data_mut(|d| { + d.insert_temp(stroke_id, stroke); + d.insert_temp(mode_id, mode); + }); + let sw = doc.swatch(&name).cloned(); + let Some(c) = doc.resolve_color(&name, 1.0) else { + ui.label(egui::RichText::new("[None] — choose a colour on the ramp.").color(t.text_dim)); + spectrum(app, ui, target); return; }; - let [mut cc, mut m, mut y, mut k] = c.to_cmyk(); - let mut changed = false; - for (label, v) in [("C", &mut cc), ("M", &mut m), ("Y", &mut y), ("K", &mut k)] { + // A named swatch shows its tint; mixing its values makes an unnamed colour. + if let Some(sw) = sw.as_ref().filter(|w| !w.hidden && !w.locked) { + let cur = if stroke { super::sel_tint(app, true) } else { super::sel_tint(app, false) }; + let mut tint = cur * 100.0; ui.horizontal(|ui| { - ui.label(label); - let mut p = *v * 100.0; - if ui.add(egui::Slider::new(&mut p, 0.0..=100.0).suffix("%").integer()).drag_stopped() { - *v = p / 100.0; - changed = true; + ui.label("T"); + if ui.add(egui::Slider::new(&mut tint, 0.0..=100.0).suffix("%").integer()).drag_stopped() { + let cmd = if stroke { "object.stroke" } else { "object.fill" }; + let _ = app.run(cmd, json!({"swatch": sw.name, "tint": tint / 100.0})); } }); } - if changed && let Ok(r) = app.run("swatch.create", json!({"color": {"c": cc * 100.0, "m": m * 100.0, "y": y * 100.0, "k": k * 100.0}})) { - let _ = app.run("object.fill", json!({"swatch": r["name"]})); + let mut send: Option = None; + match mode { + ColorMode::Cmyk => { + let mut v = c.to_cmyk(); + let mut done = false; + for (k, label) in ["C", "M", "Y", "K"].iter().enumerate() { + done |= channel(ui, label, &mut v[k], 100.0, "%", |x| cmyk_ramp(k, x)); + } + if done { + send = Some(json!({"c": v[0] * 100.0, "m": v[1] * 100.0, "y": v[2] * 100.0, "k": v[3] * 100.0})); + } + } + ColorMode::Rgb => { + let mut v = c.to_rgb(); + let mut done = false; + for (k, label) in ["R", "G", "B"].iter().enumerate() { + done |= channel(ui, label, &mut v[k], 255.0, "", |x| { + let mut p = [0.0; 3]; + p[k] = x; + egui::Color32::from_rgb((p[0] * 255.0) as u8, (p[1] * 255.0) as u8, (p[2] * 255.0) as u8) + }); + } + if done { + send = Some(json!([(v[0] * 255.0).round(), (v[1] * 255.0).round(), (v[2] * 255.0).round()])); + } + } + ColorMode::Lab => { + let lab = c.to_lab(); + ui.label(egui::RichText::new(format!("L {:.0} a {:.0} b {:.0}", lab.l, lab.a, lab.b)).color(t.text_dim)); + } + } + if let Some(color) = send { + let _ = app.run("object.color", json!({"color": color, "target": target})); + } + spectrum(app, ui, target); + ui.horizontal(|ui| { + let unnamed = sw.as_ref().is_some_and(|w| w.hidden); + if ui.add_enabled(unnamed, egui::Button::new("Add to Swatches")).clicked() { + let _ = app.run("swatch.addToSwatches", json!({"target": target})); + } + }); +} + +/// One slider with a gradient track of the channel and a value field. True when it was changed +/// (on release / edit). +fn channel(ui: &mut egui::Ui, label: &str, v: &mut f32, scale: f32, suffix: &str, ramp: impl Fn(f32) -> egui::Color32) -> bool { + let mut changed = false; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(label).monospace()); + let w = (ui.available_width() - 60.0).max(60.0); + let (track, resp) = ui.allocate_exact_size(vec2(w, 14.0), Sense::click_and_drag()); + // Gradient track. + let n = 24; + for i in 0..n { + let x0 = track.min.x + track.width() * i as f32 / n as f32; + let x1 = track.min.x + track.width() * (i + 1) as f32 / n as f32; + let r = egui::Rect::from_min_max(egui::pos2(x0, track.min.y + 2.0), egui::pos2(x1 + 0.5, track.max.y - 2.0)); + ui.painter().rect_filled(r, 0.0, ramp((i as f32 + 0.5) / n as f32)); + } + let x = track.min.x + track.width() * v.clamp(0.0, 1.0); + ui.painter().add(egui::Shape::convex_polygon( + vec![egui::pos2(x, track.max.y - 3.0), egui::pos2(x - 4.0, track.max.y + 3.0), egui::pos2(x + 4.0, track.max.y + 3.0)], + ui.visuals().text_color(), + egui::Stroke::NONE, + )); + if let Some(p) = resp.interact_pointer_pos() { + *v = ((p.x - track.min.x) / track.width()).clamp(0.0, 1.0); + } + changed |= resp.drag_stopped() || resp.clicked(); + let mut num = (*v * scale).round(); + let field = ui.add(egui::DragValue::new(&mut num).range(0.0..=scale).suffix(suffix)); + if field.changed() { + *v = num / scale; + } + changed |= field.lost_focus() || field.drag_stopped(); + }); + changed +} + +fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 { + let mut v = [0.0f32; 4]; + v[k] = x; + let [r, g, b] = designcraft_color::Color::cmyk(v[0], v[1], v[2], v[3]).to_rgb(); + egui::Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) +} + +/// The spectrum ramp: hue across, light to dark down; click to apply. +fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); + let at = |u: f32, v: f32| -> [f32; 3] { + let c = egui::ecolor::Hsva::new(u, (v * 2.0).min(1.0), (2.0 - v * 2.0).min(1.0), 1.0).to_rgb(); + [c[0], c[1], c[2]] + }; + let (nx, ny) = (48, 8); + for i in 0..nx { + for j in 0..ny { + let c = at((i as f32 + 0.5) / nx as f32, (j as f32 + 0.5) / ny as f32); + let cell = egui::Rect::from_min_max( + egui::pos2(r.min.x + r.width() * i as f32 / nx as f32, r.min.y + r.height() * j as f32 / ny as f32), + egui::pos2(r.min.x + r.width() * (i + 1) as f32 / nx as f32 + 0.5, r.min.y + r.height() * (j + 1) as f32 / ny as f32 + 0.5), + ); + ui.painter().rect_filled(cell, 0.0, egui::Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)); + } + } + if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0)); + let _ = app.run("object.color", json!({"color": [(c[0] * 255.0).round(), (c[1] * 255.0).round(), (c[2] * 255.0).round()], "target": target})); } } diff --git a/docs/parity.md b/docs/parity.md index 639d6e9a..051344c7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -140,8 +140,8 @@ Open P0 items: Dockable panels, Recovery / autosave, Preferences, Document Setup | Transform | Dimensions Include Stroke Weight | P0 | M | | Transform | Rotate 90 / Flip buttons | P0 | D | | Fill, stroke, colour | Swatches panel | P0 | D | -| Fill, stroke, colour | Color panel | P0 | P | -| Fill, stroke, colour | Color Picker dialog | P0 | M | +| Fill, stroke, colour | Color panel | P0 | D | +| Fill, stroke, colour | Color Picker dialog | P0 | D | | Fill, stroke, colour | Gradient panel & tool | P0 | P | | Fill, stroke, colour | Gradient Feather tool | P1 | M | | Fill, stroke, colour | Tints | P0 | D | @@ -150,7 +150,7 @@ Open P0 items: Dockable panels, Recovery / autosave, Preferences, Document Setup | Fill, stroke, colour | Ink Manager | P2 | M | | Fill, stroke, colour | Load/Save Swatches (.ase) | P1 | M | | Fill, stroke, colour | Color groups | P2 | M | -| Fill, stroke, colour | Unnamed colour to swatch | P1 | M | +| Fill, stroke, colour | Unnamed colour to swatch | P1 | D | | Fill, stroke, colour | Stroke panel | P0 | P | | Fill, stroke, colour | Stroke styles | P1 | P | | Fill, stroke, colour | Color management | P1 | P |