Merge colour-entry: Color Picker and Color panel keep typed values; colours read on a fixed scale (#323)

Conflict: crates/engine/src/cmd/style.rs: object.color params doc and enabled = next's (text?, has_text or selection) + the model form; apply_color = next's text targets and caret typing format + the branch's rounding-based swatch reuse
Conflict: crates/ui-egui/src/dialogs.rs: next's deleteCharacterStyle / swatchOptions arms; colorPicker = the branch's (no-op without edits, model-form colour) with next's `text`
Conflict: crates/ui-egui/src/panels/swatches.rs: double-click opens the picker with picker_fields + next's `text`; spectrum = the branch's model form + next's `text`
Conflict: docs/target-app-parity.md: the branch's Color panel line + next's Threading and Place text lines
Conflict: ROADMAP.md: logs both
This commit is contained in:
aiongg
2026-10-12 22:17:14 +00:00
6 changed files with 427 additions and 51 deletions
+1
View File
@@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-12** · Colour entry: the Color Picker's hex, R/G/B and C/M/Y/K fields and the Color panel's number fields keep typed text until it is complete; panel and picker values are applied on a fixed scale (`{model, …}` colours in 0..1), so M 1 is 1 %. Picking keeps a CMYK colour CMYK; dialogs leave the page behind them undimmed; colours that round to the same swatch name share one swatch (#323). No readiness percentage change.
- **2026-10-12** · Placed photos show in their own colours: embedded ICC profiles (Adobe RGB, ProPhoto, P3, grey) are converted to sRGB on screen, and CMYK JPEGs (including YCCK), CMYK TIFFs and CMYK PSDs go through their embedded CMYK profile or the working CMYK space with the colour settings' intent and black-point compensation, instead of the profile-free formula. Unreadable profiles are ignored. No readiness percentage change.
- **2026-10-12** · Placed Word documents keep their bullets and numbering (#626): `numbering.xml` levels become bullet or numbered paragraphs with Word's number formats, label text, start numbers, restarts and list indents, also when a paragraph style carries the list. No readiness percentage change.
- **2026-10-12** · Text strokes can sit behind the fill: the stroke lies wholly outside the letters at its full weight on screen, in PNG and in PDF, set from the Properties panel or Character Style Options. IDML `StrokeAlignment` on text imports and exports as this setting, so imported text with outside-aligned strokes keeps its letter shapes (#619). No readiness percentage change.
+12 -1
View File
@@ -141,7 +141,7 @@ impl Color {
let [r, g, b, _] = self.to_rgba8(1.0);
format!("#{r:02x}{g:02x}{b:02x}")
}
/// Parse `#rgb`, `#rrggbb` or a few CSS names.
/// Parse `#rgb`, `#rrggbb` (the `#` optional, any case, spaces around) or a few CSS names.
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.trim();
match s.to_ascii_lowercase().as_str() {
@@ -153,6 +153,10 @@ impl Color {
_ => {}
}
let h = s.strip_prefix('#').unwrap_or(s);
// `from_str_radix` takes a sign: "+f+f+f" is not a colour.
if !h.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok();
match h.len() {
3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)),
@@ -244,6 +248,13 @@ mod tests {
assert_eq!(c.to_hex(), "#ff8000");
assert_eq!(Color::from_hex("#fff").unwrap().to_hex(), "#ffffff");
assert_eq!(Color::from_hex("zz"), None);
for typed in ["#F59019", "f59019", " #f59019 "] {
assert_eq!(Color::from_hex(typed).map(|c| c.to_hex()).as_deref(), Some("#f59019"), "{typed:?}");
}
assert_eq!(Color::from_hex("F59").map(|c| c.to_hex()).as_deref(), Some("#ff5599"));
for partial in ["#f5", "#f590", "#f5901", "#f590190", "+f+f+f", "#+1+2+3"] {
assert_eq!(Color::from_hex(partial), None, "{partial:?}");
}
}
#[test]
+78 -5
View File
@@ -136,7 +136,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Color Swatch…",
[],
None,
"{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b], spot?: bool}",
"{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1), spot?: bool}",
has_doc,
create_swatch
),
@@ -181,7 +181,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Color",
[],
None,
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)",
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, r,g,b|c,m,y,k|k}(0..1), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)",
|s| super::has_text(s).or_else(|_| super::has_selection(s)),
apply_color
),
@@ -842,6 +842,8 @@ pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> {
use designcraft_color::Color;
match v {
Value::String(s) => Color::from_hex(s),
// `{model, …}` is the colour's own form (as `color.convert` takes it): fractions, never guessed.
Value::Object(o) if o.contains_key("model") => model_color(v),
// One scale per colour, not per component: {c: 0.7, y: 12.2} is C 0.7 %, Y 12.2 %. Only a colour whose
// components are all within 0..1 is read as fractions (the older form).
Value::Array(a) if a.len() >= 3 => {
@@ -863,6 +865,20 @@ pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> {
}
}
/// `{model: rgb|cmyk|gray, …}` with every component in 0..=1; anything else is not a colour.
fn model_color(v: &Value) -> Option<designcraft_color::Color> {
use designcraft_color::Color;
let color: Color = serde_json::from_value(v.clone()).ok()?;
// A float's last bit (100.0 / 100.0 ≠ 1.0 after an f32 round trip) is not out of range.
let unit = |x: f32| x.is_finite() && (-1e-4..=1.0 + 1e-4).contains(&x);
let q = |x: f32| x.clamp(0.0, 1.0);
match color {
Color::Rgb { r, g, b } => [r, g, b].into_iter().all(unit).then(|| Color::rgb(q(r), q(g), q(b))),
Color::Cmyk { c, m, y, k } => [c, m, y, k].into_iter().all(unit).then(|| Color::cmyk(q(c), q(m), q(y), q(k))),
Color::Gray { k } => unit(k).then_some(Color::Gray { k: q(k) }),
}
}
fn apply_object(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("style.object.apply", "missing name"))?.to_string();
let os = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad("style.object.apply", format!("no object style `{name}`")))?;
@@ -1088,16 +1104,28 @@ fn apply_color(s: &mut Session, p: &Value) -> Result<Value> {
// A caret: the colour is for the next typing (once the swatch exists).
let caret = matches!(texts.as_slice(), [t] if t.range.is_empty());
let r = s.edit(|d, _| {
// Reuse a swatch holding exactly this colour, else add an unnamed one.
// Reuse a swatch holding this colour to the precision its name shows (whole CMYK percents,
// 0-255 RGB), else add an unnamed one: values a fraction of a percent apart would otherwise
// add hidden "C=0 M=50 Y=100 K=0 2" twins.
let shown = value_name(color);
let process = |w: &&Swatch| match &w.value {
SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } => Some(*c),
_ => None,
};
let existing = d
.swatches
.iter()
.find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color))
.find(|w| process(w) == Some(color))
.or_else(|| {
d.swatches
.iter()
.find(|w| process(w).is_some_and(|c| std::mem::discriminant(&c) == std::mem::discriminant(&color) && value_name(c) == shown))
})
.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));
let name = Styles::unique_name(|n| d.swatch(n).is_some(), &shown);
d.swatches.push(Swatch {
name: name.clone(),
value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process },
@@ -1546,6 +1574,51 @@ mod color_tests {
assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6);
}
#[test]
fn a_colour_with_a_model_is_read_as_fractions_without_guessing() {
use designcraft_color::Color;
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 fill = |s: &Session| {
let d = &s.doc().unwrap().doc;
d.resolve_color(&d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, 1.0).unwrap()
};
// M 1 % from the Color panel: not M 100 % (magenta), though every component is within 0..1.
s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0, "m": 0.01, "y": 0, "k": 0}})).unwrap();
let Color::Cmyk { c, m, y, k } = fill(&s) else { panic!("not CMYK") };
assert!(c == 0.0 && (m - 0.01).abs() < 1e-6 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}");
// R 1 of 255 is a near black, not pure red.
s.execute("object.color", &json!({"color": {"model": "rgb", "r": 1.0 / 255.0, "g": 0, "b": 0}})).unwrap();
let Color::Rgb { r, g, b } = fill(&s) else { panic!("not RGB") };
assert!((r - 1.0 / 255.0).abs() < 1e-6 && g == 0.0 && b == 0.0, "got {r} {g} {b}");
// Out of range or not a model: refused rather than guessed.
assert!(s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0, "m": 50, "y": 0, "k": 0}})).is_err());
assert!(s.execute("object.color", &json!({"color": {"model": "hsb", "h": 0, "s": 0, "b": 0}})).is_err());
}
#[test]
fn colours_that_round_to_the_same_name_share_one_swatch() {
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 n = |s: &Session| s.doc().unwrap().doc.swatches.len();
let first = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": 0.5, "y": 1, "k": 0}})).unwrap();
let before = n(&s);
for m in [0.501, 0.499, 0.5041] {
let r = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": m, "y": 1, "k": 0}})).unwrap();
assert_eq!(r["swatch"], first["swatch"], "M {m}");
}
s.execute("object.color", &json!({"color": {"model": "rgb", "r": 0.5, "g": 0.25, "b": 0}})).unwrap();
s.execute("object.color", &json!({"color": {"model": "rgb", "r": 0.5005, "g": 0.25, "b": 0}})).unwrap();
assert_eq!(n(&s), before + 1, "no hidden twins for values a fraction of a step apart");
// A whole percent apart is a different colour.
let r = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": 0.51, "y": 1, "k": 0}})).unwrap();
assert_eq!(r["swatch"], "C=10 M=51 Y=100 K=0");
}
#[test]
fn style_groups_rename_every_use() {
let mut s = Session::new();
+249 -27
View File
@@ -1524,7 +1524,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
None => crate::i18n::tr(&app.ui.language, "Dialog"),
},
};
egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| {
// No dimmed backdrop: the page behind a dialog is what its settings and colours are judged against.
egui::Modal::new(egui::Id::new("dialog")).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| {
let frame_margin = egui::Frame::popup(ui.style()).total_margin().sum();
let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0));
let preferred_width: f32 = match d.id.as_str() {
@@ -2420,9 +2421,17 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
r
}
// Nothing edited: OK leaves the fill or stroke as it was (its swatch and tint).
"colorPicker" if !d.b("edited") => Ok(Value::Null),
"colorPicker" => {
let hex = d.s("hex");
app.run("object.color", json!({"color": hex, "target": d.s("target"), "text": d.b("text")}))
let color = if d.s("model") == "cmyk" {
let [c, m, y, k] = nums::<4>(&d, "cmyk").ok_or("no colour")?.map(|v| v / 100.0);
json!({"model": "cmyk", "c": c, "m": m, "y": y, "k": k})
} else {
let [r, g, b] = nums::<3>(&d, "rgb").ok_or("no colour")?.map(|v| v / 255.0);
json!({"model": "rgb", "r": r, "g": g, "b": b})
};
app.run("object.color", json!({"color": color, "target": d.s("target"), "text": d.b("text")}))
}
"layerOptions" => {
let hidden: Vec<Value> = d.fields.get("layers").and_then(Value::as_array).map(|a| a.iter().filter(|l| l["visible"] == false).map(|l| l["name"].clone()).collect()).unwrap_or_default();
@@ -5394,45 +5403,116 @@ 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.
/// `N` numbers from a dialog field holding a JSON array.
fn nums<const N: usize>(d: &Dialog, k: &str) -> Option<[f64; N]> {
let a = d.fields.get(k)?.as_array()?;
if a.len() != N {
return None;
}
let mut out = [0.0; N];
for (o, v) in out.iter_mut().zip(a) {
*o = v.as_f64().filter(|x| x.is_finite())?;
}
Some(out)
}
/// What changed the Color Picker's colour this frame.
#[derive(Clone, Copy, PartialEq)]
enum ColorEdit {
None,
/// The saturation/brightness field, the hue slider or the hex field: keeps the colour's model.
Picked,
/// Typed R/G/B values: the colour becomes RGB.
Rgb,
/// Typed C/M/Y/K values: the colour becomes CMYK.
Cmyk,
}
/// Color Picker: saturation/brightness field and hue slider, with R/G/B, C/M/Y/K and hex fields.
///
/// `rgb` (0-255) and `cmyk` (percent) hold the values as typed, so a field being edited is never
/// rebuilt from the colour; the hex field keeps its text in `hexEdit` while it has focus and shows
/// the colour again when focus leaves. `model` is what OK applies: picking keeps a CMYK colour CMYK
/// (converted through the CMYK working space); typing R/G/B or C/M/Y/K values chooses that model.
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;
use designcraft_color::Color;
let to8 = |v: f64| v.clamp(0.0, 255.0).round() as u8;
let display_rgb = |c: Color| c.to_rgb().map(|v| (f64::from(v) * 255.0).clamp(0.0, 255.0).round());
let cmyk_of = |rgb: [f64; 3]| {
let c = Color::rgb((rgb[0] / 255.0) as f32, (rgb[1] / 255.0) as f32, (rgb[2] / 255.0) as f32);
c.to_cmyk_managed(designcraft_color::cms::active_settings().intent).map(|v| f64::from(v) * 100.0)
};
let hex_rgb = |s: &str| match Color::from_hex(s) {
Some(Color::Rgb { r, g, b }) => Some([r, g, b].map(|v| (f64::from(v) * 255.0).round())),
_ => None,
};
let mut rgb = nums::<3>(d, "rgb").unwrap_or_else(|| hex_rgb(&d.s("hex")).unwrap_or([0.0; 3]));
let mut cmyk = nums::<4>(d, "cmyk").unwrap_or_else(|| cmyk_of(rgb));
let mut model = if d.s("model") == "cmyk" || (!d.fields.contains_key("model") && d.fields.contains_key("cmyk")) { "cmyk" } else { "rgb" };
let mut edit = ColorEdit::None;
let hex_id = egui::Id::new("color_picker_hex");
ui.horizontal_top(|ui| {
egui::color_picker::color_picker_color32(ui, &mut c, egui::color_picker::Alpha::Opaque);
let mut c = egui::Color32::from_rgb(to8(rgb[0]), to8(rgb[1]), to8(rgb[2]));
let shown = c;
if egui::color_picker::color_picker_color32(ui, &mut c, egui::color_picker::Alpha::Opaque) && c != shown {
rgb = [c.r(), c.g(), c.b()].map(f64::from);
edit = ColorEdit::Picked;
}
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));
egui::Grid::new("cp_values").num_columns(4).spacing([6.0, 4.0]).show(ui, |ui| {
let mut rgb_rows = rgb.iter_mut().zip(["R", "G", "B"]);
for (v, l) in cmyk.iter_mut().zip(["C", "M", "Y", "K"]) {
if let Some((rv, rl)) = rgb_rows.next() {
ui.label(rl);
if ui.add(egui::DragValue::new(rv).range(0.0..=255.0).max_decimals(1)).changed() {
edit = ColorEdit::Rgb;
}
} else {
ui.label("");
ui.label("");
}
ui.label(l);
if ui.add(egui::DragValue::new(v).range(0.0..=100.0).max_decimals(1).suffix("%")).changed() {
edit = ColorEdit::Cmyk;
}
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 editing = ui.memory(|m| m.has_focus(hex_id));
let mut h = if editing { d.s("hexEdit") } else { format!("{:02x}{:02x}{:02x}", to8(rgb[0]), to8(rgb[1]), to8(rgb[2])) };
if ui.add(egui::TextEdit::singleline(&mut h).id(hex_id).desired_width(70.0)).changed()
&& let Some(v) = hex_rgb(&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);
rgb = v;
edit = ColorEdit::Picked;
}
d.fields.insert("hexEdit".into(), json!(h));
});
});
});
if c != before {
d.fields.insert("hex".into(), json!(format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())));
match edit {
ColorEdit::None => {}
ColorEdit::Picked => cmyk = cmyk_of(rgb),
ColorEdit::Rgb => {
model = "rgb";
cmyk = cmyk_of(rgb);
}
ColorEdit::Cmyk => {
model = "cmyk";
let [c, m, y, k] = cmyk.map(|v| (v / 100.0) as f32);
rgb = display_rgb(Color::cmyk(c, m, y, k));
}
}
if edit != ColorEdit::None {
d.fields.insert("edited".into(), json!(true));
}
d.fields.insert("rgb".into(), json!(rgb));
d.fields.insert("cmyk".into(), json!(cmyk));
d.fields.insert("model".into(), json!(model));
d.fields.insert("hex".into(), json!(format!("#{:02x}{:02x}{:02x}", to8(rgb[0]), to8(rgb[1]), to8(rgb[2]))));
}
/// Find/Replace Font: the document's fonts (missing ones first, flagged) and a replacement.
@@ -5890,6 +5970,148 @@ mod tests {
}
}
fn select_all_key() -> egui::Event {
egui::Event::Key {
key: egui::Key::A,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers { ctrl: true, command: true, ..Default::default() },
}
}
/// The Color Picker over a new document's selected frame, a few frames in.
fn picker(fields: Value) -> (DesignApp, egui::Context, egui::Rect, u64) {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let id = app.run("frame.create", json!({"rect": [36, 36, 200, 200]})).unwrap()["id"].as_u64().unwrap();
app.run("selection.set", json!({"ids": [id]})).unwrap();
app.ui.dialog = Some(Dialog::new("colorPicker", fields));
let ctx = dialog_context("en");
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(700.0, 500.0));
for _ in 0..3 {
dialog_frame(&mut app, &ctx, screen, vec![]);
}
(app, ctx, screen, id)
}
/// The rightmost painted `text`: the picker's own field, right of egui's colour editor (which
/// has R/G/B fields of its own).
fn rightmost(labels: &[(String, egui::Rect, egui::Rect)], text: &str) -> egui::Pos2 {
labels
.iter()
.filter(|(t, _, _)| t.trim() == text)
.map(|(_, r, _)| r.center())
.max_by(|a, b| a.x.total_cmp(&b.x))
.unwrap_or_else(|| panic!("{text} is painted"))
}
/// Types `text` one character per frame, with an idle frame after each.
fn type_slowly(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, text: &str) {
for ch in text.chars() {
dialog_frame(app, ctx, screen, vec![egui::Event::Text(ch.into())]);
dialog_frame(app, ctx, screen, vec![]);
}
}
fn fill(app: &DesignApp, id: u64) -> designcraft_color::Color {
let d = &app.session.active().unwrap().doc;
d.resolve_color(&d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, 1.0).unwrap()
}
#[test]
fn the_hex_field_keeps_typed_text_and_commits_a_valid_code() {
let (mut app, ctx, screen, _) = picker(json!({"target": "fill", "hex": "#ffffff"}));
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "ffffff").center());
dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]);
type_slowly(&mut app, &ctx, screen, "#f5");
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.s("hexEdit").as_str(), d.s("hex").as_str()), ("#f5", "#ffffff"), "half-typed text stays; the colour waits");
type_slowly(&mut app, &ctx, screen, "9019");
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.s("hexEdit").as_str(), d.s("hex").as_str()), ("#f59019", "#f59019"), "a 7-character #rrggbb commits");
// Invalid text is replaced by the colour when focus leaves.
dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]);
type_slowly(&mut app, &ctx, screen, "zz");
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "#").center());
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
visible_label(&labels, screen, "f59019");
assert_eq!(app.ui.dialog.as_ref().unwrap().s("hex"), "#f59019");
}
#[test]
fn typed_channel_values_are_kept_while_typing() {
let (mut app, ctx, screen, id) = picker(json!({"target": "fill", "hex": "#010203", "model": "rgb", "rgb": [1, 2, 3]}));
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
click_dialog(&mut app, &ctx, screen, rightmost(&labels, "1"));
dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]);
type_slowly(&mut app, &ctx, screen, "12.5");
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
assert!(rightmost(&labels, "12.5").x > rightmost(&labels, "2").x - 1.0, "the typed text stays in the R field");
assert_eq!(app.ui.dialog.as_ref().unwrap().s("hex"), "#0d0203");
confirm(&mut app).unwrap();
let designcraft_color::Color::Rgb { r, .. } = fill(&app, id) else { panic!("not RGB") };
assert!((r - 12.5 / 255.0).abs() < 1e-6, "{r}");
}
#[test]
fn a_cmyk_colour_stays_cmyk_unless_rgb_values_are_typed() {
let cmyk = json!({"target": "fill", "hex": "#ff8000", "model": "cmyk", "cmyk": [0, 50, 100, 0]});
// Picked by hex: converted to CMYK.
let (mut app, ctx, screen, id) = picker(cmyk.clone());
let hex = app.ui.dialog.as_ref().unwrap().s("hex");
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, hex.trim_start_matches('#')).center());
dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]);
type_slowly(&mut app, &ctx, screen, "3366cc");
confirm(&mut app).unwrap();
assert!(matches!(fill(&app, id), designcraft_color::Color::Cmyk { .. }), "{:?}", fill(&app, id));
// A typed C/M/Y/K percent is a percent: M 1 is 1 %.
let (mut app, ctx, screen, id) = picker(cmyk.clone());
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
click_dialog(&mut app, &ctx, screen, rightmost(&labels, "50"));
dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]);
type_slowly(&mut app, &ctx, screen, "1");
confirm(&mut app).unwrap();
let designcraft_color::Color::Cmyk { c, m, y, k } = fill(&app, id) else { panic!("not CMYK") };
assert!(c == 0.0 && (m - 0.01).abs() < 1e-6 && (y - 1.0).abs() < 1e-6 && k == 0.0, "{c} {m} {y} {k}");
// Typed R/G/B values: RGB.
let (mut app, ctx, screen, id) = picker(cmyk);
let r = app.ui.dialog.as_ref().unwrap().fields["rgb"][0].as_f64().unwrap();
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
click_dialog(&mut app, &ctx, screen, rightmost(&labels, &r.to_string()));
dialog_frame(&mut app, &ctx, screen, vec![select_all_key()]);
type_slowly(&mut app, &ctx, screen, "10");
confirm(&mut app).unwrap();
assert!(matches!(fill(&app, id), designcraft_color::Color::Rgb { .. }), "{:?}", fill(&app, id));
}
#[test]
fn ok_without_edits_leaves_the_fill_alone() {
let (mut app, _, _, id) = picker(json!({"target": "fill", "hex": "#ff8000", "model": "cmyk", "cmyk": [0, 50, 100, 0]}));
let before = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.clone();
confirm(&mut app).unwrap();
assert_eq!(app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill, before);
}
#[test]
fn dialogs_do_not_dim_the_page() {
let (mut app, ctx, screen, _) = picker(json!({"target": "fill", "hex": "#ff8000"}));
let time = ctx.input(|i| i.time) + 1.0 / 60.0;
let output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), ..Default::default() }, |ui| show(&mut app, ui.ctx()));
fn covers(shape: &egui::Shape, screen: egui::Rect) -> bool {
match shape {
egui::Shape::Rect(r) => r.rect.contains_rect(screen) && r.fill.a() > 0,
egui::Shape::Vec(v) => v.iter().any(|s| covers(s, screen)),
_ => false,
}
}
assert!(app.ui.dialog.is_some());
assert!(!output.shapes.iter().any(|s| covers(&s.shape, screen)), "nothing paints over the whole window");
}
fn draw(app: &mut DesignApp, ctx: &egui::Context) {
let input =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
+86 -17
View File
@@ -259,11 +259,9 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
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, "text": text})));
let mut fields = picker_fields(doc.resolve_color(n, 1.0), k);
fields["text"] = json!(text);
app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", fields));
}
}
ui.label(egui::RichText::new(&name).size(11.0).color(t.text_dim));
@@ -313,7 +311,7 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
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}));
send = Some(json!({"model": "cmyk", "c": v[0], "m": v[1], "y": v[2], "k": v[3]}));
}
}
ColorMode::Rgb => {
@@ -327,7 +325,7 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
if done {
send = Some(json!([(v[0] * 255.0).round(), (v[1] * 255.0).round(), (v[2] * 255.0).round()]));
send = Some(json!({"model": "rgb", "r": v[0], "g": v[1], "b": v[2]}));
}
}
ColorMode::Lab => {
@@ -347,8 +345,27 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
/// The Color Picker's fields for `color` (`None`: black): its values in its own model, so OK
/// keeps a CMYK colour CMYK.
fn picker_fields(color: Option<designcraft_color::Color>, stroke: bool) -> serde_json::Value {
let color = color.unwrap_or(designcraft_color::Color::BLACK);
let mut f = json!({"target": if stroke { "stroke" } else { "fill" }, "hex": color.to_hex()});
match color {
designcraft_color::Color::Cmyk { c, m, y, k } => {
f["model"] = json!("cmyk");
f["cmyk"] = json!([c, m, y, k].map(|v| f64::from(v) * 100.0));
}
designcraft_color::Color::Rgb { r, g, b } => {
f["model"] = json!("rgb");
f["rgb"] = json!([r, g, b].map(|v| f64::from(v) * 255.0));
}
designcraft_color::Color::Gray { .. } => f["model"] = json!("rgb"),
}
f
}
/// One slider with a gradient track of the channel and a value field. True when it was changed
/// (on release / edit).
/// (on release, or when a typed value is committed by Enter or leaving the field).
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| {
@@ -373,12 +390,20 @@ fn channel(ui: &mut egui::Ui, label: &str, v: &mut f32, scale: f32, suffix: &str
*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;
// The value is applied when typing ends or a drag stops; until then the field's number
// lives here, because `v` is read back from the document every frame.
let pending_id = ui.id().with(("channel", label));
let mut num = ui.data(|d| d.get_temp::<f32>(pending_id)).unwrap_or((*v * scale).round());
let field = ui.add(egui::DragValue::new(&mut num).range(0.0..=scale).suffix(suffix).update_while_editing(false));
if field.dragged() {
ui.data_mut(|d| d.insert_temp(pending_id, num));
} else {
ui.data_mut(|d| d.remove::<f32>(pending_id));
if field.changed() || field.drag_stopped() {
*v = num / scale;
changed = true;
}
}
changed |= field.lost_focus() || field.drag_stopped();
});
changed
}
@@ -413,10 +438,8 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) {
{
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 text = super::colors_affect_text(app);
let _ = app.run(
"object.color",
json!({"color": [(c[0] * 255.0).round(), (c[1] * 255.0).round(), (c[2] * 255.0).round()], "target": target, "text": text}),
);
let q = |v: f32| (v * 255.0).round() / 255.0;
let _ = app.run("object.color", json!({"color": {"model": "rgb", "r": q(c[0]), "g": q(c[1]), "b": q(c[2])}, "target": target, "text": text}));
}
}
@@ -603,3 +626,49 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
}
#[cfg(test)]
mod color_panel_tests {
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::DesignApp;
fn fill(app: &DesignApp, id: u64) -> designcraft_color::Color {
let d = &app.session.active().unwrap().doc;
d.resolve_color(&d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, 1.0).unwrap()
}
/// A number typed into the M field stays while typing and applies as a percent on Enter:
/// M 1 is 1 %, not 100 %.
#[test]
fn a_typed_channel_value_is_kept_and_applied_as_a_percent() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let id = app.run("frame.create", json!({"rect": [36, 36, 200, 200]})).unwrap()["id"].as_u64().unwrap();
app.run("selection.set", json!({"ids": [id]})).unwrap();
app.run("object.color", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})).unwrap();
let mut h = Harness::builder().with_size(vec2(320.0, 300.0)).build_ui_state(|ui, app: &mut DesignApp| super::color_panel(app, ui), app);
h.run_steps(3);
let fields: Vec<egui::Rect> = h.query_all_by(|n| n.role() == egui::accesskit::Role::SpinButton).map(|n| n.rect()).collect();
assert_eq!(fields.len(), 4, "C, M, Y and K fields");
let m = fields[1].center();
h.hover_at(m);
h.drag_at(m);
h.drop_at(m);
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text("1".into()));
h.run_steps(4);
let typed = h.query_by(|n| n.role() == egui::accesskit::Role::SpinButton && n.is_focused()).and_then(|n| n.value());
assert_eq!(typed.as_deref(), Some("1"), "the typed text stays while the field has focus");
let designcraft_color::Color::Cmyk { m, .. } = fill(h.state(), id) else { panic!("not CMYK") };
assert!((m - 0.5).abs() < 1e-6, "nothing is applied while typing: {m}");
h.key_press(egui::Key::Enter);
h.run_steps(2);
let designcraft_color::Color::Cmyk { c, m, y, k } = fill(h.state(), id) else { panic!("not CMYK") };
assert!(c == 0.0 && (m - 0.01).abs() < 1e-6 && (y - 1.0).abs() < 1e-6 && k == 0.0, "{c} {m} {y} {k}");
}
}
+1 -1
View File
@@ -295,7 +295,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
- Graphics adapter chosen by the app: the GPU that drives the primary display (Windows `EnumDisplayDevices`, Linux sysfs), then the power preference (`WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` override). An adapter that fails or hangs while the window starts is left out and the app starts again on the next one, then on the next backend, with software renderers last; wgpu's indirect-call check is off (it lost the Metal device on an Intel Mac, #334); why the app stopped is logged, also when the window never opened (#273). Ported from VectorCraft.
- 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`.
- 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; R/G/B, C/M/Y/K and hex fields; a CMYK colour stays CMYK unless R/G/B values are typed); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Threading: click a selected text frame's in or out port (overset or not) to load the text cursor, then click a frame to thread it (stories merge), click empty space for a column-wide frame down to the bottom margin, or drag a frame; Shift-click autoflows, Alt-click stays loaded; click the linked frame or double-click a port to break the thread; Esc unloads. Link and unthread cursors show where a click threads or unthreads. `text.thread`, `text.unthread`, `text.removeFromThread`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow threads one frame per page column and adds pages until the text fits; Remove Styles option (`designcraft-textimport`).
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.