From 096ddf6085bbedc7b011caa818d0f850b4df8423 Mon Sep 17 00:00:00 2001 From: vpkoskinen <46873698+vpkoskinen@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:16:24 +0300 Subject: [PATCH] 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 Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/panels/mod.rs | 37 ++++++++++++++---- crates/ui-egui/src/panels/properties.rs | 50 +++++++++++++++++++++---- crates/ui-egui/src/taskbar.rs | 12 +++++- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3015567..05a7339 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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, 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(); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 09f759c..90fbed0 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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::(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"); + } } diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index 9881542..9b25afd 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -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(); }