From 3a50b20b95c604029c87afd0a4500b692a17014d Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:30:07 +0530 Subject: [PATCH] Select by Artboards dialog: artboard list, search and multi-select (#5548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Plug-ins › Selection Tools › Select by Artboards… lists each artboard's number, name and size, filters by name, chooses rows with click, Shift-click and Cmd/Ctrl-click, and runs addon.select.byArtboards on OK. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/addons.rs | 2 + crates/ui-egui/src/addons/select.rs | 168 ++++++++++++++++++++++ crates/ui-egui/src/addons/select/tests.rs | 69 +++++++++ crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/menus/invoke.rs | 1 + 5 files changed, 241 insertions(+) create mode 100644 crates/ui-egui/src/addons/select.rs create mode 100644 crates/ui-egui/src/addons/select/tests.rs diff --git a/crates/ui-egui/src/addons.rs b/crates/ui-egui/src/addons.rs index 92895fea0a..d9c86240ad 100644 --- a/crates/ui-egui/src/addons.rs +++ b/crates/ui-egui/src/addons.rs @@ -7,6 +7,7 @@ pub mod document; pub mod enclosing_lasso; pub mod pathtools; pub mod pen; +pub mod select; pub mod typetools; use crate::menus::Item; @@ -20,6 +21,7 @@ pub fn plugin_menu() -> Vec { Item::Sub("Document Tools", document::menu()), Item::Sub("Enclosing Lasso", enclosing_lasso::menu()), Item::Sub("Path Precision Tools", pathtools::menu()), + Item::Sub("Selection Tools", select::menu()), Item::Sub("Smart Pen", pen::menu()), Item::Sub("Type Tools", typetools::menu()), Item::Sep, diff --git a/crates/ui-egui/src/addons/select.rs b/crates/ui-egui/src/addons/select.rs new file mode 100644 index 0000000000..461203f0f0 --- /dev/null +++ b/crates/ui-egui/src/addons/select.rs @@ -0,0 +1,168 @@ +//! Selection Tools in the interface: its submenu (Object › Plug-ins › Selection Tools) and the +//! Select by Artboards dialog (#5548), which lists the artboards with their number, name and size, +//! filters them by name, picks several, and on OK runs `addon.select.byArtboards` with the +//! relation chosen. + +use egui::{Sense, Ui, vec2}; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::dialogs::{DialogSpec, form}; +use crate::menus::Item; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::widgets; + +/// Select by Artboards, and its dialog's kind. +pub const COMMAND: &str = "addon.select.byArtboards"; +pub const KIND: &str = "selectByArtboards"; +/// The relations the dialog offers: (value, label). +const RELATIONS: [(&str, &str); 3] = [("overlap", "Overlapping"), ("inside", "Fully Inside"), ("outside", "Fully Outside")]; +const LABEL_W: f32 = 70.0; +const ROW_H: f32 = 22.0; +const NUMBER_W: f32 = 28.0; +const LIST_H: f32 = 220.0; + +/// Object › Plug-ins › Selection Tools. +pub fn menu() -> Vec { + vec![Item::Cmd("Select by Artboards…", COMMAND, Value::Null)] +} + +/// The dialog's fields to start with: the active artboard chosen, Overlapping. +pub(crate) fn fields(app: &VectorcraftApp) -> Value { + json!({ "artboards": [app.view().map_or(0, |v| v.artboard)], "relation": "overlap" }) +} + +pub(crate) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Select by Artboards").to_string(), + body, + confirm, + min_width: 360.0, + ok_enabled: Some(|_, d| !chosen(d).is_empty()), + ..DialogSpec::FORM +}; + +/// The artboards chosen in dialog `d`, in order. +fn chosen(d: &Dialog) -> Vec { + let mut v: Vec = + d.fields.get("artboards").and_then(Value::as_array).into_iter().flatten().filter_map(|i| usize::try_from(i.as_u64()?).ok()).collect(); + v.sort_unstable(); + v.dedup(); + v +} + +/// The indices of `names` whose name holds `query`, ignoring case (all of them for an empty query). +pub(crate) fn shown(names: &[String], query: &str) -> Vec { + let q = query.trim().to_lowercase(); + names.iter().enumerate().filter(|(_, n)| q.is_empty() || n.to_lowercase().contains(&q)).map(|(i, _)| i).collect() +} + +/// A click on row `i` of the rows `shown`: alone it chooses only that artboard, with Cmd/Ctrl it +/// toggles it, with Shift it adds the rows from the last one clicked (`anchor`) to it. Returns the +/// new anchor. +pub(crate) fn click(chosen: &mut Vec, anchor: Option, i: usize, shown: &[usize], shift: bool, command: bool) -> Option { + let at = |j: usize| shown.iter().position(|k| *k == j); + match (shift, anchor.and_then(at), at(i)) { + (true, Some(a), Some(b)) => { + chosen.extend(shown.iter().skip(a.min(b)).take(a.abs_diff(b) + 1)); + chosen.sort_unstable(); + chosen.dedup(); + return anchor; + } + _ if command => match chosen.iter().position(|j| *j == i) { + Some(p) => { + chosen.remove(p); + } + None => chosen.push(i), + }, + _ => *chosen = vec![i], + } + chosen.sort_unstable(); + Some(i) +} + +fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let unit = app.session.general_unit(); + let boards: Vec<(String, f64, f64)> = app + .session + .active() + .map(|st| st.doc.artboards.iter().map(|a| (a.name.clone(), a.rect.width(), a.rect.height())).collect()) + .unwrap_or_default(); + let names: Vec = boards.iter().map(|b| b.0.clone()).collect(); + let query = widgets::search_field(ui, ui.scope_id().with("select-by-artboards-search"), "Search artboards"); + d.fields.insert("__search".into(), json!(query)); + let rows = shown(&names, &query); + let picked = chosen(d); + let mut clicked = None; + ui.add_space(4.0); + egui::ScrollArea::vertical().max_height(LIST_H).auto_shrink([false, true]).show(ui, |ui| { + if rows.is_empty() { + ui.label(egui::RichText::new(tl!("No artboards match.")).color(t.text_dim)); + } + for &i in &rows { + let Some((name, w, h)) = boards.get(i) else { continue }; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); + let on = picked.contains(&i); + if on { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let font = egui::FontId::proportional(12.0); + let mid = r.left_center(); + let p = ui.painter(); + p.text(mid + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, (i + 1).to_string(), font.clone(), t.text_dim); + let size = format!("{} × {}", unit.number(*w), unit.format(*h)); + let size_at = r.right_center() - vec2(6.0, 0.0); + p.text(size_at, egui::Align2::RIGHT_CENTER, &size, font.clone(), t.text_dim); + p.with_clip_rect(r.with_max_x(size_at.x - 80.0)).text(mid + vec2(NUMBER_W, 0.0), egui::Align2::LEFT_CENTER, name, font, t.text); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::ListItem, true, on, format!("{} {name} {size}", i + 1))); + if resp.clicked() { + clicked = Some((i, ui.input(|inp| inp.modifiers))); + } + } + }); + if let Some((i, m)) = clicked { + row_clicked(d, i, &rows, m); + } + let mut picked = chosen(d); + ui.add_space(4.0); + ui.horizontal(|ui| { + if widgets::flat_button_fit(ui, "Select All", 70.0).clicked() { + picked.extend(rows.iter().copied()); + picked.sort_unstable(); + picked.dedup(); + } + if widgets::flat_button_fit(ui, "None", 50.0).clicked() { + picked.clear(); + } + // Rows hidden by the search stay chosen: the count says how many are. + let count = crate::i18n::tn(picked.len() as u64, "{n} of {total} chosen", "{n} of {total} chosen"); + ui.label(egui::RichText::new(crate::i18n::fmt(&count, &[("total", &boards.len().to_string())])).color(t.text_dim)); + }); + d.fields.insert("artboards".into(), json!(picked)); + ui.add_space(6.0); + form::choice(ui, d, "relation", tl!("Select:"), (LABEL_W, 160.0), &RELATIONS); + false +} + +/// A click on row `i` of the rows `shown`, with modifier keys `m`: [`click`] on dialog `d`'s +/// chosen artboards. +pub(crate) fn row_clicked(d: &mut Dialog, i: usize, shown: &[usize], m: egui::Modifiers) { + let mut picked = chosen(d); + let anchor = d.fields.get("__anchor").and_then(Value::as_u64).and_then(|a| usize::try_from(a).ok()); + if let Some(a) = click(&mut picked, anchor, i, shown, m.shift, m.command) { + d.fields.insert("__anchor".into(), json!(a)); + } + d.fields.insert("artboards".into(), json!(picked)); +} + +/// OK: select the objects and close. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.dialog = None; + app.run(COMMAND, form::params(d)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/addons/select/tests.rs b/crates/ui-egui/src/addons/select/tests.rs new file mode 100644 index 0000000000..ac205bddcb --- /dev/null +++ b/crates/ui-egui/src/addons/select/tests.rs @@ -0,0 +1,69 @@ +use serde_json::json; +use vectorcraft_engine::Session; + +use super::{click, shown}; +use crate::tests_labels::painted_text; +use crate::{VectorcraftApp, menus}; + +fn names(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() +} + +#[test] +fn the_search_filters_artboards_by_name_ignoring_case() { + let n = names(&["Cover", "Page 2", "Back cover", "Inside"]); + assert_eq!(shown(&n, ""), vec![0, 1, 2, 3]); + assert_eq!(shown(&n, " COVER "), vec![0, 2]); + assert_eq!(shown(&n, "zzz"), Vec::::new()); +} + +#[test] +fn clicks_choose_toggle_and_extend_rows() { + let rows = [0, 2, 3, 5]; + let mut c = vec![]; + let a = click(&mut c, None, 2, &rows, false, false); + assert_eq!((c.clone(), a), (vec![2], Some(2))); + // Shift adds the rows shown between the last one clicked and this one. + let a = click(&mut c, a, 5, &rows, true, false); + assert_eq!((c.clone(), a), (vec![2, 3, 5], Some(2))); + // Cmd/Ctrl toggles a row. + let a = click(&mut c, a, 3, &rows, false, true); + assert_eq!((c.clone(), a), (vec![2, 5], Some(3))); + let a = click(&mut c, a, 0, &rows, false, true); + assert_eq!((c.clone(), a), (vec![0, 2, 5], Some(0))); + // A plain click chooses that row alone; Shift without an anchor does too. + let a = click(&mut c, a, 3, &rows, false, false); + assert_eq!((c.clone(), a), (vec![3], Some(3))); + let a = click(&mut c, None, 5, &rows, true, false); + assert_eq!((c, a), (vec![5], Some(5))); +} + +#[test] +fn select_by_artboards_lists_the_artboards_and_selects_on_ok() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("artboard.new", json!({"x": 150, "y": 0, "width": 200, "height": 50, "name": "Banner"})).unwrap(); + let inside = app.run("shape.rectangle", json!({"x": 160, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let entries = menus::menu_entries(&app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Selection Tools"] && e.label == "Select by Artboards…").unwrap(); + let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(&mut app, &cmd, p); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.kind, super::KIND); + assert_eq!(d.fields["artboards"], json!([0]), "the active artboard is chosen"); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Select by Artboards", "Artboard 1", "Banner", "100 × 100 pt", "200 × 50 pt", "1 of 2 chosen", "Overlapping", "Select All", "None"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("artboards".into(), json!([])); + assert!(!(super::SPEC.ok_enabled.unwrap())(&app, app.ui.dialog.as_ref().unwrap()), "OK is off with nothing chosen"); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("artboards".into(), json!([1])); + d.fields.insert("relation".into(), json!("inside")); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(inside)]); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3e029b2474..099d6db583 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -416,6 +416,7 @@ registry! { NearestSwatch: [crate::addons::color::nearest::KIND] => crate::addons::color::nearest::SPEC, SwatchLegend: [crate::addons::color::legend::KIND] => crate::addons::color::legend::SPEC, TextTweaker: [crate::addons::typetools::KIND] => crate::addons::typetools::SPEC, + SelectByArtboards: [crate::addons::select::KIND] => crate::addons::select::SPEC, ArrangeTools: [crate::addons::arrange::GRID, crate::addons::arrange::REPLACE, crate::addons::arrange::SIZE, crate::addons::arrange::OPACITY, crate::addons::arrange::SORT] => crate::addons::arrange::SPEC, ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm), } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 0b30e0dc63..18e77b792e 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -72,6 +72,7 @@ pub(super) fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static st crate::addons::color::nearest::COMMAND => (crate::addons::color::nearest::KIND, crate::addons::color::nearest::fields()), crate::addons::color::legend::COMMAND => (crate::addons::color::legend::KIND, crate::addons::color::legend::fields()), crate::addons::typetools::COMMAND => (crate::addons::typetools::KIND, crate::addons::typetools::fields(app)), + crate::addons::select::COMMAND => (crate::addons::select::KIND, crate::addons::select::fields(app)), "artboard.rearrange" => (crate::dialogs::rearrange_artboards::KIND, crate::dialogs::rearrange_artboards::open_fields(app)), _ => return None, })