From f21161ce8c1d556b9d58cef01e95f3869c9f964c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:49:26 +0700 Subject: [PATCH] =?UTF-8?q?M3.27:=20Color=20Picker=20dialog=20=E2=80=94=20?= =?UTF-8?q?double-clicking=20a=20Fill/Stroke=20proxy=20(toolbar,=20Color,?= =?UTF-8?q?=20Swatches,=20Properties)=20or=20ui.colorPicker=20{stroke=3F,?= =?UTF-8?q?=20color=3F}=20opens=20a=20colour=20field=20with=20an=20H/S/B/R?= =?UTF-8?q?/G/B=20channel=20slider,=20HSB/RGB/Lab/CMYK=20fields=20and=20he?= =?UTF-8?q?x,=20Only=20Web=20Colors,=20out-of-gamut=20(corrected=20through?= =?UTF-8?q?=20color.convert)=20and=20out-of-web=20warnings,=20New/Original?= =?UTF-8?q?=20chips=20and=20a=20Color=20Swatches=20list;=20OK=20runs=20pai?= =?UTF-8?q?nt.setFill=20/=20paint.setStroke=20(CMYK=20in=20CMYK=20document?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/color_picker.rs | 514 +++++++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 3 + crates/ui-egui/src/menus.rs | 7 + crates/ui-egui/src/panels/color.rs | 79 ++-- crates/ui-egui/src/panels/mod.rs | 24 +- crates/ui-egui/src/panels/properties.rs | 4 +- crates/ui-egui/src/widgets.rs | 193 +++++++- docs/control-protocol.md | 2 + 8 files changed, 762 insertions(+), 64 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/color_picker.rs diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs new file mode 100644 index 00000000..eef6ec4d --- /dev/null +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -0,0 +1,514 @@ +//! The Color Picker (double-click a Fill/Stroke proxy, or `ui.colorPicker`): a colour field with a +//! channel slider for H, S, B, R, G or B; HSB, RGB, Lab and CMYK fields and hex; Only Web Colors; +//! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; and a Color +//! Swatches list. OK applies the colour to its proxy through `paint.setFill` / `paint.setStroke`. +//! +//! Fields agents set with `ui.dialog.set`: `hex` (`"00FF00"`) or `color` (any colour the paint +//! commands take), `channel` (`hue`, `saturation`, `brightness`, `red`, `green`, `blue`), `webOnly` +//! and `swatches`. `stroke` and `original` come from the opener. + +use egui::{Rect, Sense, Ui, vec2}; +use serde_json::{Value, json}; +use vectorcraft_color::cms::{self, Lab}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::ColorMode; + +use super::DialogSpec; +use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, hex_digits, in_gamut, is_web_safe, parse_hex, warning_chip, web_safe}; +use crate::panels::{c32, color_from_json, color_json}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +pub(super) const KIND: &str = "colorPicker"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Color Picker".into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM }; + +/// Side of the colour field (and height of the channel slider). +const FIELD: f32 = 240.0; +/// Width of the value fields. +const VALUE_W: f32 = 54.0; + +/// The component the channel slider edits; the colour field shows the other two. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Channel { + #[default] + Hue, + Saturation, + Brightness, + Red, + Green, + Blue, +} + +impl Channel { + pub const ALL: [Channel; 6] = [Channel::Hue, Channel::Saturation, Channel::Brightness, Channel::Red, Channel::Green, Channel::Blue]; + + pub fn id(self) -> &'static str { + match self { + Channel::Hue => "hue", + Channel::Saturation => "saturation", + Channel::Brightness => "brightness", + Channel::Red => "red", + Channel::Green => "green", + Channel::Blue => "blue", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|c| c.id() == s) + } + fn label(self) -> &'static str { + match self { + Channel::Hue => "H", + Channel::Saturation => "S", + Channel::Brightness | Channel::Blue => "B", + Channel::Red => "R", + Channel::Green => "G", + } + } + fn is_hsb(self) -> bool { + matches!(self, Channel::Hue | Channel::Saturation | Channel::Brightness) + } + /// Indices of (slider, field x, field y) into the channel's space: [h, s, b] or [r, g, b]. + fn axes(self) -> (usize, usize, usize) { + match self { + Channel::Hue => (0, 1, 2), + Channel::Saturation => (1, 0, 2), + Channel::Brightness => (2, 0, 1), + Channel::Red => (0, 2, 1), + Channel::Green => (1, 2, 0), + Channel::Blue => (2, 0, 1), + } + } +} + +/// The picked colour with its HSB (hue in degrees), kept so the hue survives grey colours. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Picked { + pub color: Color, + pub hsb: [f32; 3], +} + +impl Picked { + pub fn of(color: Color) -> Self { + Self { color, hsb: color.to_hsb() } + } + fn from_hsb(hsb: [f32; 3]) -> Self { + Self { color: Color::from_hsb(hsb[0], hsb[1], hsb[2]), hsb } + } + /// The nearest web colour when Only Web Colors is on. + pub fn snapped(self, web: bool) -> Self { + if web && !is_web_safe(&self.color) { Self::of(web_safe(&self.color)) } else { self } + } +} + +/// The slider value and the field point (x right, y down; 0..1 each) of a colour. +pub fn field_pos(ch: Channel, p: &Picked) -> (f32, f32, f32) { + let v = if ch.is_hsb() { [p.hsb[0] / 360.0, p.hsb[1], p.hsb[2]] } else { p.color.to_rgb() }; + let (z, x, y) = ch.axes(); + (v[z], v[x], 1.0 - v[y]) +} + +/// The colour at slider value `z` and field point (x, y): the inverse of [`field_pos`]. +pub fn field_pick(ch: Channel, z: f32, x: f32, y: f32) -> Picked { + let (zi, xi, yi) = ch.axes(); + let mut v = [0.0; 3]; + v[zi] = z; + v[xi] = x; + v[yi] = 1.0 - y; + let v = v.map(|c| c.clamp(0.0, 1.0)); + if ch.is_hsb() { Picked::from_hsb([v[0] * 360.0, v[1], v[2]]) } else { Picked::of(Color::rgb(v[0], v[1], v[2])) } +} + +/// The channel slider's track at `z`: the full spectrum for hue, else the colour with only the +/// slider's component changing. +pub fn slider_color(ch: Channel, z: f32, x: f32, y: f32) -> Color { + if ch == Channel::Hue { Color::from_hsb(z * 360.0, 1.0, 1.0) } else { field_pick(ch, z, x, y).color } +} + +/// The colour the dialog holds: an edited `hex` wins over `color` (agents set either one). +fn picked(d: &Dialog) -> Picked { + let hex = d.str("hex"); + let color = match parse_hex(&hex) { + Some(c) if hex != d.str("__hex") => c, + _ => d.fields.get("color").and_then(color_from_json).unwrap_or(Color::WHITE), + }; + let hsb = d.fields.get("__hsb").and_then(|v| serde_json::from_value::<[f32; 3]>(v.clone()).ok()); + let p = match hsb { + Some(h) if Picked::from_hsb(h).color.to_hex() == color.to_hex() => Picked { color, hsb: h }, + _ => Picked::of(color), + }; + p.snapped(d.bool("webOnly")) +} + +fn store(d: &mut Dialog, p: &Picked) { + let hex = hex_digits(&p.color); + d.fields.insert("color".into(), color_json(&p.color)); + d.fields.insert("hex".into(), json!(hex)); + d.fields.insert("__hex".into(), json!(hex)); + d.fields.insert("__hsb".into(), json!(p.hsb)); +} + +/// The colour the proxy shows (a gradient's first stop; the last colour for None and patterns). +fn proxy_color(app: &VectorcraftApp, stroke: bool) -> Color { + let (f, s) = crate::panels::current_paints(app); + match if stroke { s } else { f } { + Paint::Solid { color, .. } => color, + Paint::Gradient(g) => g.gradient.stops.first().map_or(app.session.last_solid, |s| s.color), + Paint::None | Paint::Pattern { .. } => app.session.last_solid, + } +} + +/// `ui.colorPicker {stroke?, color?}`: open the picker for the fill (or stroke) proxy, starting +/// from `color` or the proxy's colour. +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!app.session.fill_active); + let color = match p.get("color") { + Some(v) => color_from_json(v).ok_or_else(|| format!("bad color {v}"))?, + None => proxy_color(app, stroke), + }; + let mut d = Dialog::new( + KIND, + json!({"stroke": stroke, "original": color_json(&color), "channel": Channel::default().id(), "webOnly": false, "swatches": false}), + ); + store(&mut d, &Picked::of(color)); + app.ui.dialog = Some(d); + Ok(Value::Null) +} + +/// The cached out-of-gamut correction of `c` (`color.convert` runs once per colour). +fn gamut_fix(app: &mut VectorcraftApp, d: &mut Dialog, c: &Color) -> Option { + let key = color_json(c); + if d.fields.get("__gamutOf") != Some(&key) { + let fix = in_gamut(app, c).map_or(Value::Null, |f| color_json(&f)); + d.fields.insert("__gamut".into(), fix); + d.fields.insert("__gamutOf".into(), key); + } + d.fields.get("__gamut").and_then(color_from_json) +} + +fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let channel = Channel::parse(&d.str("channel")).unwrap_or_default(); + let web = d.bool("webOnly"); + let swatches = d.bool("swatches"); + let p = picked(d); + let original = d.fields.get("original").and_then(color_from_json).unwrap_or(p.color); + let gamut = gamut_fix(app, d, &p.color); + let mut pick: Option = None; + let mut new_channel = None; + let mut toggle_swatches = false; + ui.horizontal_top(|ui| { + if swatches { + pick = swatch_list(app, ui, &p.color).map(Picked::of); + } else { + let (z, x, y) = field_pos(channel, &p); + let at = |x: f32, y: f32| c32(&field_pick(channel, z, x, y).snapped(web).color); + if let (Some((x, y)), _) = widgets::color_field(ui, "cp-field", vec2(FIELD, FIELD), (x, y), &at) { + pick = Some(field_pick(channel, z, x, y)); + } + ui.add_space(6.0); + let track = |z: f32| c32(&Picked::of(slider_color(channel, z, x, y)).snapped(web).color); + if let (Some(z), _) = widgets::channel_slider(ui, "cp-slider", z, vec2(22.0, FIELD), &track) { + pick = Some(field_pick(channel, z, x, y)); + } + } + ui.add_space(14.0); + ui.vertical(|ui| { + ui.horizontal_top(|ui| { + if new_original_chips(ui, &p.color, &original) { + pick = Some(Picked::of(original)); + } + ui.add_space(8.0); + ui.vertical(|ui| { + if let Some(fix) = gamut + && warning_chip(ui, GAMUT_WARNING, &fix) + { + pick = Some(Picked::of(fix)); + } + let ws = web_safe(&p.color); + if !web && !is_web_safe(&p.color) && warning_chip(ui, WEB_WARNING, &ws) { + pick = Some(Picked::of(ws)); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + toggle_swatches = widgets::flat_button(ui, if swatches { "Color Models" } else { "Color Swatches" }, 110.0).clicked(); + }); + }); + ui.add_space(10.0); + let (edit, ch) = value_fields(ui, &p, channel); + pick = pick.or(edit); + new_channel = ch; + }); + }); + ui.add_space(8.0); + if widgets::check(ui, "Only Web Colors", web, true) { + d.fields.insert("webOnly".into(), json!(!web)); + } + if let Some(ch) = new_channel { + d.fields.insert("channel".into(), json!(ch.id())); + } + if toggle_swatches { + d.fields.insert("swatches".into(), json!(!swatches)); + } + store(d, &pick.unwrap_or(p).snapped(d.bool("webOnly"))); + false +} + +/// The New (top) and Original (bottom) chips. Returns true when Original is clicked. +fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(64.0, 60.0), Sense::hover()); + let top = Rect::from_min_max(r.min, egui::pos2(r.right(), r.center().y)); + let bottom = Rect::from_min_max(egui::pos2(r.left(), r.center().y), r.max); + widgets::paint_chip(ui, top, &Paint::solid(*new)); + widgets::paint_chip(ui, bottom, &Paint::solid(*original)); + ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + ui.interact(top, ui.id().with("cp-new"), Sense::hover()).on_hover_text("New"); + ui.interact(bottom, ui.id().with("cp-original"), Sense::click()).on_hover_text("Original: click to restore").clicked() +} + +/// The HSB and RGB fields with the channel radios, hex, Lab and CMYK. Returns the edited colour +/// and the newly chosen channel. +fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option, Option) { + let t = Tokens::get(ui.ctx()); + let rgb = p.color.to_rgb(); + let lab = p.color.to_lab(); + let cms = cms::active(); + let cmyk = p.color.to_cmyk_managed(cms.settings().intent); + let mut edit = None; + let mut chosen = None; + egui::Grid::new("cp-values").num_columns(4).spacing([6.0, 4.0]).show(ui, |ui| { + for i in 0..7 { + if let Some(&ch) = Channel::ALL.get(i) { + if widgets::radio(ui, ch.label(), ch == channel) { + chosen = Some(ch); + } + let (v, suffix) = match i { + 0 => (p.hsb[0], "°"), + 1 | 2 => (p.hsb[i] * 100.0, "%"), + _ => (rgb[i - 3] * 255.0, ""), + }; + if let Some(nv) = widgets::plain_field(ui, ("cp-left", i), v as f64, suffix, 0, VALUE_W) { + let nv = nv as f32; + edit = Some(match i { + 0..3 => { + let mut hsb = p.hsb; + hsb[i] = if i == 0 { nv.clamp(0.0, 360.0) } else { (nv / 100.0).clamp(0.0, 1.0) }; + Picked::from_hsb(hsb) + } + _ => { + let mut c = rgb; + c[i - 3] = (nv / 255.0).clamp(0.0, 1.0); + Picked::of(Color::rgb(c[0], c[1], c[2])) + } + }); + } + } else if let Some(c) = widgets::hex_field(ui, "cp-hex", &hex_digits(&p.color)).as_deref().and_then(parse_hex) { + edit = Some(Picked::of(c)); + } + let (label, v, suffix) = match i { + 0 => ("L", lab.l, ""), + 1 => ("a", lab.a, ""), + 2 => ("b", lab.b, ""), + _ => (["C", "M", "Y", "K"][i - 3], cmyk[i - 3] * 100.0, "%"), + }; + ui.horizontal(|ui| { + ui.add_space(12.0); + ui.label(egui::RichText::new(label).size(12.5).color(t.text)); + }); + if let Some(nv) = widgets::plain_field(ui, ("cp-right", i), v as f64, suffix, 0, VALUE_W) { + let nv = nv as f32; + edit = Some(Picked::of(if i < 3 { + let mut l = [lab.l, lab.a, lab.b]; + l[i] = if i == 0 { nv.clamp(0.0, 100.0) } else { nv.clamp(-128.0, 127.0) }; + cms.from_lab(Lab::new(l[0], l[1], l[2])) + } else { + let mut k = cmyk; + k[i - 3] = (nv / 100.0).clamp(0.0, 1.0); + Color::cmyk(k[0], k[1], k[2], k[3]) + })); + } + ui.end_row(); + } + }); + (edit, chosen) +} + +/// The document's colour swatches as a list; returns the clicked swatch's colour. +fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color) -> Option { + let t = Tokens::get(ui.ctx()); + let mut chosen = None; + ui.allocate_ui(vec2(FIELD + 28.0, FIELD), |ui| { + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().id_salt("cp-swatches").max_height(FIELD).show(ui, |ui| { + ui.set_width(FIELD + 24.0); + let Some(st) = app.session.active() else { + widgets::dim_label(ui, "Open a document to see its swatches."); + return; + }; + let doc = &st.doc; + for s in doc.swatches.iter().chain(doc.swatch_groups.iter().flat_map(|g| g.swatches.iter())) { + let Paint::Solid { color, .. } = &s.paint else { continue }; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let chip = Rect::from_min_size(r.left_center() + vec2(4.0, -7.0), vec2(14.0, 14.0)); + widgets::swatch_tile(ui, chip, &s.paint, color == current, false); + ui.painter().text( + egui::pos2(chip.right() + 8.0, r.center().y), + egui::Align2::LEFT_CENTER, + &s.name, + egui::FontId::proportional(12.0), + t.text, + ); + if resp.clicked() { + chosen = Some(*color); + } + } + }); + }); + }); + chosen +} + +/// OK: apply the colour to the proxy (as CMYK in a CMYK document). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut c = picked(d).color; + if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) { + let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent); + c = Color::cmyk(cy, m, y, k); + } + let cmd = if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" }; + super::run_and_close(app, cmd, json!({ "color": color_json(&c) })) +} + +#[cfg(test)] +mod tests { + use vectorcraft_engine::Session; + + use super::*; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app + } + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn fill_hex(app: &VectorcraftApp) -> String { + crate::panels::current_paints(app).0.color().unwrap().to_hex() + } + + #[test] + fn field_and_slider_map_back_per_channel() { + let p = Picked::of(Color::rgb8(51, 153, 204)); + for ch in Channel::ALL { + let (z, x, y) = field_pos(ch, &p); + assert_eq!(field_pick(ch, z, x, y).color.to_hex(), "#3399cc", "{ch:?}"); + assert_eq!(Channel::parse(ch.id()), Some(ch)); + } + // Hue: x is saturation, y brightness (white at the top left, black along the bottom). + assert_eq!(field_pick(Channel::Hue, 0.0, 0.0, 0.0).color.to_hex(), "#ffffff"); + assert_eq!(field_pick(Channel::Hue, 0.0, 1.0, 0.0).color.to_hex(), "#ff0000"); + assert_eq!(field_pick(Channel::Hue, 0.5, 1.0, 1.0).color.to_hex(), "#000000"); + // Red: the slider is red, x blue and y green (green grows upwards). + assert_eq!(field_pick(Channel::Red, 1.0, 1.0, 0.0).color.to_hex(), "#ffffff"); + assert_eq!(field_pick(Channel::Red, 0.0, 1.0, 1.0).color.to_hex(), "#0000ff"); + assert_eq!(field_pick(Channel::Green, 1.0, 0.0, 1.0).color.to_hex(), "#00ff00"); + assert_eq!(field_pick(Channel::Blue, 0.0, 1.0, 1.0).color.to_hex(), "#ff0000"); + // The hue slider shows the spectrum whatever the colour; the others vary one component. + assert_eq!(slider_color(Channel::Hue, 1.0 / 3.0, 0.0, 1.0).to_hex(), "#00ff00"); + assert_eq!(slider_color(Channel::Saturation, 0.0, 0.0, 0.0).to_hex(), "#ffffff"); + // A grey keeps the hue it was picked with. + let grey = field_pick(Channel::Hue, 0.5, 0.0, 0.5); + assert_eq!(grey.hsb[0], 180.0); + assert_eq!(field_pos(Channel::Hue, &grey).0, 0.5); + } + + #[test] + fn only_web_colors_snaps() { + let p = Picked::of(Color::rgb8(230, 120, 40)); + assert_eq!(p.snapped(false), p); + assert_eq!(p.snapped(true).color.to_hex(), "#ff6633"); + assert!(is_web_safe(&p.snapped(true).color)); + let mut d = Dialog::new(KIND, json!({"color": "#e67828", "webOnly": true})); + assert_eq!(picked(&d).color.to_hex(), "#ff6633"); + d.fields.insert("webOnly".into(), json!(false)); + assert_eq!(picked(&d).color.to_hex(), "#e67828"); + } + + #[test] + fn gamut_correction_gives_a_printable_colour() { + let mut app = app(); + let blue = Color::rgb(0.0, 0.0, 1.0); + assert!(blue.out_of_gamut()); + let fix = in_gamut(&mut app, &blue).expect("pure blue can't be printed"); + assert!(!fix.out_of_gamut(), "{fix:?}"); + assert!(matches!(fix, Color::Rgb { .. })); + assert_eq!(in_gamut(&mut app, &Color::rgb8(128, 128, 128)), None); + // The dialog offers it and clicking it would pick it. + app.run("ui.colorPicker", json!({"color": "#0000ff"})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + assert_eq!(gamut_fix(&mut app, &mut d, &blue), Some(fix)); + } + + #[test] + fn open_set_hex_and_confirm_fills() { + let mut app = app(); + app.run("ui.colorPicker", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(super::super::DialogKind::of(&d.kind), Some(super::super::DialogKind::ColorPicker)); + assert_eq!(d.str("hex"), "FFFFFF", "starts from the fill"); + assert!(!d.bool("stroke")); + frame(&mut app); + // `ui.dialog.set {field: "hex"}` then `ui.dialog.confirm`. + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00")); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(fill_hex(&app), "#00ff00"); + // Without a frame in between, a set hex still wins; `color` works as well. + app.run("ui.colorPicker", json!({"stroke": true})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("#3366CC")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(crate::panels::current_paints(&app).1.color().unwrap().to_hex(), "#3366cc"); + // The stroke proxy is in front now: the opener follows it unless told otherwise. + app.run("ui.colorPicker", json!({})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool("stroke")); + app.run("ui.colorPicker", json!({"stroke": false})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!({"c": 0, "m": 1, "y": 1, "k": 0})); + super::super::confirm(&mut app).unwrap(); + assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK is kept"); + assert!(app.run("ui.colorPicker", json!({"color": "nope"})).is_err()); + } + + #[test] + fn every_mode_draws_and_cmyk_documents_get_cmyk() { + let mut app = app(); + for fields in + [json!({"channel": "red"}), json!({"channel": "brightness", "webOnly": true}), json!({"swatches": true}), json!({"color": "#0000ff"})] + { + app.run("ui.colorPicker", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in fields.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } + frame(&mut app); + assert!(app.ui.dialog.is_some()); + } + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + app.run("ui.colorPicker", json!({"color": "#ff0000"})).unwrap(); + super::super::confirm(&mut app).unwrap(); + assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. }))); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 9532e989..f79ce01d 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -9,6 +9,7 @@ mod about; mod all_tools; mod artboard_options; +mod color_picker; mod command; mod document_setup; mod effect; @@ -24,6 +25,7 @@ mod transform; use serde_json::{Value, json}; +pub use color_picker::open as open_color_picker; pub use tools::open_tool_dialog; use crate::state::Dialog; @@ -127,6 +129,7 @@ registry! { Shortcuts: ["shortcuts"] => DialogSpec::window(crate::shortcut_editor::show, |app, _| crate::shortcut_editor::confirm(app)), Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)), FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)), + ColorPicker: [color_picker::KIND] => color_picker::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7c61cf88..c9010356 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -160,6 +160,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("help.about", "About VectorCraft", "", "{}"), ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), + ( + "ui.colorPicker", + "Color Picker…", + "", + "{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -482,6 +488,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::unsaved::close_all(app, "closeAll"), "app.quit" => crate::unsaved::close_all(app, "quit"), + "ui.colorPicker" => crate::dialogs::open_color_picker(app, p), _ => return None, }; Some(r) diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 27d71944..7900673c 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -140,6 +140,11 @@ pub fn spectrum_at(mode: Mode, x: f32, y: f32) -> Color { } } +/// The hex field text of a colour (`E67828`). +pub fn hex_digits(c: &Color) -> String { + c.to_hex().trim_start_matches('#').to_uppercase() +} + /// Parse a hex field (`E67828`, `#e67828`, `fff`). pub fn parse_hex(s: &str) -> Option { let s = s.trim().trim_start_matches('#'); @@ -149,6 +154,37 @@ pub fn parse_hex(s: &str) -> Option { Color::from_hex(s) } +/// The out-of-web-colour warning: its icon and tooltip. +pub(crate) const WEB_WARNING: (&str, &str) = ("dc-cube", "Out of Web Color Warning"); +/// The out-of-gamut warning: its icon and tooltip. +pub(crate) const GAMUT_WARNING: (&str, &str) = ("dc-gamut", "Out of Gamut Warning"); + +/// A warning icon with the corrected colour beside it. Returns true when the colour is clicked. +pub(crate) fn warning_chip(ui: &mut Ui, (icon, warning): (&str, &str), fix: &Color) -> bool { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + icons::icon(ui, icon, 16.0, t.icon).on_hover_text(warning); + let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); + widgets::swatch_tile(ui, r, &Paint::solid(*fix), false, resp.hovered()); + resp.on_hover_text(format!("{warning}: click to correct to the closest color ({})", fix.to_hex())).clicked() + }) + .inner +} + +/// The printable colour closest to `c` (its working-CMYK reproduction, back in RGB) when `c` is out +/// of the CMYK gamut, through `color.convert`. +pub(crate) fn in_gamut(app: &mut VectorcraftApp, c: &Color) -> Option { + let r = app.run("color.convert", json!({"color": color_json(c), "to": "cmyk"})).ok()?; + if !r["outOfGamut"].as_bool()? { + return None; + } + let v = |r: &serde_json::Value, i: usize| r["values"][i].as_f64(); + let ink = json!({"c": v(&r, 0)?, "m": v(&r, 1)?, "y": v(&r, 2)?, "k": v(&r, 3)?}); + let rgb = app.run("color.convert", json!({"color": ink, "to": "rgb"})).ok()?; + Some(Color::rgb(v(&rgb, 0)? as f32, v(&rgb, 1)? as f32, v(&rgb, 2)? as f32)) +} + fn to32(c: &Color) -> Color32 { super::c32(c) } @@ -224,16 +260,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { && !is_web_safe(&c) { ui.add_space(8.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 3.0; - icons::icon(ui, "dc-cube", 16.0, t.icon).on_hover_text("Out of Web Color Warning"); - let ws = web_safe(&c); - let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); - widgets::swatch_tile(ui, r, &Paint::solid(ws), false, resp.hovered()); - if resp.on_hover_text(format!("Click to correct to the closest web color ({})", ws.to_hex())).clicked() { - new = Some((ws, Live::Released, components(mode, &ws))); - } - }); + let ws = web_safe(&c); + if warning_chip(ui, WEB_WARNING, &ws) { + new = Some((ws, Live::Released, components(mode, &ws))); + } } }); // Sliders. @@ -292,33 +322,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.spacing_mut().item_spacing.x = 6.0; if matches!(mode, Mode::Rgb | Mode::WebSafe | Mode::Hsb) { ui.add_space((ui.available_width() - 96.0).max(4.0)); - ui.label(egui::RichText::new("#").size(15.0).color(t.text)); - let hex = color.map(|c| c.to_hex().trim_start_matches('#').to_uppercase()).unwrap_or_default(); - let id = ui.id().with("hex"); - let editing = ui.memory(|m| m.has_focus(id)); - let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.clone()) } else { hex.clone() }; - let resp = egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(2) - .inner_margin(egui::Margin::symmetric(6, 4)) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .desired_width(62.0) - .char_limit(7) - .font(egui::FontId::proportional(12.5)) - .text_color(t.text_strong), - ) - }) - .inner; - ui.data_mut(|d| d.insert_temp(id, buf.clone())); - if resp.lost_focus() - && buf != hex - && let Some(c) = parse_hex(&buf) - { + let hex = color.map(|c| hex_digits(&c)).unwrap_or_default(); + if let Some(c) = widgets::hex_field(ui, "hex", &hex).as_deref().and_then(parse_hex) { new = Some((c, Live::Released, components(mode, &c))); } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 4a70a1c8..e86dd4e2 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -188,16 +188,19 @@ pub(crate) fn active_paint(app: &VectorcraftApp) -> Paint { /// Draw the Fill/Stroke proxy and handle its clicks through commands. pub(crate) fn proxy(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { let (f, s) = current_paints(app); - let (a, b, swap, def) = crate::widgets::fill_stroke_proxy(ui, &f, &s, app.session.fill_active, size); - if (a && !app.session.fill_active) || (b && app.session.fill_active) { + let c = crate::widgets::fill_stroke_proxy(ui, &f, &s, app.session.fill_active, size); + if (c.fill && !app.session.fill_active) || (c.stroke && app.session.fill_active) { app.run("paint.toggleActive", json!({})).ok(); } - if swap { + if c.swap { app.run("paint.swap", json!({})).ok(); } - if def { + if c.default { app.run("paint.default", json!({})).ok(); } + if let Some(stroke) = c.pick { + app.run("ui.colorPicker", json!({ "stroke": stroke })).ok(); + } } /// Run `cmd` live: while dragging, preview on top of an interaction snapshot; on release, commit @@ -253,6 +256,19 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// A colour from command JSON: `"#rrggbb"`, `[r,g,b]`, `{c,m,y,k}`, `{gray}` (inverse of +/// [`color_json`]) or a serialized colour. +pub(crate) fn color_from_json(v: &Value) -> Option { + let f = |v: Option<&Value>| v.and_then(Value::as_f64).map(|x| x as f32); + match v { + Value::String(s) => color::parse_hex(s), + Value::Array(a) if a.len() == 3 => Some(Color::rgb(f(a.first())?, f(a.get(1))?, f(a.get(2))?)), + Value::Object(o) if o.contains_key("gray") => Some(Color::gray(f(o.get("gray"))?)), + Value::Object(o) if !o.contains_key("model") => Some(Color::cmyk(f(o.get("c"))?, f(o.get("m"))?, f(o.get("y"))?, f(o.get("k"))?)), + _ => serde_json::from_value(v.clone()).ok(), + } +} + /// A colour as command JSON, keeping its model. pub(crate) fn color_json(c: &Color) -> Value { match *c { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index abf79623..05139999 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -279,7 +279,9 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().rect_filled(r.shrink(7.0), 0.0, t.panel); } ui.painter().rect_stroke(r.shrink(2.0), 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside); - if resp.clicked() { + if resp.double_clicked() { + app.run("ui.colorPicker", json!({ "stroke": !is_fill })).ok(); + } else if resp.clicked() { app.session.fill_active = is_fill; app.ui.open_panel = Some("swatches".into()); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 514f9eb5..0f9288db 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -222,8 +222,19 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient } } -/// The Fill/Stroke proxy pair (Illustrator's overlapping squares). Returns (fill clicked, stroke clicked, swap, default). -pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: bool, size: f32) -> (bool, bool, bool, bool) { +/// What the user did on a [`fill_stroke_proxy`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ProxyClicks { + pub fill: bool, + pub stroke: bool, + pub swap: bool, + pub default: bool, + /// Double-clicked a square: open the Color Picker for it (`true` = the stroke). + pub pick: Option, +} + +/// The Fill/Stroke proxy pair (two overlapping squares). +pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: bool, size: f32) -> ProxyClicks { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); let s = size * 0.62; @@ -264,12 +275,20 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: ui.painter().rect_stroke(b, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside); ui.painter().rect_filled(a, 0.0, Color32::WHITE); ui.painter().rect_stroke(a, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); - ( - fill_resp.on_hover_text("Fill (X)").clicked(), - stroke_resp.on_hover_text("Stroke (X)").clicked(), - swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(), - def.on_hover_text("Default Fill and Stroke (D)").clicked(), - ) + let pick = if fill_resp.double_clicked() { + Some(false) + } else if stroke_resp.double_clicked() { + Some(true) + } else { + None + }; + ProxyClicks { + fill: fill_resp.on_hover_text("Fill (X), double-click for the Color Picker").clicked(), + stroke: stroke_resp.on_hover_text("Stroke (X), double-click for the Color Picker").clicked(), + swap: swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(), + default: def.on_hover_text("Default Fill and Stroke (D)").clicked(), + pick, + } } /// A compact dropdown. Returns the chosen index. @@ -519,12 +538,7 @@ pub fn color_slider( let (rect, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); let bar = Rect::from_min_size(pos2(rect.left() + 4.0, rect.top() + 4.0), vec2(width - 8.0, 7.0)); let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag()); - let n = (bar.width() / 2.0).ceil().max(2.0) as usize; - let seg = bar.width() / n as f32; - for i in 0..n { - let r = Rect::from_min_size(pos2(bar.left() + i as f32 * seg, bar.top()), vec2(seg + 0.6, bar.height())); - ui.painter().rect_filled(r, 0.0, track((i as f32 + 0.5) / n as f32)); - } + gradient_strip(ui, bar, (bar.width() / 2.0).ceil().max(2.0) as usize, false, track); ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); let v = value.clamp(0.0, 1.0); let x = bar.left() + v * bar.width(); @@ -532,15 +546,150 @@ pub fn color_slider( let thumb = vec![tip, pos2(x + 5.5, tip.y + 6.0), pos2(x + 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 6.0)]; let fill = if resp.dragged() || resp.hovered() { Color32::WHITE } else { t.icon }; ui.painter().add(egui::Shape::convex_polygon(thumb, fill, Stroke::new(1.0, t.border))); - let mut out = None; - let mut phase = Live::Idle; - if (resp.dragged() || resp.clicked() || resp.drag_stopped()) - && let Some(p) = resp.interact_pointer_pos() - { - out = Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)); - phase = if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released }; + match pointer_phase(&resp) { + Some((p, phase)) => (Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)), phase), + None => (None, Live::Idle), } - (out, phase) +} + +/// The pointer of a click or drag on `resp` and its phase (dragging, or released on click/drop). +fn pointer_phase(resp: &Response) -> Option<(Pos2, Live)> { + if !(resp.dragged() || resp.clicked() || resp.drag_stopped()) { + return None; + } + let p = resp.interact_pointer_pos()?; + Some((p, if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released })) +} + +/// A gradient strip into `rect`: `n` bands along x (or up y when `vertical`), each filled with +/// `track(t)` at its centre. +fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn(f32) -> Color32) { + let len = if vertical { rect.height() } else { rect.width() }; + let seg = len / n as f32; + for i in 0..n { + let r = if vertical { + Rect::from_min_size(pos2(rect.left(), rect.bottom() - (i + 1) as f32 * seg), vec2(rect.width(), seg + 0.6)) + } else { + Rect::from_min_size(pos2(rect.left() + i as f32 * seg, rect.top()), vec2(seg + 0.6, rect.height())) + }; + ui.painter().rect_filled(r, 0.0, track((i as f32 + 0.5) / n as f32)); + } +} + +/// The Color Picker's 2D colour field: `color_at(x, y)` gives the colour at a point (0..1 each, y +/// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase. +pub fn color_field( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + size: Vec2, + pos: (f32, f32), + color_at: &dyn Fn(f32, f32) -> Color32, +) -> (Option<(f32, f32)>, Live) { + const CELLS: u32 = 32; + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag()); + let mut mesh = egui::Mesh::default(); + let row = CELLS + 1; + mesh.reserve_vertices((row * row) as usize); + mesh.reserve_triangles((CELLS * CELLS * 2) as usize); + for j in 0..row { + for i in 0..row { + let (x, y) = (i as f32 / CELLS as f32, j as f32 / CELLS as f32); + mesh.colored_vertex(pos2(rect.left() + x * rect.width(), rect.top() + y * rect.height()), color_at(x, y)); + } + } + for j in 0..CELLS { + for i in 0..CELLS { + let a = j * row + i; + mesh.add_triangle(a, a + 1, a + row); + mesh.add_triangle(a + 1, a + row + 1, a + row); + } + } + let painter = ui.painter_at(rect.expand(6.0)); + painter.add(egui::Shape::mesh(mesh)); + painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height()); + painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); + painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); + match pointer_phase(&resp) { + Some((p, phase)) => { + let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); + (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase) + } + None => (None, Live::Idle), + } +} + +/// The Color Picker's vertical channel slider: `track(t)` gives the colour at 0..1 (bottom to top) +/// and arrows on both sides mark `value`. Returns the new value and the phase. +pub fn channel_slider( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + value: f32, + size: Vec2, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag()); + let bar = rect.shrink2(vec2(6.0, 0.0)); + gradient_strip(ui, bar, (bar.height() / 2.0).ceil().max(2.0) as usize, true, track); + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + let y = bar.bottom() - value.clamp(0.0, 1.0) * bar.height(); + let fill = if resp.dragged() || resp.hovered() { t.text_strong } else { t.icon }; + for (x, d) in [(rect.left(), 1.0), (rect.right(), -1.0)] { + let tip = pos2(x + d * 5.0, y); + ui.painter().add(egui::Shape::convex_polygon(vec![tip, pos2(x, y + 4.0 * d), pos2(x, y - 4.0 * d)], fill, Stroke::NONE)); + } + match pointer_phase(&resp) { + Some((p, phase)) => (Some(((bar.bottom() - p.y) / bar.height()).clamp(0.0, 1.0)), phase), + None => (None, Live::Idle), + } +} + +/// A radio button (a ring, dotted when selected) with a label. Returns true when clicked. +pub fn radio(ui: &mut Ui, label: &str, selected: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(12.5), t.text); + let (rect, resp) = ui.allocate_exact_size(vec2(18.0 + galley.size().x, 20.0f32.max(galley.size().y)), Sense::click()); + let c = pos2(rect.left() + 6.5, rect.center().y); + ui.painter().circle(c, 6.0, t.input, Stroke::new(1.0, if resp.hovered() { t.text } else { t.button_border })); + if selected { + ui.painter().circle_filled(c, 3.5, t.accent_strong); + } + ui.painter().galley(pos2(rect.left() + 18.0, rect.center().y - galley.size().y / 2.0), galley, t.text); + resp.clicked() +} + +/// A hex colour field (`E67828`, no `#`) after a `#` label. Returns the text on commit when it +/// changed. +pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, hex: &str) -> Option { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new("#").size(15.0).color(t.text)); + let id = ui.id().with(id); + let editing = ui.memory(|m| m.has_focus(id)); + let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; + let resp = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(2) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(id) + .frame(egui::Frame::NONE) + .desired_width(62.0) + .char_limit(7) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) + .inner; + let commit = resp.lost_focus() && buf != hex; + ui.data_mut(|d| d.insert_temp(id, buf.clone())); + commit.then_some(buf) } /// One swatch tile (Illustrator: 1 px dark frame, white inset on hover/selection). diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9f7d6c78..eb69b6c8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -19,3 +19,5 @@ | `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) | | `ui.resize` / `ui.focus` | | | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | + +The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`.