mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 17:47:43 +00:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
7e016a0bcc
commit
3a50b20b95
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user