Select by Artboards dialog: artboard list, search and multi-select (#5548)

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 <noreply@anthropic.com>
This commit is contained in:
ruchirguitar
2026-11-04 22:57:57 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 7e016a0bcc
commit 3a50b20b95
5 changed files with 241 additions and 0 deletions
+2
View File
@@ -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> {
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,
+168
View File
@@ -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<Item> {
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<usize> {
let mut v: Vec<usize> =
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<usize> {
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<usize>, anchor: Option<usize>, i: usize, shown: &[usize], shift: bool, command: bool) -> Option<usize> {
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<String> = 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<Value, String> {
app.ui.dialog = None;
app.run(COMMAND, form::params(d))
}
#[cfg(test)]
mod tests;
+69
View File
@@ -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<String> {
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::<usize>::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)]);
}
+1
View File
@@ -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),
}
+1
View File
@@ -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,
})