From d050223b7820c14654aa2b8665c5a74c0b4bdf0f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 21:53:06 +0000 Subject: [PATCH 1/2] Colour entry: panel number fields keep typed text; colours are read on a fixed scale The Color panel's C/M/Y/K and R/G/B fields keep the typed number until Enter or leaving the field, and send {model, ...} colours in 0..1, which object.color reads without guessing the scale (M 1 is 1 %, R 1 is not pure red). Colours that round to the same swatch name reuse that swatch instead of adding hidden twins. The hex parser rejects signs and other non-hex characters (#323). --- crates/color/src/lib.rs | 13 ++++- crates/engine/src/cmd/style.rs | 83 +++++++++++++++++++++++++-- crates/ui-egui/src/panels/swatches.rs | 71 ++++++++++++++++++++--- 3 files changed, 153 insertions(+), 14 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index bd5e915de..ed150941a 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -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 { 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] diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4bf69ec41..d5fe5b25e 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -135,7 +135,7 @@ pub fn specs() -> Vec { "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 ), @@ -180,7 +180,7 @@ pub fn specs() -> Vec { "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", + "{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, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection", super::has_selection, apply_color ), @@ -720,6 +720,8 @@ pub fn parse_color(v: &Value) -> Option { 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 => { @@ -737,6 +739,20 @@ pub fn parse_color(v: &Value) -> Option { } } +/// `{model: rgb|cmyk|gray, …}` with every component in 0..=1; anything else is not a colour. +fn model_color(v: &Value) -> Option { + 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 { 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}`")))?; @@ -957,16 +973,28 @@ fn apply_color(s: &mut Session, p: &Value) -> Result { 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. + // 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 }, @@ -1215,6 +1243,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(); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 1aa11ddb3..eb8ac649a 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -287,7 +287,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 => { @@ -301,7 +301,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,12 +347,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::(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::(pending_id)); + if field.changed() || field.drag_stopped() { + *v = num / scale; + changed = true; + } } - changed |= field.lost_focus() || field.drag_stopped(); }); changed } @@ -386,7 +394,8 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { && 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})); + 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})); } } @@ -573,3 +582,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 = 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}"); + } +} From 7f494e4f3f159429b7933c91e324901e44e411dd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 21:53:06 +0000 Subject: [PATCH 2/2] Color Picker: hex and number fields keep typed text; CMYK colours stay CMYK; the page is not dimmed The hex field keeps its text while focused, commits each time it parses (#rgb, rrggbb, any case) and shows the colour again when focus leaves. R/G/B and editable C/M/Y/K fields hold their values as typed. Picking on the field or by hex keeps a CMYK colour CMYK through the CMYK working space; typed R/G/B values make it RGB. OK without edits leaves the fill or stroke alone. Dialogs draw no backdrop, so the page keeps its colours behind them (#323). --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 276 +++++++++++++++++++++++--- crates/ui-egui/src/panels/swatches.rs | 26 ++- docs/target-app-parity.md | 2 +- 4 files changed, 272 insertions(+), 33 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 91a951616..9d5a14934 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,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** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e8..ced768aea 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1155,7 +1155,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() { @@ -1899,9 +1900,17 @@ pub fn confirm(app: &mut DesignApp) -> Result { } 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")})) + 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")})) } "layerOptions" => { let hidden: Vec = 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(); @@ -3645,45 +3654,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(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. @@ -4000,6 +4080,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() }; diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index eb8ac649a..79e49d925 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -231,10 +231,7 @@ 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}))); + app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", picker_fields(doc.resolve_color(n, 1.0), k))); } } ui.label(egui::RichText::new(&name).size(11.0).color(t.text_dim)); @@ -321,8 +318,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, 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| { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 78e968dcf..7a4394dfc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -294,7 +294,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Window close, application/menu Quit and macOS Dock Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Repeated native requests preserve an active dialog and partial approvals. Control-channel quit remains immediate. - 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. - 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`. - 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 adds pages and threaded frames 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. - Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).