Files
designcraft/crates/ui-egui/src/panels/swatches.rs
T
PiMinusMaxandClaude Opus 5.5 d080a5635a Swatches panel: a gradient dropped on a colour group row is kept too
The drop zone held the ungrouped swatches only, so releasing the
gradient over a colour group's row did nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:13:31 +02:00

1247 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Swatches and Color panels.
use designcraft_color::{ColorType, SwatchValue};
use egui::{Sense, vec2};
use serde_json::json;
use crate::theme::Tokens;
use crate::{DesignApp, icons};
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let target = super::color_target(app);
header(app, ui, &doc, target.as_ref());
// The row of the colour in front of the proxy is highlighted.
let fill_cur = target.map(|(fill, _, stroke, _)| if app.ui.stroke_front { stroke } else { fill });
// Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in
// colour groups are listed under their folder.
let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect();
// A gradient dragged from the Gradient panel's thumbnail is dropped on the list, colour
// groups included.
let (_, dropped) = ui.dnd_drop_zone::<GradientDrag, _>(egui::Frame::NONE, |ui| {
for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) {
swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref());
}
for g in &doc.color_groups {
let open_id = egui::Id::new(("color_group_open", &g.name));
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &g.name));
if resp.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
icons::paint(
ui.painter(),
egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)),
if open { "chevron-down" } else { "chevron-right" },
t.text_dim,
);
// A folder.
let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0));
ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside);
ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim));
ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text);
// A double click's first click folded the group: it stays as it was.
if resp.clicked() {
open = !open;
ui.data_mut(|d| d.insert_temp(open_id, open));
}
if resp.double_clicked() {
let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name}));
}
resp.context_menu(|ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Group Options…"))).clicked() {
let _ = app.run("app.colorGroupOptionsDialog", json!({"name": g.name}));
ui.close();
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ungroup Color Group"))).clicked() {
let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name}));
ui.close();
}
});
if open {
for n in &g.swatches {
if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) {
swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref());
}
}
}
}
});
// An unnamed gradient becomes "New Gradient Swatch"; one that is a swatch already stays one.
if let Some(g) = dropped
&& doc.swatch(&g.0).is_some_and(|w| w.hidden)
{
let _ = app.run("swatch.new", json!({"swatch": g.0}));
}
ui.add_space(6.0);
// InDesign's bottom row: New Color Group, New Swatch, Delete Swatch (the one last clicked).
let picked: Option<String> = ui.data(|d| d.get_temp(egui::Id::new(PICKED))).filter(|n: &String| doc.swatch(n).is_some_and(|w| !w.locked));
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let del = ui
.add_enabled_ui(picked.is_some(), |ui| icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Swatch")))
.inner;
if del.clicked()
&& let Some(name) = &picked
&& app.run("swatch.delete", json!({"name": name})).is_ok()
{
ui.data_mut(|d| d.remove::<String>(egui::Id::new(PICKED)));
}
// The selection's fill or stroke (whichever is in front) becomes a swatch; with nothing
// selected, a new colour.
if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "New Swatch")).clicked() {
let target = if app.ui.stroke_front { "stroke" } else { "fill" };
let _ = app.run("swatch.new", json!({"target": target}));
}
if folder_button(ui, crate::i18n::tr(&app.ui.language, "New Color Group")).clicked() {
let _ = app.run("swatch.newColorGroup", json!({}));
}
crate::menus::menu_button(ui, "☰", |ui| {
let current = fill_cur.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| !w.hidden));
if ui
.add_enabled(current.is_some(), egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Swatch Options…"))))
.clicked()
{
let _ = app.run("app.swatchOptionsDialog", json!({"name": current}));
ui.close();
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Theme…"))).clicked() {
crate::menus::activate(app, "swatch.addColorTheme", &serde_json::Value::Null);
ui.close();
}
crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| {
let Ok(l) = app.session.execute("ink.list", &json!({})) else { return };
let mut all = l["allToProcess"].as_bool().unwrap_or(false);
if ui.checkbox(&mut all, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "All Spots to Process"))).changed() {
let _ = app.run("ink.options", json!({"allToProcess": all}));
ui.close();
}
ui.separator();
let inks = l["inks"].as_array().cloned().unwrap_or_default();
if inks.is_empty() {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "No spot inks"));
}
for ink in inks {
let name = ink["name"].as_str().unwrap_or("").to_string();
let mut process = ink["process"].as_bool().unwrap_or(false);
ui.add_enabled_ui(!all, |ui| {
if ui.checkbox(&mut process, format!("{name} → process")).changed() {
let _ = app.run("ink.options", json!({"ink": name, "toProcess": process}));
ui.close();
}
});
}
});
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Color Group"))).clicked() {
let _ = app.run("swatch.newColorGroup", json!({}));
ui.close();
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load Swatches…"))).clicked() {
let _ = app.run("app.loadSwatches", json!({}));
ui.close();
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Save Swatches for Exchange…"))).clicked() {
let _ = app.run("app.saveSwatches", json!({}));
ui.close();
}
});
});
});
}
/// Temp-data key of the swatch last clicked (Delete Swatch removes it).
const PICKED: &str = "swatches_picked";
/// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front),
/// Formatting Affects Container / Text, and the tint of the colour in front. `target` is
/// [`super::color_target`]: fill and stroke swatches and tints of the text or the first object.
fn header(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, target: Option<&(String, f32, String, f32)>) {
let t = Tokens::get(ui.ctx());
let (fill_sw, stroke_sw) = target.map_or(("[None]", "[Black]"), |(f, _, s, _)| (f.as_str(), s.as_str()));
let text = super::colors_affect_text(app) || app.ui.formatting_affects_text;
let stroke_front = app.ui.stroke_front;
ui.add_space(3.0);
ui.horizontal(|ui| {
let (r, resp) = ui.allocate_exact_size(vec2(27.0, 27.0), Sense::click());
let tip = crate::i18n::tr(&app.ui.language, if stroke_front { "Stroke" } else { "Fill" });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
let fill_r = egui::Rect::from_min_size(r.min + vec2(1.0, 2.0), vec2(16.0, 16.0));
let stroke_r = egui::Rect::from_min_size(r.min + vec2(10.0, 10.0), vec2(16.0, 16.0));
let paint = |stroke: bool| {
let p = ui.painter();
if stroke {
// The stroke: a frame of its colour.
let (c, g) = crate::widgets::swatch_colors(doc, stroke_sw, 1.0);
crate::widgets::paint_chip(p, stroke_r, c, g);
p.rect_filled(stroke_r.shrink(4.0), 0.0, t.panel);
p.rect_stroke(stroke_r.shrink(4.0), 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside);
p.rect_stroke(stroke_r, 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside);
} else {
let (c, g) = crate::widgets::swatch_colors(doc, fill_sw, 1.0);
crate::widgets::paint_chip(p, fill_r, c, g);
p.rect_stroke(fill_r, 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside);
}
};
// The one in front is drawn last.
paint(!stroke_front);
paint(stroke_front);
if resp
.on_hover_ui(|ui| {
crate::rtl::label(ui, tip);
})
.clicked()
{
let _ = app.run("color.toggleFillStroke", json!({}));
}
ui.add_space(4.0);
for (k, (icon, tip)) in
[("format-container", "Formatting affects container (J)"), ("format-text", "Formatting affects text (J)")].into_iter().enumerate()
{
if icons::button(ui, icon, 20.0, text == (k == 1), crate::i18n::tr(&app.ui.language, tip)).clicked() {
let _ = app.run("app.formattingAffectsText", json!({"on": k == 1}));
}
}
ui.add_space(6.0);
crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Tint:"));
let tint = target.map(|(_, ft, _, st)| f64::from(if stroke_front { *st } else { *ft }) * 100.0);
if let Some(v) = crate::widgets::number(ui, "swatches_tint", tint, "", 44.0, 0) {
let _ = set_tint(app, v);
}
crate::widgets::caption(ui, "%");
});
}
/// The Tint field: `percent` of the colour in front of the proxy, for the text or each selected
/// object, which keep their own swatches.
fn set_tint(app: &mut DesignApp, percent: f64) -> Result<serde_json::Value, String> {
let v = percent.clamp(0.0, 100.0) / 100.0;
let stroke = app.ui.stroke_front;
if super::colors_affect_text(app) {
app.run("type.char", json!({"attrs": {if stroke { "strokeTint" } else { "fillTint" }: v}}))
} else {
app.run(if stroke { "object.stroke" } else { "object.fill" }, json!({"tint": v}))
}
}
/// A folder button (New Color Group), drawn like the colour groups' folders.
fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let f = egui::Rect::from_center_size(r.center(), vec2(14.0, 10.0));
ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.icon), egui::StrokeKind::Inside);
ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.icon));
resp.on_hover_ui(|ui| {
crate::rtl::label(ui, tip);
})
}
/// One Swatches panel row: chip, name, type; click applies it to the fill or stroke in front of
/// the proxy (of the frames or their text, see [`super::colors_affect_text`]), the context menu
/// to the stroke, colour groups and delete.
fn swatch_row(
app: &mut DesignApp,
ui: &mut egui::Ui,
doc: &designcraft_doc::Document,
sw: &designcraft_color::swatch::Swatch,
indent: f32,
fill_cur: Option<&str>,
) {
let t = Tokens::get(ui.ctx());
// Rows can be dragged onto the Gradient panel's ramp.
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &sw.name));
resp.dnd_set_drag_payload(SwatchDrag(sw.name.clone()));
if resp.dragged()
&& let Some(p) = ui.ctx().pointer_interact_pos()
{
// The swatch's chip follows the pointer.
let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0);
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("swatch_drag")));
let chip = egui::Rect::from_min_size(p + vec2(8.0, 8.0), vec2(16.0, 16.0));
crate::widgets::paint_chip(&painter, chip, c, g);
painter.rect_stroke(chip, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
}
if fill_cur == Some(sw.name.as_str()) {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0);
let chip = egui::Rect::from_min_size(row.min + vec2(4.0 + indent, 3.0), vec2(16.0, 16.0));
crate::widgets::paint_chip(ui.painter(), chip, c, g);
ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text);
// Type indicators: spot dot / process square, colour mode.
let kind = match &sw.value {
SwatchValue::Color { color, color_type } => {
let m = match color {
designcraft_color::Color::Cmyk { .. } => "CMYK",
designcraft_color::Color::Rgb { .. } => "RGB",
designcraft_color::Color::Gray { .. } => crate::i18n::tr(&app.ui.language, "Gray"),
};
format!("{}{}", if *color_type == ColorType::Spot { "● " } else { "" }, m)
}
SwatchValue::Gradient { .. } => "Gradient".into(),
SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0),
_ => String::new(),
};
if sw.locked {
icons::paint(ui.painter(), egui::Rect::from_min_size(egui::pos2(row.max.x - 64.0, row.min.y + 4.0), vec2(13.0, 13.0)), "lock", t.text_dim);
}
ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim);
if resp.clicked() && !resp.double_clicked() {
ui.data_mut(|d| d.insert_temp(egui::Id::new(PICKED), sw.name.clone()));
let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None);
}
// Double-click: Swatch Options (the first click has applied the swatch).
if resp.double_clicked() {
let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name}));
}
resp.context_menu(|ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Swatch Options…"))).clicked() {
let _ = app.run("app.swatchOptionsDialog", json!({"name": sw.name}));
ui.close();
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply to Stroke"))).clicked() {
let _ = super::apply_swatch(app, true, &sw.name, None);
ui.close();
}
if !sw.locked {
crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Color Group")), |ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(Top Level)"))).clicked() {
let _ = app.run("swatch.moveToGroup", json!({"swatches": [sw.name], "group": null}));
ui.close();
}
for g in &doc.color_groups {
if ui.button(&g.name).clicked() {
let _ = app.run("swatch.moveToGroup", json!({"swatches": [sw.name], "group": g.name}));
ui.close();
}
}
});
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Color Group with Swatch"))).clicked() {
let _ = app.run("swatch.newColorGroup", json!({"swatches": [sw.name]}));
ui.close();
}
ui.separator();
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Swatch"))).clicked() {
let _ = app.run("swatch.delete", json!({"name": sw.name}));
ui.close();
}
}
});
}
/// Colour models of the Color panel (its panel menu: Lab, CMYK, RGB).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ColorMode {
Cmyk,
Rgb,
Lab,
}
/// Color panel: fill/stroke proxy, sliders in the chosen model (or a tint slider for a named
/// swatch), the spectrum ramp, Add to Swatches. Edits apply unnamed colours (`object.color`).
pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let Some((fill, fill_tint, stroke_sw, stroke_tint)) = super::color_target(app) else {
ui.label(crate::rtl::widget(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Select an object or text to color its fill or stroke.")).color(t.text_dim),
));
return;
};
let text = super::colors_affect_text(app);
let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };
let mode_id = egui::Id::new("color_panel_mode");
let mut stroke = app.ui.stroke_front;
let default_mode = if doc.settings.intent == designcraft_doc::Intent::Print { ColorMode::Cmyk } else { ColorMode::Rgb };
let mut mode: ColorMode = ui.data(|d| d.get_temp(mode_id)).unwrap_or(default_mode);
let target = if stroke { "stroke" } else { "fill" };
let name = if stroke { stroke_sw.clone() } else { fill.clone() };
// Proxy: fill (front) and stroke chips; click to choose, double-click for the Color Picker.
ui.horizontal(|ui| {
for (k, label) in [(false, "Fill"), (true, "Stroke")] {
let n = if k { &stroke_sw } else { &fill };
let (c, g) = crate::widgets::swatch_colors(&doc, n, 1.0);
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
crate::widgets::paint_chip(ui.painter(), r.shrink(2.0), c, g);
if k == stroke {
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
}
let resp = resp.on_hover_text(
crate::i18n::tr(&app.ui.language, "{label}: {name} (double-click: Color Picker)")
.replace("{label}", crate::i18n::tr(&app.ui.language, label))
.replace("{name}", n),
);
if resp.clicked() {
stroke = k;
}
if resp.double_clicked() {
let mut fields = picker_fields(doc.resolve_color(n, 1.0), k);
fields["text"] = json!(text);
app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", fields));
}
}
ui.label(egui::RichText::new(&name).size(11.0).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
egui::ComboBox::from_id_salt("color_mode")
.width(64.0)
.selected_text(match mode {
ColorMode::Cmyk => "CMYK",
ColorMode::Rgb => "RGB",
ColorMode::Lab => "Lab",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut mode, ColorMode::Lab, "Lab");
ui.selectable_value(&mut mode, ColorMode::Cmyk, "CMYK");
ui.selectable_value(&mut mode, ColorMode::Rgb, "RGB");
});
});
});
app.ui.stroke_front = stroke;
ui.data_mut(|d| d.insert_temp(mode_id, mode));
let sw = doc.swatch(&name).cloned();
let Some(c) = doc.resolve_color(&name, 1.0) else {
ui.label(crate::rtl::widget(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "[None] — choose a colour on the ramp.")).color(t.text_dim),
));
spectrum(app, ui, target);
return;
};
// A named swatch shows its tint; mixing its values makes an unnamed colour.
if let Some(sw) = sw.as_ref().filter(|w| !w.hidden && !w.locked) {
let cur = if stroke { stroke_tint } else { fill_tint };
let mut tint = cur * 100.0;
let mut commit = None;
ui.horizontal(|ui| {
ui.label("T");
// The slider applies on release. The field keeps typed text while it has focus (the
// tint is read back from the document every frame) and applies it, clamped, on Enter
// or when it loses focus.
ui.spacing_mut().slider_width = (ui.available_width() - 60.0).max(60.0);
if ui.add(egui::Slider::new(&mut tint, 0.0..=100.0).integer().show_value(false)).drag_stopped() {
commit = Some(tint);
}
if let Some(v) =
crate::widgets::NumField::number("color_tint", Some(f64::from(cur) * 100.0), "%", 0).width(44.0).range(0.0, 100.0).show(ui)
{
commit = Some(v as f32);
}
});
if let Some(v) = commit {
let _ = super::apply_swatch(app, stroke, &sw.name, Some(v / 100.0));
}
}
let mut send: Option<serde_json::Value> = None;
match mode {
ColorMode::Cmyk => {
let mut v = c.to_cmyk();
let mut done = false;
for (k, label) in ["C", "M", "Y", "K"].iter().enumerate() {
done |= channel(ui, label, &mut v[k], 100.0, "%", |x| cmyk_ramp(k, x));
}
if done {
send = Some(json!({"model": "cmyk", "c": v[0], "m": v[1], "y": v[2], "k": v[3]}));
}
}
ColorMode::Rgb => {
let mut v = c.to_rgb();
let mut done = false;
for (k, label) in ["R", "G", "B"].iter().enumerate() {
done |= channel(ui, label, &mut v[k], 255.0, "", |x| {
let mut p = [0.0; 3];
p[k] = x;
egui::Color32::from_rgb((p[0] * 255.0) as u8, (p[1] * 255.0) as u8, (p[2] * 255.0) as u8)
});
}
if done {
send = Some(json!({"model": "rgb", "r": v[0], "g": v[1], "b": v[2]}));
}
}
ColorMode::Lab => {
let lab = c.to_lab();
ui.label(egui::RichText::new(format!("L {:.0} a {:.0} b {:.0}", lab.l, lab.a, lab.b)).color(t.text_dim));
}
}
if let Some(color) = send {
let _ = app.run("object.color", json!({"color": color, "target": target, "text": text}));
}
spectrum(app, ui, target);
ui.horizontal(|ui| {
let unnamed = sw.as_ref().is_some_and(|w| w.hidden);
if ui.add_enabled(unnamed, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add to Swatches")))).clicked() {
let _ = app.run("swatch.addToSwatches", json!({"swatch": name, "target": target}));
}
});
}
/// The Color Picker's fields for `color` (`None`: black): its values in its own model, so OK
/// keeps a CMYK colour CMYK.
pub(crate) fn picker_fields(color: Option<designcraft_color::Color>, stroke: bool) -> serde_json::Value {
let color = color.unwrap_or(designcraft_color::Color::BLACK);
let mut f = json!({"target": if stroke { "stroke" } else { "fill" }, "hex": color.to_hex()});
match color {
designcraft_color::Color::Cmyk { c, m, y, k } => {
f["model"] = json!("cmyk");
f["cmyk"] = json!([c, m, y, k].map(|v| f64::from(v) * 100.0));
}
designcraft_color::Color::Rgb { r, g, b } => {
f["model"] = json!("rgb");
f["rgb"] = json!([r, g, b].map(|v| f64::from(v) * 255.0));
}
designcraft_color::Color::Gray { .. } => f["model"] = json!("rgb"),
}
f
}
/// One slider with a gradient track of the channel and a value field. True when it was changed
/// (on release, 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| {
ui.label(egui::RichText::new(label).monospace());
let w = (ui.available_width() - 60.0).max(60.0);
let (track, resp) = ui.allocate_exact_size(vec2(w, 14.0), Sense::click_and_drag());
// Gradient track.
let n = 24;
for i in 0..n {
let x0 = track.min.x + track.width() * i as f32 / n as f32;
let x1 = track.min.x + track.width() * (i + 1) as f32 / n as f32;
let r = egui::Rect::from_min_max(egui::pos2(x0, track.min.y + 2.0), egui::pos2(x1 + 0.5, track.max.y - 2.0));
ui.painter().rect_filled(r, 0.0, ramp((i as f32 + 0.5) / n as f32));
}
let x = track.min.x + track.width() * v.clamp(0.0, 1.0);
ui.painter().add(egui::Shape::convex_polygon(
vec![egui::pos2(x, track.max.y - 3.0), egui::pos2(x - 4.0, track.max.y + 3.0), egui::pos2(x + 4.0, track.max.y + 3.0)],
ui.visuals().text_color(),
egui::Stroke::NONE,
));
if let Some(p) = resp.interact_pointer_pos() {
*v = ((p.x - track.min.x) / track.width()).clamp(0.0, 1.0);
}
changed |= resp.drag_stopped() || resp.clicked();
// The value is applied when typing ends or a drag stops; until then the field's number
// lives here, because `v` is read back from the document every frame.
let pending_id = ui.id().with(("channel", label));
let mut num = ui.data(|d| d.get_temp::<f32>(pending_id)).unwrap_or((*v * scale).round());
let field = ui.add(egui::DragValue::new(&mut num).range(0.0..=scale).suffix(suffix).update_while_editing(false));
if field.dragged() {
ui.data_mut(|d| d.insert_temp(pending_id, num));
} else {
ui.data_mut(|d| d.remove::<f32>(pending_id));
if field.changed() || field.drag_stopped() {
*v = num / scale;
changed = true;
}
}
});
changed
}
fn cmyk_ramp(k: usize, x: f32) -> egui::Color32 {
let mut v = [0.0f32; 4];
v[k] = x;
let [r, g, b] = designcraft_color::Color::cmyk(v[0], v[1], v[2], v[3]).to_rgb();
egui::Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
}
/// The spectrum ramp: hue across, light to dark down; click to apply.
fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click());
let at = |u: f32, v: f32| -> [f32; 3] {
let c = egui::ecolor::Hsva::new(u, (v * 2.0).min(1.0), (2.0 - v * 2.0).min(1.0), 1.0).to_rgb();
[c[0], c[1], c[2]]
};
let (nx, ny) = (48, 8);
for i in 0..nx {
for j in 0..ny {
let c = at((i as f32 + 0.5) / nx as f32, (j as f32 + 0.5) / ny as f32);
let cell = egui::Rect::from_min_max(
egui::pos2(r.min.x + r.width() * i as f32 / nx as f32, r.min.y + r.height() * j as f32 / ny as f32),
egui::pos2(r.min.x + r.width() * (i + 1) as f32 / nx as f32 + 0.5, r.min.y + r.height() * (j + 1) as f32 / ny as f32 + 0.5),
);
ui.painter().rect_filled(cell, 0.0, egui::Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8));
}
}
if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0));
let text = super::colors_affect_text(app);
let q = |v: f32| (v * 255.0).round() / 255.0;
let _ = app.run("object.color", json!({"color": {"model": "rgb", "r": q(c[0]), "g": q(c[1]), "b": q(c[2])}, "target": target, "text": text}));
}
}
/// A swatch dragged out of the Swatches panel (the Gradient panel's ramp takes it as a stop).
#[derive(Clone, Debug)]
pub struct SwatchDrag(pub String);
/// The selection's gradient dragged from the Gradient panel's thumbnail (its swatch name): the
/// Swatches panel takes it as a new gradient swatch.
#[derive(Clone, Debug)]
pub struct GradientDrag(pub String);
/// A colour in `object.gradient` form, keeping its model (CMYK stays CMYK).
fn color_json(c: &designcraft_color::Color) -> serde_json::Value {
use designcraft_color::Color;
match *c {
// The model form takes fractions; bare CMYK would be read as 0..100.
Color::Cmyk { c, m, y, k } => json!({"model": "cmyk", "c": c, "m": m, "y": y, "k": k}),
Color::Gray { k } => json!({"model": "gray", "k": k}),
Color::Rgb { .. } => json!(c.to_hex()),
}
}
/// Stops in `object.gradient` form.
fn stops_json(g: &designcraft_color::Gradient) -> Vec<serde_json::Value> {
g.stops
.iter()
.map(|s| json!({"location": s.offset as f64 * 100.0, "color": color_json(&s.color), "opacity": s.opacity as f64 * 100.0, "midpoint": s.midpoint as f64 * 100.0}))
.collect()
}
fn color32(c: &designcraft_color::Color, alpha: f32) -> egui::Color32 {
let [r, g, b] = c.to_rgb();
egui::Color32::from_rgba_unmultiplied((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, (alpha * 255.0) as u8)
}
/// The panel's thumbnail: the gradient in a square, linear across or radial from the centre.
fn thumbnail(p: &egui::Painter, r: egui::Rect, g: &designcraft_color::Gradient, border: egui::Color32) {
let n = 32;
if g.kind == designcraft_color::GradientKind::Radial {
// Rings from the outside in.
for i in (0..n).rev() {
let t = (i as f32 + 0.5) / n as f32;
let (c, a) = g.sample(t);
let rad = r.width() / 2.0 * std::f32::consts::SQRT_2 * (i + 1) as f32 / n as f32;
p.with_clip_rect(r).circle_filled(r.center(), rad, color32(&c, a));
}
} else {
for i in 0..n {
let (a, b) = (i as f32 / n as f32, (i + 1) as f32 / n as f32);
let (c, al) = g.sample((a + b) / 2.0);
let x0 = r.left() + r.width() * a;
p.rect_filled(egui::Rect::from_x_y_ranges(x0..=r.left() + r.width() * b + 0.5, r.y_range()), 0.0, color32(&c, al));
}
}
p.rect_stroke(r, 0.0, egui::Stroke::new(1.0, border), egui::StrokeKind::Outside);
}
/// What's selected on the ramp: a stop, or the midpoint after a stop.
#[derive(Clone, Copy, PartialEq, Debug)]
enum RampSel {
Stop(usize),
Mid(usize),
}
/// A marker being dragged: what, where (0–1), off the panel (stops only).
type RampDrag = (RampSel, f32, bool);
/// The drag kept from the last frame, if it still names a marker of a gradient with `n` stops
/// (the selection or its gradient may have changed since).
fn live_drag(d: Option<RampDrag>, n: usize) -> Option<RampDrag> {
d.filter(|(h, _, _)| match *h {
RampSel::Stop(i) => i < n,
RampSel::Mid(i) => i.saturating_add(1) < n,
})
}
/// Sets `key` of stop `i` in `object.gradient` stops; nothing if there's no such stop.
fn set_stop(stops: &mut [serde_json::Value], i: usize, key: &str, v: serde_json::Value) {
if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) {
o.insert(key.to_string(), v);
}
}
/// Gradient panel, laid out as InDesign's: thumbnail, Type, Location and Angle, Reverse, and the
/// ramp with midpoint diamonds above it and stops below it. Drag a stop or a diamond to move it,
/// click below the ramp to add a stop, drag a stop off to remove it, and drop a swatch from the
/// Swatches panel on a stop to colour it or elsewhere on the ramp to add a stop (or right-click a
/// stop for its colour). Until the fill is
/// a gradient the ramp shows the default White → Black, and using it gives the fill a gradient.
pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let lang = app.ui.language.clone();
let Some(st) = app.session.active() else { return };
let item = st.selection.items.first().and_then(|i| st.doc.item(*i));
let has_item = item.is_some();
// The fill's gradient, or the stroke's when the toolbox's Stroke box is in front.
let stroke_front = app.ui.stroke_front;
let fill = item.map(|it| if stroke_front { it.stroke.as_fill() } else { it.fill.clone() }).unwrap_or_default();
let cur = designcraft_color::swatch::resolve_gradient(&st.doc.swatches, &fill.swatch).cloned();
let is_gradient = cur.is_some();
let g = cur.unwrap_or_default();
let radial = g.kind == designcraft_color::GradientKind::Radial;
let sel_id = egui::Id::new("grad_sel");
let drag_id = egui::Id::new("grad_drag");
let mut sel: RampSel = ui.data(|d| d.get_temp(sel_id)).unwrap_or(RampSel::Stop(0));
let last = g.stops.len().saturating_sub(1);
sel = match sel {
RampSel::Stop(i) => RampSel::Stop(i.min(last)),
RampSel::Mid(i) if i < last => RampSel::Mid(i),
RampSel::Mid(_) => RampSel::Stop(0),
};
let mut send: Option<serde_json::Value> = None;
// The ramp spans the panel, however wide the rows above are.
let full = ui.available_width().max(60.0);
ui.add_enabled_ui(has_item, |ui| {
ui.horizontal(|ui| {
let (r, resp) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click_and_drag());
thumbnail(ui.painter(), r.shrink(1.0), &g, t.field_border);
// Drag the gradient onto the Swatches panel to keep it as a swatch.
if is_gradient {
resp.dnd_set_drag_payload(GradientDrag(fill.swatch.clone()));
if resp.dragged()
&& let Some(p) = ui.ctx().pointer_interact_pos()
{
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gradient_drag")));
thumbnail(&painter, egui::Rect::from_min_size(p + vec2(8.0, 8.0), vec2(18.0, 18.0)), &g, t.field_border);
}
}
// Clicking the thumbnail applies the gradient shown (InDesign's way to start one).
if resp.clicked() && !is_gradient {
send = Some(json!({}));
}
ui.add_space(4.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
ui.horizontal(|ui| {
crate::widgets::caption_w(ui, crate::i18n::tr(&lang, "Type:"), 52.0);
let shown = if is_gradient { crate::i18n::tr(&lang, g.kind.label()) } else { "" };
egui::ComboBox::from_id_salt("grad_type").selected_text(crate::rtl::widget(ui, shown)).width(104.0).show_ui(ui, |ui| {
for (k, v) in [("Linear", "linear"), ("Radial", "radial")] {
if ui.selectable_label(is_gradient && g.kind.label() == k, crate::rtl::widget(ui, crate::i18n::tr(&lang, k))).clicked() {
send = Some(json!({"kind": v}));
}
}
});
});
ui.horizontal(|ui| {
crate::widgets::caption_w(ui, crate::i18n::tr(&lang, "Location:"), 52.0);
let loc = match sel {
RampSel::Stop(i) => g.stops.get(i).map(|s| s.offset as f64 * 100.0),
RampSel::Mid(i) => g.stops.get(i).map(|s| s.midpoint as f64 * 100.0),
};
if let Some(v) = crate::widgets::number(ui, "grad_loc", loc.filter(|_| is_gradient), "", 50.0, 3) {
let mut stops = stops_json(&g);
match sel {
RampSel::Stop(i) => set_stop(&mut stops, i, "location", json!(v.clamp(0.0, 100.0))),
RampSel::Mid(i) => set_stop(&mut stops, i, "midpoint", json!(v.clamp(13.0, 87.0))),
}
send = Some(json!({"stops": stops}));
}
crate::widgets::caption(ui, "%");
ui.add_space(4.0);
crate::widgets::caption(ui, crate::i18n::tr(&lang, "Angle:"));
let angle =
fill.gradient_vector.map(|[x0, y0, x1, y1]| (-(y1 - y0)).atan2(x1 - x0).to_degrees()).or(fill.gradient_angle).unwrap_or(0.0);
ui.add_enabled_ui(is_gradient && !radial, |ui| {
if let Some(v) = crate::widgets::number(ui, "grad_angle", is_gradient.then_some(angle), "°", 44.0, 1) {
send = Some(json!({"angle": v}));
}
});
});
ui.horizontal(|ui| {
crate::widgets::caption_w(ui, crate::i18n::tr(&lang, "Reverse"), 52.0);
if icons::button(ui, "swap", 20.0, false, crate::i18n::tr(&lang, "Reverse the gradient")).clicked() && is_gradient {
send = Some(json!({"reverse": true}));
}
});
});
});
ui.add_space(6.0);
// The ramp: midpoint diamonds above, stops below.
let w = full;
let (area, resp) = ui.allocate_exact_size(vec2(w, 40.0), Sense::click_and_drag());
let ramp = egui::Rect::from_min_size(area.min + vec2(6.0, 10.0), vec2(w - 12.0, 14.0));
let painter = ui.painter();
let n = 96;
for i in 0..n {
let (a, b) = (i as f32 / n as f32, (i + 1) as f32 / n as f32);
let (c, alpha) = g.sample((a + b) / 2.0);
let x0 = ramp.left() + ramp.width() * a;
let x1 = ramp.left() + ramp.width() * b + 0.5;
painter.rect_filled(egui::Rect::from_x_y_ranges(x0..=x1, ramp.y_range()), 0.0, color32(&c, alpha));
}
painter.rect_stroke(ramp, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
let x_of = |o: f32| ramp.left() + ramp.width() * o;
let o_of = |x: f32| ((x - ramp.left()) / ramp.width()).clamp(0.0, 1.0);
let mid_x = |i: usize| match (g.stops.get(i), g.stops.get(i + 1)) {
(Some(a), Some(b)) => Some(x_of(a.offset + (b.offset - a.offset) * a.midpoint)),
_ => None,
};
// The marker under the pointer: a stop below the ramp, a diamond above it.
let hit = |p: egui::Pos2| -> Option<RampSel> {
if p.y < ramp.top() {
(0..last)
.filter_map(|i| mid_x(i).map(|x| (i, (x - p.x).abs())))
.filter(|(_, d)| *d < 6.0)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| RampSel::Mid(i))
} else {
g.stops
.iter()
.enumerate()
.map(|(i, s)| (i, (x_of(s.offset) - p.x).abs()))
.filter(|(_, d)| *d < 7.0)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| RampSel::Stop(i))
}
};
let mut dragging = live_drag(ui.data(|d| d.get_temp(drag_id)), g.stops.len());
if resp.drag_started()
&& let Some(p) = resp.interact_pointer_pos()
&& let Some(h) = hit(p)
&& let Some(o) = match h {
RampSel::Stop(i) => g.stops.get(i).map(|s| s.offset),
RampSel::Mid(i) => g.stops.get(i).map(|s| s.midpoint),
}
{
sel = h;
dragging = Some((h, o, false));
}
if resp.dragged()
&& let (Some((h, _, _)), Some(p)) = (dragging, resp.interact_pointer_pos())
{
dragging = Some(match h {
RampSel::Stop(_) => (h, o_of(p.x), p.y > area.bottom() + 16.0 && g.stops.len() > 2),
RampSel::Mid(i) => {
let m = match (g.stops.get(i), g.stops.get(i + 1)) {
(Some(a), Some(b)) if b.offset - a.offset > 1e-6 => ((o_of(p.x) - a.offset) / (b.offset - a.offset)).clamp(0.13, 0.87),
_ => 0.5,
};
(h, m, false)
}
});
}
if resp.drag_stopped()
&& let Some((h, o, off)) = dragging.take()
{
let mut stops = stops_json(&g);
match h {
RampSel::Stop(i) if off => {
if i < stops.len() {
stops.remove(i);
}
sel = RampSel::Stop(0);
}
RampSel::Stop(i) => set_stop(&mut stops, i, "location", json!(o as f64 * 100.0)),
RampSel::Mid(i) => set_stop(&mut stops, i, "midpoint", json!(o as f64 * 100.0)),
}
send = Some(json!({"stops": stops}));
}
if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
match hit(p) {
Some(h) => sel = h,
// Below the ramp, away from the stops: a new stop with the colour there.
None if p.y > ramp.bottom() => {
let o = o_of(p.x);
let (c, a) = g.sample(o);
let mut stops = stops_json(&g);
stops.push(json!({"location": o as f64 * 100.0, "color": color_json(&c), "opacity": a as f64 * 100.0, "midpoint": 50.0}));
send = Some(json!({"stops": stops}));
sel = RampSel::Stop(g.stops.iter().filter(|s| s.offset <= o).count());
}
None => {}
}
}
// Right-click a stop: its colour (InDesign takes it from the Swatches or Color panel).
if resp.secondary_clicked()
&& let Some(RampSel::Stop(i)) = resp.interact_pointer_pos().and_then(hit)
{
sel = RampSel::Stop(i);
}
if let (true, RampSel::Stop(i)) = (is_gradient, sel)
&& let Some(s) = g.stops.get(i)
{
resp.context_menu(|ui| {
ui.horizontal(|ui| {
crate::widgets::caption(ui, crate::i18n::tr(&lang, "Stop Color:"));
let [r, gg, bb] = s.color.to_rgb();
let mut rgb = [(r * 255.0) as u8, (gg * 255.0) as u8, (bb * 255.0) as u8];
if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() {
let mut stops = stops_json(&g);
set_stop(&mut stops, i, "color", json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
send = Some(json!({"stops": stops}));
}
});
});
}
// A swatch dropped from the Swatches panel: on a stop it colours that stop, elsewhere on
// the ramp it's a new stop there.
let drop_at = |p: egui::Pos2| match hit(egui::pos2(p.x, ramp.bottom() + 4.0)) {
Some(RampSel::Stop(i)) => match g.stops.get(i) {
Some(s) => (Some(i), s.offset),
None => (None, o_of(p.x)),
},
_ => (None, o_of(p.x)),
};
if resp.dnd_hover_payload::<SwatchDrag>().is_some()
&& let Some(p) = ui.ctx().pointer_hover_pos()
{
let x = x_of(drop_at(p).1);
painter.line_segment([egui::pos2(x, ramp.top() - 3.0), egui::pos2(x, ramp.bottom() + 14.0)], egui::Stroke::new(2.0, t.accent));
}
if let Some(sw) = resp.dnd_release_payload::<SwatchDrag>()
&& let Some(p) = ui.ctx().pointer_interact_pos()
{
let mut stops = stops_json(&g);
match drop_at(p) {
(Some(i), _) => {
if let Some(o) = stops.get_mut(i).and_then(serde_json::Value::as_object_mut) {
o.insert("swatch".to_string(), json!(sw.0));
o.remove("color");
}
sel = RampSel::Stop(i);
}
(None, o) => {
stops.push(json!({"location": o as f64 * 100.0, "swatch": sw.0, "midpoint": 50.0}));
sel = RampSel::Stop(g.stops.iter().filter(|s| s.offset <= o).count());
}
}
send = Some(json!({"stops": stops}));
}
// Diamonds.
for i in 0..last {
let x = match dragging {
Some((RampSel::Mid(j), m, _)) if j == i => match (g.stops.get(i), g.stops.get(i + 1)) {
(Some(a), Some(b)) => x_of(a.offset + (b.offset - a.offset) * m),
_ => ramp.left(),
},
_ => mid_x(i).unwrap_or(ramp.left()),
};
let (cy, r) = (ramp.top() - 6.0, 4.0);
let pts = vec![egui::pos2(x, cy - r), egui::pos2(x + r, cy), egui::pos2(x, cy + r), egui::pos2(x - r, cy)];
let on = sel == RampSel::Mid(i);
painter.add(egui::Shape::convex_polygon(pts, if on { t.text } else { egui::Color32::TRANSPARENT }, egui::Stroke::new(1.0, t.text_dim)));
}
// Stops: a colour square under a pointer.
for (i, s) in g.stops.iter().enumerate() {
let (o, off) = match dragging {
Some((RampSel::Stop(j), o, off)) if j == i => (o, off),
_ => (s.offset, false),
};
if off {
continue;
}
let x = x_of(o);
let top = ramp.bottom() + 2.0;
let on = sel == RampSel::Stop(i);
let edge = if on { t.text_strong } else { t.text_dim };
painter.add(egui::Shape::convex_polygon(
vec![egui::pos2(x, top), egui::pos2(x + 5.0, top + 5.0), egui::pos2(x - 5.0, top + 5.0)],
if on { t.text_strong } else { t.panel },
egui::Stroke::new(1.0, edge),
));
let sq = egui::Rect::from_min_size(egui::pos2(x - 5.0, top + 5.0), vec2(10.0, 9.0));
painter.rect_filled(sq, 0.0, color32(&s.color, 1.0));
painter.rect_stroke(sq, 0.0, egui::Stroke::new(if on { 1.5 } else { 1.0 }, edge), egui::StrokeKind::Inside);
}
ui.data_mut(|d| {
d.insert_temp(sel_id, sel);
match dragging {
Some(v) => {
d.insert_temp(drag_id, v);
}
None => d.remove::<RampDrag>(drag_id),
}
});
});
if let Some(mut p) = send {
if let Some(o) = p.as_object_mut() {
o.insert("target".to_string(), json!(if stroke_front { "stroke" } else { "fill" }));
}
let _ = app.run("object.gradient", p);
}
}
#[cfg(test)]
mod gradient_panel_tests {
use designcraft_color::Color;
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use super::*;
fn app_with_frame() -> DesignApp {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [100, 100, 300, 300], "content": "unassigned"})).unwrap();
app
}
fn gradient(app: &DesignApp) -> Option<designcraft_color::Gradient> {
let d = &app.session.doc().unwrap().doc;
let id = app.session.doc().unwrap().selection.items[0];
designcraft_color::swatch::resolve_gradient(&d.swatches, &d.item(id).unwrap().fill.swatch).cloned()
}
#[test]
fn panel_edits_keep_cmyk_and_grey_stops() {
let mut app = app_with_frame();
app.run(
"object.gradient",
json!({"stops": [{"location": 0, "swatch": "C=0 M=100 Y=0 K=0"}, {"location": 50, "color": {"gray": 60}}, {"location": 100, "color": {"model": "gray", "k": 0.01}}]}),
)
.unwrap();
let g = gradient(&app).unwrap();
// What the panel sends when a stop moves: the colours come back exactly.
let mut stops = stops_json(&g);
stops[1]["location"] = json!(80.0);
app.run("object.gradient", json!({"stops": stops})).unwrap();
let g = gradient(&app).unwrap();
assert_eq!(g.stops[0].color, Color::cmyk(0.0, 1.0, 0.0, 0.0));
assert_eq!(g.stops[1].color, Color::gray(0.6));
assert!((g.stops[1].offset - 0.8).abs() < 1e-6);
assert_eq!(g.stops[2].color, Color::gray(0.01), "a 1 % grey stays 1 %");
}
#[test]
fn a_drag_kept_from_a_longer_gradient_is_dropped() {
let d = |h| Some((h, 0.5, false));
assert_eq!(live_drag(d(RampSel::Stop(1)), 2), d(RampSel::Stop(1)));
assert_eq!(live_drag(d(RampSel::Stop(2)), 2), None);
assert_eq!(live_drag(d(RampSel::Mid(0)), 2), d(RampSel::Mid(0)));
assert_eq!(live_drag(d(RampSel::Mid(1)), 2), None);
assert_eq!(live_drag(d(RampSel::Mid(usize::MAX)), 2), None);
let mut stops = vec![json!({"location": 0})];
set_stop(&mut stops, 3, "location", json!(50));
assert_eq!(stops, vec![json!({"location": 0})]);
}
#[test]
fn a_swatch_dropped_on_the_ramp_is_a_new_stop() {
let app = app_with_frame();
let mut h = Harness::builder().with_size(vec2(280.0, 200.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let id = egui::Id::new("test_fonts");
if ui.data(|d| d.get_temp::<bool>(id)).is_none() {
crate::theme::install_fonts(ui.ctx(), "");
ui.data_mut(|d| d.insert_temp(id, true));
return;
}
gradient_panel(app, ui);
},
app,
);
h.run_steps(3);
assert!(gradient(h.state()).is_none(), "no gradient yet");
// The ramp sits under the Reverse row: drop cyan at its middle.
let row = h.get_by_label("Reverse the gradient").rect();
let at = egui::pos2(h.ctx.content_rect().center().x, row.bottom() + 6.0 + 17.0);
egui::DragAndDrop::set_payload(&h.ctx, SwatchDrag("C=100 M=0 Y=0 K=0".into()));
h.event(egui::Event::PointerMoved(at));
h.run_steps(2);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
let g = gradient(h.state()).expect("the drop gives the fill a gradient");
assert_eq!(g.stops.len(), 3, "White, cyan, Black");
assert_eq!(g.stops[1].color, Color::cmyk(1.0, 0.0, 0.0, 0.0));
assert!((g.stops[1].offset - 0.5).abs() < 0.05, "{}", g.stops[1].offset);
}
/// The Gradient panel's thumbnail drags the selection's gradient onto the Swatches panel,
/// which keeps it as a gradient swatch.
#[test]
fn a_gradient_dragged_onto_the_swatches_becomes_a_swatch() {
let mut app = app_with_frame();
app.run("object.gradient", json!({})).unwrap();
app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap();
app.run("window.panel.float", json!({"panel": "swatches", "x": 620, "y": 120})).unwrap();
let named =
|app: &DesignApp| app.session.doc().unwrap().doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect::<Vec<_>>();
let before = named(&app);
let mut h = crate::test_window::open(app, vec2(1400.0, 900.0));
h.run_steps(6);
drag_gradient_to(&mut h, "[Black]");
let after = named(&h.state().app);
assert_eq!(after.len(), before.len() + 1);
assert_eq!(after.last().map(String::as_str), Some("New Gradient Swatch"));
let d = &h.state().app.session.doc().unwrap().doc;
assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. })));
let id = h.state().app.session.doc().unwrap().selection.items[0];
assert_eq!(d.item(id).unwrap().fill.swatch, "New Gradient Swatch", "the object keeps it, now by name");
}
/// Drag the Gradient panel's thumbnail onto the Swatches panel row labelled `label`.
fn drag_gradient_to(h: &mut egui_kittest::Harness<'static, crate::test_window::Window>, label: &str) {
use egui::{Event, Modifiers, PointerButton};
// The thumbnail sits left of the Type row.
let ty = h.get_by_label("Type:").rect();
let from = egui::pos2(ty.min.x - 21.0, ty.min.y + 12.0);
let to = h.get_by_label(label).rect().center();
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for i in 1..=10 {
h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(4);
}
/// A colour group's row is part of the list too: a gradient dropped on it becomes a swatch.
#[test]
fn a_gradient_dropped_on_a_colour_group_becomes_a_swatch() {
let mut app = app_with_frame();
app.run("object.gradient", json!({})).unwrap();
let group = app.run("swatch.newColorGroup", json!({})).unwrap()["name"].as_str().unwrap().to_string();
app.run("window.panel.float", json!({"panel": "gradient", "x": 300, "y": 120})).unwrap();
// Tall enough to show the group's row below the swatches.
app.run("window.panel.float", json!({"panel": "swatches", "x": 620, "y": 120, "height": 700})).unwrap();
let mut h = crate::test_window::open(app, vec2(1400.0, 900.0));
h.run_steps(6);
drag_gradient_to(&mut h, &group);
let d = &h.state().app.session.doc().unwrap().doc;
assert!(matches!(d.swatch("New Gradient Swatch").map(|w| &w.value), Some(SwatchValue::Gradient { .. })), "dropped on the group's row");
}
}
#[cfg(test)]
mod color_panel_tests {
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::DesignApp;
fn fill(app: &DesignApp, id: u64) -> designcraft_color::Color {
let d = &app.session.active().unwrap().doc;
d.resolve_color(&d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, 1.0).unwrap()
}
/// A number typed into the M field stays while typing and applies as a percent on Enter:
/// M 1 is 1 %, not 100 %.
#[test]
fn a_typed_channel_value_is_kept_and_applied_as_a_percent() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let id = app.run("frame.create", json!({"rect": [36, 36, 200, 200]})).unwrap()["id"].as_u64().unwrap();
app.run("selection.set", json!({"ids": [id]})).unwrap();
app.run("object.color", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})).unwrap();
let mut h = Harness::builder().with_size(vec2(320.0, 300.0)).build_ui_state(|ui, app: &mut DesignApp| super::color_panel(app, ui), app);
h.run_steps(3);
let fields: Vec<egui::Rect> = h.query_all_by(|n| n.role() == egui::accesskit::Role::SpinButton).map(|n| n.rect()).collect();
assert_eq!(fields.len(), 4, "C, M, Y and K fields");
let m = fields[1].center();
h.hover_at(m);
h.drag_at(m);
h.drop_at(m);
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text("1".into()));
h.run_steps(4);
let typed = h.query_by(|n| n.role() == egui::accesskit::Role::SpinButton && n.is_focused()).and_then(|n| n.value());
assert_eq!(typed.as_deref(), Some("1"), "the typed text stays while the field has focus");
let designcraft_color::Color::Cmyk { m, .. } = fill(h.state(), id) else { panic!("not CMYK") };
assert!((m - 0.5).abs() < 1e-6, "nothing is applied while typing: {m}");
h.key_press(egui::Key::Enter);
h.run_steps(2);
let designcraft_color::Color::Cmyk { c, m, y, k } = fill(h.state(), id) else { panic!("not CMYK") };
assert!(c == 0.0 && (m - 0.01).abs() < 1e-6 && (y - 1.0).abs() < 1e-6 && k == 0.0, "{c} {m} {y} {k}");
}
}
#[cfg(test)]
mod swatches_header_tests {
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use super::*;
fn harness(app: DesignApp) -> Harness<'static, DesignApp> {
Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let id = egui::Id::new("test_fonts");
if ui.data(|d| d.get_temp::<bool>(id)).is_none() {
crate::theme::install_fonts(ui.ctx(), "");
ui.data_mut(|d| d.insert_temp(id, true));
return;
}
show(app, ui);
},
app,
)
}
fn items(app: &DesignApp) -> Vec<designcraft_doc::Item> {
let st = app.session.doc().unwrap();
st.selection.items.iter().map(|id| st.doc.item(*id).unwrap().clone()).collect()
}
#[test]
fn swatches_go_to_the_fill_or_stroke_in_front_and_the_bottom_row_deletes_the_one_clicked() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap();
app.run("swatch.create", json!({"name": "Brand", "color": "#108080"})).unwrap();
let mut h = harness(app);
h.run_steps(3);
// Fill in front: a swatch is the fill.
h.get_by_label("C=0 M=100 Y=0 K=0").click();
h.run_steps(2);
assert_eq!(items(h.state())[0].fill.swatch, "C=0 M=100 Y=0 K=0");
// The proxy brings the stroke to the front; the next swatch is the stroke.
h.get_by_label("Fill").click();
h.run_steps(2);
assert!(h.state().ui.stroke_front);
h.get_by_label("C=100 M=0 Y=0 K=0").click();
h.run_steps(2);
let it = &items(h.state())[0];
assert_eq!((it.fill.swatch.as_str(), it.stroke.swatch.as_str()), ("C=0 M=100 Y=0 K=0", "C=100 M=0 Y=0 K=0"));
// Formatting affects text, from the panel.
h.get_by_label("Formatting affects text (J)").click();
h.run_steps(2);
assert!(h.state().ui.formatting_affects_text);
h.get_by_label("Formatting affects container (J)").click();
h.run_steps(2);
assert!(!h.state().ui.formatting_affects_text);
// Delete Swatch removes the swatch last clicked.
h.get_by_label("Brand").click();
h.run_steps(2);
h.get_by_label("Delete Swatch").click();
h.run_steps(2);
assert!(h.state().session.doc().unwrap().doc.swatch("Brand").is_none());
}
#[test]
fn the_tint_field_keeps_each_objects_own_swatch() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let a = app.run("frame.create", json!({"rect": [36, 36, 100, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
let b = app.run("frame.create", json!({"rect": [136, 36, 200, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
app.run("object.fill", json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap();
app.run("object.fill", json!({"ids": [b], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap();
app.run("object.stroke", json!({"ids": [a, b], "swatch": "[Black]"})).unwrap();
app.run("selection.set", json!({"ids": [a, b]})).unwrap();
let mut h = harness(app);
h.run_steps(3);
// The field shows the first selected object's fill tint.
assert!(h.query_all_by_value("100").next().is_some());
set_tint(h.state_mut(), 40.0).unwrap();
h.run_steps(2);
assert!(h.query_all_by_value("40").next().is_some(), "the field follows");
// The stroke in front: the strokes' tints.
h.state_mut().ui.stroke_front = true;
set_tint(h.state_mut(), 70.0).unwrap();
assert!(items(h.state()).iter().all(|i| i.stroke.tint == 0.7));
let it = items(h.state());
assert_eq!((it[0].fill.swatch.as_str(), it[0].fill.tint), ("C=100 M=0 Y=0 K=0", 0.4));
assert_eq!((it[1].fill.swatch.as_str(), it[1].fill.tint), ("C=0 M=100 Y=0 K=0", 0.4));
}
}