//! 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::(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 = 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::(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 { 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 = 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, 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::(pending_id)).unwrap_or((*v * scale).round()); let field = ui.add(egui::DragValue::new(&mut num).range(0.0..=scale).suffix(suffix).update_while_editing(false)); if field.dragged() { ui.data_mut(|d| d.insert_temp(pending_id, num)); } else { ui.data_mut(|d| d.remove::(pending_id)); if field.changed() || field.drag_stopped() { *v = num / scale; changed = true; } } }); changed } 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 { 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, n: usize) -> Option { 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 = 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 { 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::().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::() && 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::(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 { 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::(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::>(); 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 = 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::(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 { 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)); } }