diff --git a/ROADMAP.md b/ROADMAP.md index fc5ae239..56de4972 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,7 +33,7 @@ since 2026-10-01. | Dimension | Estimate | Evidence and what's missing | |---|---|---| -| **Breadth:** menus, tools and panels exist | ~90% | 16 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | +| **Breadth:** menus, tools and panels exist | ~90% | 15 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | | **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) | | **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku and tate-chu-yoko; ruby, mojikumi and vertical metrics open), Variables (data merge), scripting | | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | @@ -103,6 +103,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. - **Tools:** - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. + - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). @@ -211,7 +212,7 @@ With 4–6 agents working on disjoint crates (as the layering allows) the wall-c (integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity, **~80–145 h** to full parity. -_Inventories (2026-10-05):_ 16 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool +_Inventories (2026-10-07):_ 15 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool implemented except Touch Type; 51 panel modules; Illustrator-style live effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3fad4703..ef1d810e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -339,6 +339,14 @@ pub struct SavedView { pub rotation: f64, } +/// A saved selection (Select → Save Selection…): the objects that were selected, listed by name at +/// the bottom of the Select menu. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SavedSelection { + pub name: String, + pub objects: Vec, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Guide { /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). @@ -490,6 +498,9 @@ pub struct Document { /// View → New View… (up to 25, like Illustrator). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub views: Vec, + /// Select → Save Selection… (up to 25 in the engine); saved with the document. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub saved_selections: Vec, #[serde(default)] pub grid: GridPrefs, #[serde(default = "ppi72")] @@ -616,6 +627,7 @@ impl Document { symbols: vec![], guides: vec![], views: vec![], + saved_selections: vec![], grid: GridPrefs::default(), raster_effects_ppi: 72.0, images: BTreeMap::new(), diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 8bfd15f3..712c0964 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -6,7 +6,7 @@ use std::collections::BTreeSet; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, TextKind}; +use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection, TextKind}; use super::edit::selected_roots; use super::*; @@ -93,15 +93,30 @@ pub fn specs() -> Vec { "Save Selection…", ["Select"], None, - "{name?} remember the current selection for this session (default name \"Selection N\") → {name}", + "{name?} save the current selection under a name, in the document (default \"Selection N\"; an existing name is replaced; at most 25) → {name}", has_selection, save_selection ), - cmd!("select.recall", "Recall Selection", ["Select"], None, "{name} → {count}", has_doc, recall_selection), - cmd!("select.editSaved", "Edit Selection…", ["Select"], None, "{name, newName?: rename, delete?: bool}", has_doc, edit_saved), + cmd!( + "select.recall", + "Recall Selection", + ["Select"], + None, + "{name} select the objects of a saved selection (those since deleted are left out) → {count}", + has_saved_selection, + recall_selection + ), + cmd!( + "select.editSaved", + "Edit Selection…", + ["Select"], + None, + "{name, newName?: rename, delete?: bool} | {edits: [{name, newName?, delete?}…]} rename or delete saved selections, all in one undo step; names are those before the edit and must stay unique", + has_saved_selection, + edit_saved + ), cmd!(query "select.savedList", "Saved Selections", [], None, "{} → [name…] for the active document", has_doc, |s, _| { - let t = s.doc()?.title(); - Ok(json!(s.menu.saved_selections.iter().filter(|x| x.0 == t).map(|x| x.1.clone()).collect::>())) + Ok(json!(s.doc()?.doc.saved_selections.iter().map(|x| x.name.clone()).collect::>())) }), cmd!( "view.guides.make", @@ -258,44 +273,80 @@ fn direction_handles(s: &mut Session, _: &Value) -> Result { Ok(json!({ "anchors": total })) } +/// Illustrator-style saved selections per document, as many as the Select menu lists. +pub const MAX_SAVED_SELECTIONS: usize = 25; + fn save_selection(s: &mut Session, p: &Value) -> Result { + const C: &str = "select.save"; let st = s.doc()?; - let title = st.title(); let ids = st.selection.objects.clone(); - let name = match str_param(p, "name").filter(|n| !n.trim().is_empty()) { - Some(n) => n.trim().to_string(), - None => { - let mut i = 1; - while s.menu.saved_selections.iter().any(|x| x.0 == title && x.1 == format!("Selection {i}")) { - i += 1; - } - format!("Selection {i}") - } + let saved = &st.doc.saved_selections; + let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + Some(n) => n.to_string(), + None => (1..).map(|i| format!("Selection {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default(), }; - s.menu.saved_selections.retain(|x| !(x.0 == title && x.1 == name)); - s.menu.saved_selections.push((title, name.clone(), ids)); + let existing = saved.iter().position(|x| x.name == name); + if existing.is_none() && saved.len() >= MAX_SAVED_SELECTIONS { + return Err(bad(C, format!("a document keeps at most {MAX_SAVED_SELECTIONS} saved selections"))); + } + s.edit("Save Selection", |d, _| { + let entry = SavedSelection { name: name.clone(), objects: ids }; + match existing.and_then(|i| d.saved_selections.get_mut(i)) { + Some(x) => *x = entry, + None => d.saved_selections.push(entry), + } + Ok(()) + })?; Ok(json!({ "name": name })) } -fn saved_index(s: &Session, p: &Value, cmd: &str) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?; - let t = s.doc()?.title(); - s.menu.saved_selections.iter().position(|x| x.0 == t && x.1 == name).ok_or_else(|| bad(cmd, format!("no saved selection `{name}`"))) -} - fn recall_selection(s: &mut Session, p: &Value) -> Result { - let i = saved_index(s, p, "select.recall")?; - let ids = s.menu.saved_selections[i].2.clone(); + const C: &str = "select.recall"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?; + let ids = s.doc()?.doc.saved_selections.iter().find(|x| x.name == name).map(|x| x.objects.clone()); + let ids = ids.ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?; s.select(|d, sel| sel.set(ids.into_iter().filter(|id| d.node(*id).is_some())))?; Ok(json!({ "count": s.doc()?.selection.len() })) } +/// Recall and Edit Selection… need a document with at least one saved selection. +fn has_saved_selection(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.doc.saved_selections.is_empty() { Err("no saved selections".into()) } else { Ok(()) } +} + fn edit_saved(s: &mut Session, p: &Value) -> Result { - let i = saved_index(s, p, "select.editSaved")?; - if bool_or(p, "delete", false) { - s.menu.saved_selections.remove(i); - } else if let Some(n) = str_param(p, "newName").filter(|n| !n.trim().is_empty()) { - s.menu.saved_selections[i].1 = n.trim().to_string(); + const C: &str = "select.editSaved"; + let edits = match p.get("edits").and_then(Value::as_array) { + Some(list) => list.as_slice(), + None => std::slice::from_ref(p), + }; + let before = s.doc()?.doc.saved_selections.clone(); + // Edits name the selections as they are now, so they don't depend on each other's order. + let mut after: Vec> = before.iter().cloned().map(Some).collect(); + for e in edits { + let name = str_param(e, "name").ok_or_else(|| bad(C, "missing name"))?; + let i = before.iter().position(|x| x.name == name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?; + let Some(slot) = after.get_mut(i) else { continue }; + if bool_or(e, "delete", false) { + *slot = None; + } else if let Some(n) = str_param(e, "newName").map(str::trim).filter(|n| !n.is_empty()) + && let Some(x) = slot + { + x.name = n.to_string(); + } + } + let after: Vec = after.into_iter().flatten().collect(); + for (i, x) in after.iter().enumerate() { + if after.iter().take(i).any(|y| y.name == x.name) { + return Err(bad(C, format!("two saved selections would be named `{}`", x.name))); + } + } + if after != before { + s.edit("Edit Selection", |d, _| { + d.saved_selections = after; + Ok(()) + })?; } ok() } diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index cdf6f155..e68bb84e 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -18,8 +18,6 @@ use super::*; /// Session-level (not saved) state owned by the menu commands. #[derive(Clone, Debug, Default)] pub struct MenuState { - /// Saved selections: (document title, name, object ids). - pub saved_selections: Vec<(String, String, Vec)>, /// View → Guides → Lock Guides. pub guides_locked: bool, /// Transparency panel menu: "New Opacity Masks Are Clipping" turned off. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d61365d6..3cfd41b5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,6 +15,7 @@ mod cut; pub(crate) mod distortcmds; mod docinfo; mod docmenu; +pub use docmenu::MAX_SAVED_SELECTIONS; pub(crate) mod docsetup; mod draw2; mod edit; diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index aa5e9440..2ed16809 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -526,6 +526,92 @@ fn save_and_recall_selection() { assert!(s.execute("select.recall", &json!({"name": "Pair"})).is_err()); } +#[test] +fn edit_selection_needs_a_saved_selection_and_a_free_name() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a]); + // Edit Selection… is off until something is saved. + assert!(s.execute("select.editSaved", &json!({"name": "Selection 1", "newName": "A"})).is_err()); + s.execute("select.save", &json!({"name": "One"})).unwrap(); + s.execute("select.save", &json!({"name": "Two"})).unwrap(); + // A name already saved can't be taken; both selections keep theirs. + assert!(s.execute("select.editSaved", &json!({"name": "One", "newName": " Two "})).is_err()); + assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["One", "Two"])); + // Renaming to its own name, or a blank name, changes nothing; a free name renames. + s.execute("select.editSaved", &json!({"name": "One", "newName": "One"})).unwrap(); + s.execute("select.editSaved", &json!({"name": "One", "newName": " "})).unwrap(); + s.execute("select.editSaved", &json!({"name": "One", "newName": " Uno "})).unwrap(); + assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["Uno", "Two"])); +} + +#[test] +fn saved_selections_live_in_the_document() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + let list = |s: &mut Session| s.execute("select.savedList", &json!({})).unwrap(); + sel(&mut s, &[a]); + s.execute("select.save", &json!({"name": "A"})).unwrap(); + sel(&mut s, &[a, b]); + s.execute("select.save", &json!({"name": "Both"})).unwrap(); + // They travel with the file. + let loaded = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap(); + assert_eq!(loaded.saved_selections.iter().map(|x| x.name.as_str()).collect::>(), ["A", "Both"]); + // Saving is an undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(list(&mut s), json!(["A"])); + // An existing name is replaced by the current selection. + sel(&mut s, &[b]); + s.execute("select.save", &json!({"name": "A"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.execute("select.recall", &json!({"name": "A"})).unwrap()["count"], 1); + assert_eq!(selected(&s), vec![b]); + // Objects deleted since are left out of a recall. + sel(&mut s, &[a, b]); + s.execute("select.save", &json!({"name": "Pair"})).unwrap(); + sel(&mut s, &[a]); + s.execute("edit.clear", &json!({})).unwrap(); + assert_eq!(s.execute("select.recall", &json!({"name": "Pair"})).unwrap()["count"], 1); +} + +#[test] +fn edit_selection_applies_several_edits_as_one_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a]); + for n in ["A", "B", "C"] { + s.execute("select.save", &json!({ "name": n })).unwrap(); + } + let list = |s: &mut Session| s.execute("select.savedList", &json!({})).unwrap(); + // Edits name the selections as they are, so two can swap names; a delete goes with them. + s.execute("select.editSaved", &json!({"edits": [{"name": "A", "newName": "B"}, {"name": "B", "newName": "A"}, {"name": "C", "delete": true}]})) + .unwrap(); + assert_eq!(list(&mut s), json!(["B", "A"])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(list(&mut s), json!(["A", "B", "C"])); + // Two ending with one name, or an unknown name, change nothing. + assert!(s.execute("select.editSaved", &json!({"edits": [{"name": "A", "newName": "B"}]})).is_err()); + assert!(s.execute("select.editSaved", &json!({"edits": [{"name": "Z", "delete": true}]})).is_err()); + assert_eq!(list(&mut s), json!(["A", "B", "C"])); +} + +#[test] +fn a_document_keeps_at_most_25_saved_selections() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a]); + for i in 0..25 { + s.execute("select.save", &json!({ "name": format!("S{i}") })).unwrap(); + } + assert!(s.execute("select.save", &json!({"name": "One too many"})).is_err()); + // A name already saved is replaced even when the list is full. + s.execute("select.save", &json!({"name": "S3"})).unwrap(); + // Unnamed saves take the first free "Selection N". + s.execute("select.editSaved", &json!({"name": "S0", "delete": true})).unwrap(); + assert_eq!(s.execute("select.save", &json!({})).unwrap()["name"], "Selection 1"); +} + // ---------- Type ---------- #[test] diff --git a/crates/ui-egui/src/dialogs/edit_selection.rs b/crates/ui-egui/src/dialogs/edit_selection.rs new file mode 100644 index 00000000..7a8cdbe1 --- /dev/null +++ b/crates/ui-egui/src/dialogs/edit_selection.rs @@ -0,0 +1,184 @@ +//! Select → Edit Selection…: the document's saved selections in a list, with the chosen one's name +//! to edit and Delete. Nothing changes until OK, which applies every rename and delete as one +//! undo step (`select.editSaved` `edits`); Cancel leaves them as they were. +//! +//! Fields: `count` and, for each saved selection `i`, `orig` (its name when the dialog opened), +//! `name` (as edited) and `deleted`; `selected` (the row picked). + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::MAX_SAVED_SELECTIONS; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Edit Selection. +pub const KIND: &str = "editSelection"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Edit Selection").into(), body, confirm, min_width: 420.0, ..DialogSpec::FORM }; + +/// Open Edit Selection on the active document's saved selections. +pub fn open(app: &mut VectorcraftApp) -> Result { + let names = app.run("select.savedList", json!({}))?; + let names: Vec<&str> = names.as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + if names.is_empty() { + return Err("No saved selections (Select → Save Selection…)".into()); + } + let mut fields = json!({ "count": names.len(), "selected": 0 }); + for (i, n) in names.iter().enumerate() { + fields[format!("orig{i}")] = json!(n); + fields[format!("name{i}")] = json!(n); + } + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn count(d: &Dialog) -> usize { + d.fields.get("count").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(0).min(MAX_SAVED_SELECTIONS) +} + +/// The rows still in the list (not deleted). +fn rows(d: &Dialog) -> Vec { + (0..count(d)).filter(|i| !d.bool(&format!("deleted{i}"))).collect() +} + +fn selected(d: &Dialog, rows: &[usize]) -> Option { + let want = d.fields.get("selected").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()); + want.filter(|w| rows.contains(w)).or_else(|| rows.first().copied()) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let rows = rows(d); + let current = selected(d, &rows); + let mut pick = None; + let mut delete = false; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(200.0); + widgets::dim_label(ui, tl!("Saved Selections")); + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().id_salt("saved-selections").min_scrolled_height(180.0).max_height(180.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_min_height(180.0); + for &i in &rows { + if ui.selectable_label(Some(i) == current, d.str(&format!("name{i}"))).clicked() { + pick = Some(i); + } + } + }); + }); + }); + ui.add_space(16.0); + ui.vertical(|ui| { + ui.set_width(190.0); + if let Some(i) = current { + widgets::dim_label(ui, tl!("Name:")); + form::text(ui, d, &format!("name{i}"), 190.0); + ui.add_space(10.0); + delete = widgets::flat_button(ui, tl!("Delete"), 70.0).clicked(); + } + }); + }); + if let Some(i) = pick { + d.fields.insert("selected".into(), json!(i)); + } + if let (true, Some(i)) = (delete, current) { + d.fields.insert(format!("deleted{i}"), json!(true)); + // The row below takes its place (the one above, for the last). + let next = rows.iter().copied().find(|r| *r > i).or_else(|| rows.iter().copied().rfind(|r| *r < i)); + d.fields.insert("selected".into(), json!(next.unwrap_or(0))); + } + false +} + +/// The edits OK applies: each deleted selection, and each one whose name was changed. +fn edits(d: &Dialog) -> Vec { + (0..count(d)) + .filter_map(|i| { + let (orig, name) = (d.str(&format!("orig{i}")), d.str(&format!("name{i}"))); + if d.bool(&format!("deleted{i}")) { + Some(json!({ "name": orig, "delete": true })) + } else if !name.trim().is_empty() && name.trim() != orig { + Some(json!({ "name": orig, "newName": name.trim() })) + } else { + None + } + }) + .collect() +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let edits = edits(d); + // A name two selections would share keeps the dialog open, to be changed. + let r = if edits.is_empty() { Ok(Value::Null) } else { app.run("select.editSaved", json!({ "edits": edits })) }; + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::VectorcraftApp; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + for n in ["Logo", "Icons", "Text"] { + app.run("select.save", json!({ "name": n })).unwrap(); + } + app + } + + fn set(app: &mut VectorcraftApp, k: &str, v: serde_json::Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + } + + fn names(app: &mut VectorcraftApp) -> serde_json::Value { + app.run("select.savedList", json!({})).unwrap() + } + + #[test] + fn nothing_changes_until_ok_and_all_edits_are_one_undo_step() { + let mut app = app(); + super::open(&mut app).unwrap(); + // Rename the first and delete the last. + set(&mut app, "name0", json!(" Brand ")); + set(&mut app, "deleted2", json!(true)); + assert_eq!(names(&mut app), json!(["Logo", "Icons", "Text"])); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(names(&mut app), json!(["Brand", "Icons"])); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(names(&mut app), json!(["Logo", "Icons", "Text"])); + } + + #[test] + fn cancel_leaves_them_and_a_shared_name_keeps_the_dialog_open() { + let mut app = app(); + super::open(&mut app).unwrap(); + set(&mut app, "name0", json!("Gone")); + super::super::cancel(&mut app); + assert_eq!(names(&mut app), json!(["Logo", "Icons", "Text"])); + super::open(&mut app).unwrap(); + set(&mut app, "name0", json!("Text")); + assert!(super::super::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + assert_eq!(names(&mut app), json!(["Logo", "Icons", "Text"])); + // Deleting the other one frees the name. + set(&mut app, "deleted2", json!(true)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(names(&mut app), json!(["Text", "Icons"])); + } + + #[test] + fn it_needs_a_saved_selection() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + assert!(super::open(&mut app).is_err()); + assert!(app.ui.dialog.is_none()); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 66ef5113..8bdee4ff 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -18,6 +18,7 @@ pub mod confirm; mod document_setup; pub mod dxf_import; pub mod dxf_options; +pub mod edit_selection; mod effect; pub mod envelope; pub mod eps_options; @@ -266,6 +267,7 @@ registry! { PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC, PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC, BlendOptions: [blend_options::KIND] => blend_options::SPEC, + EditSelection: [edit_selection::KIND] => edit_selection::SPEC, PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC, } diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 77ef28c4..bf8d004f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -725,6 +725,7 @@ Edit Pattern 編輯圖樣 Edit PDF Preset 編輯 PDF 預設集 Edit Print Preset 編輯列印預設集 + Edit Selection 編輯選取範圍 Edit Selection… 編輯選取範圍… Edit Symbol 編輯符號 Edit Text 編輯文字 @@ -2130,6 +2131,31 @@ SAVED 已儲存 Saved 已儲存 Saved recovery data for {title} 已儲存 {title} 的復原資料 + Saved Selection 1 已儲存的選取範圍 1 + Saved Selection 10 已儲存的選取範圍 10 + Saved Selection 11 已儲存的選取範圍 11 + Saved Selection 12 已儲存的選取範圍 12 + Saved Selection 13 已儲存的選取範圍 13 + Saved Selection 14 已儲存的選取範圍 14 + Saved Selection 15 已儲存的選取範圍 15 + Saved Selection 16 已儲存的選取範圍 16 + Saved Selection 17 已儲存的選取範圍 17 + Saved Selection 18 已儲存的選取範圍 18 + Saved Selection 19 已儲存的選取範圍 19 + Saved Selection 2 已儲存的選取範圍 2 + Saved Selection 20 已儲存的選取範圍 20 + Saved Selection 21 已儲存的選取範圍 21 + Saved Selection 22 已儲存的選取範圍 22 + Saved Selection 23 已儲存的選取範圍 23 + Saved Selection 24 已儲存的選取範圍 24 + Saved Selection 25 已儲存的選取範圍 25 + Saved Selection 3 已儲存的選取範圍 3 + Saved Selection 4 已儲存的選取範圍 4 + Saved Selection 5 已儲存的選取範圍 5 + Saved Selection 6 已儲存的選取範圍 6 + Saved Selection 7 已儲存的選取範圍 7 + Saved Selection 8 已儲存的選取範圍 8 + Saved Selection 9 已儲存的選取範圍 9 Saved Selections 已儲存的選取範圍 Saved to User Defined: {} 已儲存至使用者自訂:{} Saved View 1 已儲存的檢視 1 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e1c306ff..7baedef9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -89,6 +89,31 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.goto8", "Saved View 8", "", "{} go to the 8. saved view"), ("view.goto9", "Saved View 9", "", "{} go to the 9. saved view"), ("view.goto10", "Saved View 10", "", "{} go to the 10. saved view"), + ("select.recall1", "Saved Selection 1", "", "{} select the 1. saved selection"), + ("select.recall2", "Saved Selection 2", "", "{} select the 2. saved selection"), + ("select.recall3", "Saved Selection 3", "", "{} select the 3. saved selection"), + ("select.recall4", "Saved Selection 4", "", "{} select the 4. saved selection"), + ("select.recall5", "Saved Selection 5", "", "{} select the 5. saved selection"), + ("select.recall6", "Saved Selection 6", "", "{} select the 6. saved selection"), + ("select.recall7", "Saved Selection 7", "", "{} select the 7. saved selection"), + ("select.recall8", "Saved Selection 8", "", "{} select the 8. saved selection"), + ("select.recall9", "Saved Selection 9", "", "{} select the 9. saved selection"), + ("select.recall10", "Saved Selection 10", "", "{} select the 10. saved selection"), + ("select.recall11", "Saved Selection 11", "", "{} select the 11. saved selection"), + ("select.recall12", "Saved Selection 12", "", "{} select the 12. saved selection"), + ("select.recall13", "Saved Selection 13", "", "{} select the 13. saved selection"), + ("select.recall14", "Saved Selection 14", "", "{} select the 14. saved selection"), + ("select.recall15", "Saved Selection 15", "", "{} select the 15. saved selection"), + ("select.recall16", "Saved Selection 16", "", "{} select the 16. saved selection"), + ("select.recall17", "Saved Selection 17", "", "{} select the 17. saved selection"), + ("select.recall18", "Saved Selection 18", "", "{} select the 18. saved selection"), + ("select.recall19", "Saved Selection 19", "", "{} select the 19. saved selection"), + ("select.recall20", "Saved Selection 20", "", "{} select the 20. saved selection"), + ("select.recall21", "Saved Selection 21", "", "{} select the 21. saved selection"), + ("select.recall22", "Saved Selection 22", "", "{} select the 22. saved selection"), + ("select.recall23", "Saved Selection 23", "", "{} select the 23. saved selection"), + ("select.recall24", "Saved Selection 24", "", "{} select the 24. saved selection"), + ("select.recall25", "Saved Selection 25", "", "{} select the 25. saved selection"), ("type.recentFont1", "Recent Font 1", "", "{} apply the 1. most recently used font"), ("type.recentFont2", "Recent Font 2", "", "{} apply the 2. most recently used font"), ("type.recentFont3", "Recent Font 3", "", "{} apply the 3. most recently used font"), @@ -803,6 +828,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("no such view".into()), } } + id if saved_selection_slot(id).is_some() => match saved_selection_name(app, id) { + Some(name) => app.run("select.recall", json!({ "name": name })), + None => Err("no such saved selection".into()), + }, "view.snapToPixel" => flag(&mut app.ui.view.snap_to_pixel), "view.textThreads" => flag(&mut app.ui.view.text_threads), "type.hiddenCharacters" => flag(&mut app.ui.view.hidden_chars), @@ -1247,6 +1276,7 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { let n: usize = id["view.goto".len()..].parse().unwrap_or(0); app.session.active().and_then(|d| n.checked_sub(1).and_then(|i| d.doc.views.get(i)).map(|v| v.name.clone())).unwrap_or_else(|| "—".into()) } + id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).unwrap_or_else(|| "—".into()), "view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(), "view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(), "view.boundingBox" => if v.bounding_box { "Hide Bounding Box" } else { "Show Bounding Box" }.into(), @@ -1304,6 +1334,7 @@ fn shows_a_name(id: &str, label: &str) -> bool { crate::dialogs::perspective_presets::SLOT, ]; SLOTS.iter().any(|s| id.starts_with(s)) + || saved_selection_slot(id).is_some() || matches!(id, "text.setStyle" | "plugin.dialog") || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) @@ -1347,6 +1378,7 @@ pub fn display_label(app: &VectorcraftApp, id: &str, label: &str) -> String { /// User Defined swatch and graphic style libraries). fn hidden_when_disabled(id: &str) -> bool { id.starts_with("view.goto") + || saved_selection_slot(id).is_some() || id.starts_with("file.openRecent") || id.starts_with(crate::panels::swatches::USER_SLOT) || id.starts_with(crate::panels::graphic_styles::USER_SLOT) @@ -1398,6 +1430,12 @@ const RECENT_IDS: [&str; io::MAX_RECENT_FILES] = [ "file.openRecent30", ]; +/// The saved selection a `select.recallN` slot names (none past the document's last). +fn saved_selection_name(app: &VectorcraftApp, id: &str) -> Option { + let n = saved_selection_slot(id)?.checked_sub(1)?; + app.session.active()?.doc.saved_selections.get(n).map(|x| x.name.clone()) +} + /// The recent file a `file.openRecentN` slot names (none past the preference's count). fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a String> { let n: usize = id.strip_prefix("file.openRecent")?.parse().ok()?; @@ -1417,6 +1455,7 @@ pub fn listed_slots(app: &VectorcraftApp) -> usize { let user = |libs: Vec| libs.iter().filter(|l| l.category == "user").count().min(10); let views = app.session.active().map_or(0, |d| d.doc.views.len().min(10)); views + + app.session.active().map_or(0, |d| d.doc.saved_selections.len().min(SAVED_SELECTION_IDS.len())) + io::recent_files(app).len().min(RECENT_IDS.len()) + user(swatchlib::libraries(&app.session)) + user(stylelib::libraries(&app.session)) @@ -1454,6 +1493,40 @@ const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] ], ]; +/// Select → saved selections: the n-th saved selection of the active document. +const SAVED_SELECTION_IDS: [&str; 25] = [ + "select.recall1", + "select.recall2", + "select.recall3", + "select.recall4", + "select.recall5", + "select.recall6", + "select.recall7", + "select.recall8", + "select.recall9", + "select.recall10", + "select.recall11", + "select.recall12", + "select.recall13", + "select.recall14", + "select.recall15", + "select.recall16", + "select.recall17", + "select.recall18", + "select.recall19", + "select.recall20", + "select.recall21", + "select.recall22", + "select.recall23", + "select.recall24", + "select.recall25", +]; + +/// The 1-based slot a `select.recallN` id stands for. +fn saved_selection_slot(id: &str) -> Option { + id.strip_prefix("select.recall")?.parse().ok() +} + /// Type → Recent Fonts slots. const RECENT_FONT_IDS: [&str; 10] = [ "type.recentFont1", @@ -1513,6 +1586,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { id if id.starts_with("type.recentFont") => { id["type.recentFont".len()..].parse::().is_ok_and(|n| n >= 1 && n <= app.ui.recent_fonts.len()) && app.session.active().is_some() } + id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).is_some(), id if id.starts_with("view.goto") => { id["view.goto".len()..].parse::().is_ok_and(|n| n >= 1 && app.session.active().is_some_and(|d| n <= d.doc.views.len())) } @@ -1956,9 +2030,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub("Type Orientation", vec![c("Horizontal", "type.orientation.horizontal"), c("Vertical", "type.orientation.vertical")]), ], ), - ( - "Select", - vec![ + ("Select", { + let mut v = vec![ c("All", "select.all"), c("All on Active Artboard", "select.allOnArtboard"), c("Deselect", "select.none"), @@ -2013,9 +2086,12 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { todo("Start Global Edit"), Sep, c("Save Selection…", "select.save"), - todo("Edit Selection…"), - ], - ), + c("Edit Selection…", "select.editSaved"), + Sep, + ]; + v.extend(SAVED_SELECTION_IDS.iter().map(|id| c("Saved Selection", id))); + v + }), ("Effect", effect_menu()), ( "View", @@ -2527,6 +2603,21 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } return; } + // Save Selection…: a name dialog, starting from the first free "Selection N". + if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) { + let taken = app.session.execute("select.savedList", &json!({})).ok().unwrap_or_default(); + let taken: Vec<&str> = taken.as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + let name = (1..).map(|i| format!("Selection {i}")).find(|n| !taken.contains(&n.as_str())).unwrap_or_default(); + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Save Selection", "params": {"name": name}})); + return; + } + // Edit Selection…: the saved selections in a list, to rename or delete. + if id == "select.editSaved" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::edit_selection::open(app) { + app.status(e); + } + return; + } // Document Raster Effects Settings and File Info: their dialogs. if matches!(id, "document.rasterEffectsSettings" | "file.info") && p.as_object().is_none_or(|o| o.is_empty()) { let r = if id == "file.info" { crate::dialogs::file_info::open(app) } else { crate::dialogs::raster_effects::open(app) }; @@ -3015,6 +3106,77 @@ mod tests { assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("view.goto2"))); } + fn app_with_a_rectangle() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app + } + + #[test] + fn save_selection_asks_for_a_name() { + let mut app = app_with_a_rectangle(); + invoke(&mut app, "select.save", json!({})); + let d = app.ui.dialog.clone().expect("Save Selection dialog"); + assert_eq!( + (d.kind.as_str(), d.str("__command"), d.str("__label"), d.str("name")), + ("command", "select.save".into(), "Save Selection".into(), "Selection 1".into()) + ); + // OK saves under the name typed. + let mut d = d; + d.fields.insert("name".into(), json!("Logo")); + app.ui.dialog = Some(d); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.run("select.savedList", json!({})).unwrap(), json!(["Logo"])); + // The next one starts from the first free name. + invoke(&mut app, "select.save", json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("name")), Some("Selection 1".into())); + } + + #[test] + fn saved_selections_are_listed_at_the_bottom_of_the_select_menu() { + let mut app = app_with_a_rectangle(); + app.run("shape.ellipse", json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap(); + // Nothing saved: no slot is listed or enabled. + assert!(!enabled(&app, "select.recall1")); + assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall1"))); + app.run("select.save", json!({"name": "Ellipse"})).unwrap(); + app.run("select.none", json!({})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("select.save", json!({"name": "Everything"})).unwrap(); + assert!(enabled(&app, "select.recall1") && enabled(&app, "select.recall2") && !enabled(&app, "select.recall3")); + assert_eq!(dynamic_label(&app, "select.recall2", "Saved Selection"), "Everything"); + // A name is shown as it is, not translated. + assert_eq!(display_label(&app, "select.recall2", "Saved Selection"), "Everything"); + assert!(menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall2"))); + assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("select.recall3"))); + // Choosing one selects its objects again. + app.run("select.none", json!({})).unwrap(); + let r = app.run("select.recall2", json!({})).unwrap(); + assert!(r["count"].as_u64().unwrap() >= 1); + assert_eq!(app.session.active().unwrap().selection.len(), 2); + // The native menu is rebuilt when the number of slots changes. + let listed = listed_slots(&app); + app.run("select.editSaved", json!({"name": "Ellipse", "delete": true})).unwrap(); + assert_eq!(listed_slots(&app), listed - 1); + assert!(!enabled(&app, "select.recall2")); + } + + #[test] + fn edit_selection_opens_its_dialog_on_the_saved_selections() { + let mut app = app_with_a_rectangle(); + // Nothing saved yet: the item is off, and invoking it (palette, shortcut) only says so. + assert!(!enabled(&app, "select.editSaved")); + invoke(&mut app, "select.editSaved", json!({})); + assert!(app.ui.dialog.is_none()); + assert!(app.ui.status.contains("No saved selections"), "{}", app.ui.status); + app.run("select.save", json!({"name": "Logo"})).unwrap(); + assert!(enabled(&app, "select.editSaved")); + invoke(&mut app, "select.editSaved", json!({})); + let d = app.ui.dialog.clone().expect("Edit Selection dialog"); + assert_eq!((d.kind.as_str(), d.str("orig0"), d.str("name0")), (crate::dialogs::edit_selection::KIND, "Logo".into(), "Logo".into())); + } + #[test] fn recent_fonts_and_corner_widget_toggle() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());