mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
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:
co-authored by
Claude Opus 5.5
parent
87727f279a
commit
f8ccff2470
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ pub fn magazine() -> Document {
|
||||
},
|
||||
locked: false,
|
||||
named: true,
|
||||
hidden: false,
|
||||
});
|
||||
// Styles.
|
||||
let serif = |style: &str, size: f64| CharAttrs {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user