Select: saved selections checked when a file opens, names capped, docs (review of #318)

A file's saved selections are untrusted: on load at most 25 are kept, names
are trimmed, capped at 255 characters and unique, and ids the document
doesn't have are dropped (after fix_next_id such an id would select whatever
object is drawn next). Save and Edit Selection cap names the same way. The
limit, the name cleanup and the default "Selection N" live on
SavedSelection, shared by the engine, the Save Selection dialog and the
Select menu's slot table. docs/mcp.md documents the select.save /
savedList / recall / editSaved commands; ROADMAP counts 13 stubbed menu items
and names what's still missing (Update Selection, the Layers panel menu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 02:03:26 +07:00
parent 87d1acd589
commit 249b34fc7c
10 changed files with 118 additions and 23 deletions

View File

@ -33,7 +33,7 @@ since 2026-10-01.
| Dimension | Estimate | Evidence and what's missing |
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 15 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
| **Breadth:** menus, tools and panels exist | ~90% | 13 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, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional 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 |
@ -105,7 +105,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. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- **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.
- **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. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
@ -217,7 +217,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-07):_ 15 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
_Inventories (2026-10-07):_ 13 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.

View File

@ -347,6 +347,24 @@ pub struct SavedSelection {
pub objects: Vec<NodeId>,
}
impl SavedSelection {
/// Most saved selections a document keeps (the Select menu lists every one).
pub const MAX: usize = 25;
/// Longest name, in characters.
pub const MAX_NAME: usize = 255;
/// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank).
pub fn clean_name(name: &str) -> Option<String> {
let name: String = name.trim().chars().take(Self::MAX_NAME).collect();
(!name.is_empty()).then_some(name)
}
/// The first "Selection N" none of `saved` is named.
pub fn default_name(saved: &[SavedSelection]) -> String {
(1..=saved.len() + 1).map(|i| format!("Selection {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default()
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Guide {
/// true = vertical guide at `pos` (x), false = horizontal at `pos` (y).
@ -498,7 +516,7 @@ pub struct Document {
/// View → New View… (up to 25, like Illustrator).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub views: Vec<SavedView>,
/// Select → Save Selection… (up to 25 in the engine); saved with the document.
/// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub saved_selections: Vec<SavedSelection>,
#[serde(default)]
@ -929,6 +947,33 @@ impl Document {
}
impl Document {
/// Saved selections as a file gives them, made safe to list and recall: at most
/// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document
/// has, each once. (An id it doesn't have would select whatever object takes that id later.)
pub fn tidy_saved_selections(&mut self) {
if self.saved_selections.is_empty() {
return;
}
let mut ids = std::collections::HashSet::new();
self.walk(|n| {
ids.insert(n.id);
});
let mut kept: Vec<SavedSelection> = vec![];
for s in std::mem::take(&mut self.saved_selections) {
if kept.len() >= SavedSelection::MAX {
break;
}
let Some(name) = SavedSelection::clean_name(&s.name) else { continue };
if kept.iter().any(|k| k.name == name) {
continue;
}
let mut seen = std::collections::HashSet::new();
let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect();
kept.push(SavedSelection { name, objects });
}
self.saved_selections = kept;
}
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
/// mask already holds (the engine syncs it after every edit).
pub fn drop_edit_modes(&mut self) {

View File

@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Selection…",
["Select"],
None,
"{name?} save the current selection under a name, in the document (default \"Selection N\"; an existing name is replaced; at most 25) → {name}",
"{name?} save the current selection under a name, in the document (default \"Selection N\"; an existing name is replaced; at most 25, names up to 255 characters) → {name}",
has_selection,
save_selection
),
@ -273,21 +273,15 @@ fn direction_handles(s: &mut Session, _: &Value) -> Result<Value> {
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<Value> {
const C: &str = "select.save";
let st = s.doc()?;
let ids = st.selection.objects.clone();
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(),
};
let name = str_param(p, "name").and_then(SavedSelection::clean_name).unwrap_or_else(|| SavedSelection::default_name(saved));
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")));
if existing.is_none() && saved.len() >= SavedSelection::MAX {
return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX)));
}
s.edit("Save Selection", |d, _| {
let entry = SavedSelection { name: name.clone(), objects: ids };
@ -335,10 +329,10 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
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())
} else if let Some(n) = str_param(e, "newName").and_then(SavedSelection::clean_name)
&& let Some(x) = slot
{
x.name = n.to_string();
x.name = n;
}
}
let after: Vec<SavedSelection> = after.into_iter().flatten().collect();

View File

@ -15,7 +15,6 @@ mod cut;
pub(crate) mod distortcmds;
mod docinfo;
mod docmenu;
pub use docmenu::MAX_SAVED_SELECTIONS;
pub(crate) mod docsetup;
mod draw2;
mod edit;

View File

@ -793,3 +793,16 @@ fn every_new_command_has_params_doc_and_menu() {
assert!(!c.params.is_empty() && !c.menu.is_empty(), "{id}");
}
}
#[test]
fn saved_selection_names_are_trimmed_and_capped() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a]);
let long = "n".repeat(1000);
let name = s.execute("select.save", &json!({ "name": format!(" {long} ") })).unwrap()["name"].clone();
assert_eq!(name.as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
s.execute("select.editSaved", &json!({"name": name, "newName": format!("{long}é")})).unwrap();
let list = s.execute("select.savedList", &json!({})).unwrap();
assert_eq!(list[0].as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
}

View File

@ -380,6 +380,8 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
// Saved before v3: the assigned profiles move out of `unknown`.
doc.migrate_color_profiles();
doc.fix_next_id();
// Untrusted: capped, named and pointing at objects the file has.
doc.tidy_saved_selections();
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, base64_decode(&icc.data)?))).collect();
Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
}

View File

@ -203,3 +203,35 @@ fn bug_f64_bit_exact_roundtrip() {
d.raster_effects_ppi = 0.1 + 0.2;
check_native_roundtrip_exact(&d).unwrap();
}
/// Saved selections come from the file: older files have none, and a hostile one is cut down to
/// what the Select menu can list, with names that fit and ids the document has.
#[test]
fn saved_selections_from_a_file_are_tidied() {
let d = rich_doc();
let mut v: Value = serde_json::from_slice(&save(&d, false)).unwrap();
assert!(v["document"].get("saved_selections").is_none(), "nothing written when there are none");
assert!(load(&serde_json::to_vec(&v).unwrap()).unwrap().saved_selections.is_empty());
let id = fixtures::all_ids(&d).into_iter().next().unwrap();
let missing = fixtures::all_ids(&d).into_iter().map(|NodeId(i)| i).max().unwrap() + 1;
let mut list = vec![
json!({"name": format!(" {} ", "x".repeat(1000)), "objects": [id.0, missing, id.0]}),
json!({"name": " ", "objects": [id.0]}),
json!({"name": "Dup", "objects": []}),
json!({"name": "Dup", "objects": [id.0]}),
];
list.extend((0..100).map(|i| json!({"name": format!("S{i}"), "objects": [id.0]})));
v["document"]["saved_selections"] = json!(list);
let back = load(&serde_json::to_vec(&v).unwrap()).unwrap();
let saved = &back.saved_selections;
assert_eq!(saved.len(), vectorcraft_doc::SavedSelection::MAX);
assert_eq!(saved[0].name, "x".repeat(vectorcraft_doc::SavedSelection::MAX_NAME));
assert_eq!(saved[0].objects, vec![id], "a missing id is dropped, a repeated one kept once");
assert_eq!((saved[1].name.as_str(), saved[1].objects.len()), ("Dup", 0), "blank names go, the first of a name stays");
assert_eq!(saved[2].name, "S0");
// What loads saves and loads again unchanged.
assert_eq!(&load(&save(&back, false)).unwrap().saved_selections, saved);
// Junk in the member is a format error, not a crash.
v["document"]["saved_selections"] = json!([{"name": 5, "objects": "x"}]);
assert!(load(&serde_json::to_vec(&v).unwrap()).is_err());
}

View File

@ -6,7 +6,7 @@
//! `name<i>` (as edited) and `deleted<i>`; `selected` (the row picked).
use serde_json::{Value, json};
use vectorcraft_engine::cmd::MAX_SAVED_SELECTIONS;
use vectorcraft_engine::doc::SavedSelection;
use super::{DialogSpec, form};
use crate::state::Dialog;
@ -34,7 +34,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
}
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)
d.fields.get("count").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(0).min(SavedSelection::MAX)
}
/// The rows still in the list (not deleted).

View File

@ -1550,7 +1550,7 @@ 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] = [
const SAVED_SELECTION_IDS: [&str; vectorcraft_engine::doc::SavedSelection::MAX] = [
"select.recall1",
"select.recall2",
"select.recall3",
@ -2712,9 +2712,7 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
}
// 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 name = app.session.active().map(|d| vectorcraft_engine::doc::SavedSelection::default_name(&d.doc.saved_selections)).unwrap_or_default();
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Save Selection", "params": {"name": name}}));
return;
}

View File

@ -521,6 +521,18 @@ the one highlighted layer or group row, else the one selected group, else the cu
so art added later is clipped too); called again it releases the mask. It returns `{clip}`, and the Layers panel
underlines clipping-path names.
## Saved selections
Select → Save Selection… keeps the selected objects under a name, in the document: `select.save {name?}` (default
the first free "Selection N"; an existing name is replaced with the current selection; at most 25 per document,
names up to 255 characters) → `{name}`, one undo step. `select.savedList` lists the names in menu order, and
`select.recall {name}` selects those objects again (ones deleted since are left out) → `{count}`. Edit Selection…
renames and deletes: `select.editSaved {name, newName?, delete?}`, or several at once as
`{edits: [{name, newName?, delete?}…]}` in one undo step, where every `name` is the name before the edit and the
names must stay unique. In the desktop app the saved selections are listed at the bottom of the Select menu
(`select.recall1` … `select.recall25`). A file's saved selections are checked when it opens: past 25, blank or
repeated names, and ids the document doesn't have are dropped.
## Graphic styles
A graphic style holds an appearance (fills, strokes, effects) plus opacity, blend mode, isolate and knockout.