M3.27: Color Picker dialog — double-clicking a Fill/Stroke proxy (toolbar, Color, Swatches, Properties) or ui.colorPicker {stroke?, color?} opens a colour field with an H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields and hex, Only Web Colors, out-of-gamut (corrected through color.convert) and out-of-web warnings, New/Original chips and a Color Swatches list; OK runs paint.setFill / paint.setStroke (CMYK in CMYK documents)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8a871d98a9
commit
f21161ce8c
|
|
@ -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> {
|
||||
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<Value, String> {
|
||||
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<Color> {
|
||||
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<Picked> = 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<Picked>, Option<Channel>) {
|
||||
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<Color> {
|
||||
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<Value, String> {
|
||||
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 { .. })));
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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<R
|
|||
},
|
||||
"file.closeAll" => 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)
|
||||
|
|
|
|||
|
|
@ -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<Color> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
|
|
@ -149,6 +154,37 @@ pub fn parse_hex(s: &str) -> Option<Color> {
|
|||
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<Color> {
|
||||
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::<String>(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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Color> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<bool>,
|
||||
}
|
||||
|
||||
/// 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<f32>, 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<String> {
|
||||
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::<String>(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).
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue