Swatch menus: make the whole row (chip included) clickable and show chips in the Contextual Task Bar (#199)

Add one shared swatch_menu_row (full-width clickable chip + name, current
highlight, hover) and use it in the Properties Appearance/text menus, the
shared swatch_picker (Control bar, Stroke, Character, Paragraph Border/
Shading) and both Contextual Task Bar fill/stroke menus. The Task Bar popups
gain a colour chip per swatch, and every swatch menu now applies on a click
anywhere on the row, including the chip.

Cap the popups at 280 pt so a full-width row keeps the menus InDesign-sized.
Label the chip controls and rows for accessibility.

Test: clicking a swatch chip in the Appearance Fill menu applies it (the Task
Bar uses the same swatch_menu_row helper).

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 02:16:24 -04:00
1 parent 617e487dff
commit 096ddf6085
3 files changed
+83 -16

No files matched your search

+30 -7
View File
@@ -153,6 +153,34 @@ pub fn preflight_errors(app: &DesignApp) -> usize {
n
}
/// Height of one swatch-menu row.
const SWATCH_MENU_ROW_H: f32 = 22.0;
/// One swatch-menu row: a chip and the swatch name; the whole row is clickable (the chip included).
/// `name == current` highlights it. Returns true when the row was clicked.
pub fn swatch_menu_row(ui: &mut egui::Ui, doc: &designcraft_doc::Document, name: &str, current: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), SWATCH_MENU_ROW_H), egui::Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, name == current, name));
if name == current {
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, name, 1.0);
let chip = egui::Rect::from_min_size(row.min + vec2(4.0, 3.0), vec2(16.0, 16.0));
crate::widgets::paint_chip(ui.painter(), chip, c, g);
crate::rtl::paint(
ui.painter(),
row.min + vec2(28.0, SWATCH_MENU_ROW_H / 2.0),
egui::Align2::LEFT_CENTER,
name,
egui::FontId::proportional(12.5),
t.text,
);
resp.clicked()
}
/// A swatch dropdown showing a chip and name; `on_pick` gets the chosen swatch name.
pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current: Option<String>, on_pick: impl FnOnce(&mut DesignApp, String)) {
let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };
@@ -175,15 +203,10 @@ pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current:
);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(200.0);
ui.set_max_width(280.0);
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for sw in &doc.swatches {
let (c, g) = crate::widgets::swatch_colors(&doc, &sw.name, 1.0);
let row = ui.horizontal(|ui| {
let (cr, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover());
crate::widgets::paint_chip(ui.painter(), cr, c, g);
ui.add(egui::Button::new(&sw.name).frame(false).selected(sw.name == cur))
});
if row.inner.clicked() {
if swatch_menu_row(ui, &doc, &sw.name, &cur) {
picked = Some(sw.name.clone());
ui.close();
}
+43 -7
View File
@@ -572,6 +572,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) {
widgets::paint_chip(ui.painter(), chip, fc, fg);
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
let resp = ui.interact(chip, ui.id().with("fillchip"), Sense::click()).on_hover_text(format!("Fill: {}", i.fill));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fill swatch"));
swatch_menu(app, &resp, &doc, &i.fill, |app, n| {
let _ = app.run("object.fill", json!({"swatch": n}));
});
@@ -591,6 +592,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) {
widgets::paint_stroke_chip(ui.painter(), chip, sc, egui::Color32::from_gray(20));
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
let resp = ui.interact(chip, ui.id().with("strokechip"), Sense::click()).on_hover_text(format!("Stroke: {}", i.stroke));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Stroke swatch"));
swatch_menu(app, &resp, &doc, &i.stroke, |app, n| {
let _ = app.run("object.stroke", json!({"swatch": n}));
});
@@ -667,15 +669,10 @@ fn swatch_menu(app: &mut DesignApp, resp: &egui::Response, doc: &designcraft_doc
let mut picked = None;
egui::Popup::menu(resp).show(|ui| {
ui.set_min_width(200.0);
ui.set_max_width(280.0);
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for sw in &doc.swatches {
let (c, g) = widgets::swatch_colors(doc, &sw.name, 1.0);
let row = ui.horizontal(|ui| {
let (cr, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
widgets::paint_chip(ui.painter(), cr, c, g);
ui.add(egui::Button::new(&sw.name).frame(false).selected(sw.name == cur))
});
if row.inner.clicked() {
if super::swatch_menu_row(ui, doc, &sw.name, cur) {
picked = Some(sw.name.clone());
ui.close();
}
@@ -2159,4 +2156,43 @@ mod tests {
h.run_steps(2);
assert_eq!(language(h.state_mut()), "French");
}
#[test]
fn clicking_a_swatch_chip_in_the_appearance_fill_menu_applies_it() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
// A graphic frame: the Appearance section shows a single Fill chip.
let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200]})).unwrap()["id"].as_u64().unwrap();
app.run("selection.set", json!({"ids": [id]})).unwrap();
let before = app.session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone();
assert_ne!(before, "[Paper]");
let mut h = Harness::builder().with_size(vec2(320.0, 1400.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,
);
h.run_steps(3);
// Open the Fill menu from the Appearance chip.
h.get_by_label("Fill swatch").click();
h.run_steps(3);
// Click the *chip* (the row's left edge), not the name.
let name = "[Paper]".to_string();
let row = h.get_by_label(&name).rect();
// The row spans the menu (chip + name), not just the name text.
assert!(row.width() > 150.0, "the row is the full menu width, chip included: {row:?}");
let p = row.min + vec2(8.0, row.height() / 2.0);
h.hover_at(p);
h.drag_at(p);
h.drop_at(p);
h.run_steps(4);
let after = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone();
assert_eq!(after, name, "clicking the swatch chip applies it");
}
}
+10 -2
View File
@@ -160,11 +160,14 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) {
let sw = c["fill"].as_str().unwrap_or("[Black]").to_string();
let (col, g) = widgets::swatch_colors(&doc, &sw, 1.0);
let resp = widgets::swatch_chip(ui, 17.0, col, g).on_hover_text(format!("Fill: {sw}"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Task Bar fill"));
let mut picked = None;
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(180.0);
ui.set_max_width(280.0);
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for s in &doc.swatches {
if ui.selectable_label(s.name == sw, &s.name).clicked() {
if panels::swatch_menu_row(ui, &doc, &s.name, &sw) {
picked = Some(s.name.clone());
ui.close();
}
@@ -228,11 +231,16 @@ fn object_controls(app: &mut DesignApp, ui: &mut Ui) {
widgets::paint_stroke_chip(ui.painter(), r, col, egui::Color32::from_gray(20));
}
let resp = resp.on_hover_text(format!("{}: {sw}", if k == 0 { "Fill" } else { "Stroke" }));
resp.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Task Bar {}", if k == 0 { "fill" } else { "stroke" }))
});
let mut picked = None;
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(180.0);
ui.set_max_width(280.0);
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for s in &doc.swatches {
if ui.selectable_label(s.name == sw, &s.name).clicked() {
if panels::swatch_menu_row(ui, &doc, &s.name, &sw) {
picked = Some(s.name.clone());
ui.close();
}