F: Color panel, Color Picker, unnamed colours

- 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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 08:45:39 -04:00
co-authored by Claude Opus 5.5
parent 87727f279a
commit f8ccff2470
12 changed files with 445 additions and 26 deletions
+1
View File
@@ -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.
+1
View File
@@ -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);
+22 -5
View File
@@ -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<Swatch> {
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);
}
+165
View File
@@ -51,6 +51,36 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
},
locked: false,
named: true,
hidden: false,
});
Ok(json!({"name": name}))
})
@@ -337,3 +368,137 @@ fn create_object(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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");
}
}
+1
View File
@@ -120,6 +120,7 @@ pub fn magazine() -> Document {
},
locked: false,
named: true,
hidden: false,
});
// Styles.
let serif = |style: &str, size: f64| CharAttrs {
+8 -3
View File
@@ -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());
+7 -1
View File
@@ -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();
+1
View File
@@ -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());
+47
View File
@@ -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<Value, String> {
}
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).
+6
View File
@@ -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<SelInfo> {
let st = app.session.active()?;
if st.selection.items.is_empty() {
+183 -14
View File
@@ -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<serde_json::Value> = 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}));
}
}
+3 -3
View File
@@ -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 |