diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 821383ec8..612612518 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -62,7 +62,7 @@ pub fn specs() -> Vec { "New Brush…", [], None, - "{type: calligraphic|scatter|art|pattern|bristle, name?, params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}", + "{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New Brush), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}", has_doc, brush_new ), @@ -75,13 +75,21 @@ pub fn specs() -> Vec { has_doc, brush_delete ), - cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} | {names: [name…]} → {name} | {names}", has_doc, brush_duplicate), + cmd!( + "brush.duplicate", + "Duplicate Brush", + [], + None, + "{name, newName? (trimmed; blank: copy)} | {names: [name…]} → {name} | {names}", + has_doc, + brush_duplicate + ), cmd!( "brush.options", "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName?} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), @@ -393,7 +401,8 @@ fn brush_new(s: &mut Session, p: &Value) -> Result { o => return Err(bad(C, format!("unknown brush type `{o}`"))), }; let mut lib = brush::library(&s.doc()?.doc); - let base_name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..])); + let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); + let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string); let name = brush::unique_name(&lib, &base_name); let mut v = json!({"name": name, "type": ty}); if let Some(params) = p.get("params") { @@ -445,7 +454,8 @@ fn brush_duplicate(s: &mut Session, p: &Value) -> Result { let mut out = vec![]; for name in &names { let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; - let new_name = str_param(p, "newName").filter(|_| p.get("names").is_none()).map_or_else(|| format!("{name} copy"), str::to_string); + let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && p.get("names").is_none()); + let new_name = given.map_or_else(|| format!("{} copy", name.trim()), str::to_string); b.name = brush::unique_name(&lib, &new_name); out.push(b.name.clone()); lib.push(b); @@ -463,7 +473,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result { if let Some(params) = p.get("params") { merge(&mut v, params); } - let new_name = str_param(p, "newName").filter(|n| !n.trim().is_empty() && *n != name).map(str::to_string); + let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); if let Some(nn) = &new_name { if lib.iter().any(|b| &b.name == nn) { return Err(bad(C, format!("a brush named `{nn}` exists"))); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 55a58a59f..df5471eff 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -810,7 +810,8 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { let r = rect_from(p, def); let idx = s.edit("New Artboard", |d, _| { let id = d.next_artboard_id(); - let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Artboard {}", d.artboards.len() + 1)); + let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); + let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string); d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false }); Ok(d.artboards.len() - 1) })?; @@ -864,8 +865,9 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { s.edit("Artboard Options", |d, _| { let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?; a.rect = now; - if let Some(n) = str_param(p, "name") { - a.name = n.to_string(); + if let Some(n) = str_param(p, "name").map(str::trim) { + // A blank name gives the artboard its default name, Artboard N for the Nth artboard. + a.name = if n.is_empty() { format!("Artboard {}", i + 1) } else { n.to_string() }; } let id = a.id; if let Some(xf) = transform { diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index c390475c7..b8d88fd9e 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Pattern"], None, - "{ids?, name?, tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}", + "{ids?, name? (trimmed; blank: New Pattern N), tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}", has_selection, pattern_make ), @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Save a Copy", [], None, - "{name?} while editing, save the current tile as a new pattern swatch → {name}", + "{name? (trimmed; blank: copy)} while editing, save the current tile as a new pattern swatch → {name}", in_pattern_edit, pattern_save_copy ), @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Pattern Options", ["Window", "Pattern Options"], None, - "{name? (default: the pattern being edited), newName?, tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}", + "{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}", has_doc, pattern_options ), @@ -294,7 +294,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "width/height must be > 0")); } let edit = bool_or(p, "edit", true); - let wanted = str_param(p, "name").map(str::to_string); + let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let name = s.edit("Make Pattern", |d, sel| { let name = match &wanted { Some(n) => unique_pattern_name(d, n), @@ -388,12 +388,12 @@ fn pattern_cancel(s: &mut Session, _: &Value) -> Result { } fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { - let wanted = str_param(p, "name").map(str::to_string); + let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let name = s.edit("Save a Copy", |d, _| { let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?; let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?; let art: Vec> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default(); - let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern))); + let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern.trim()))); def.name = name.clone(); def.art = copies(d, &art).into_iter().map(Arc::new).collect(); if def.size_tile_to_art { @@ -419,7 +419,7 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("{k} must be > 0"))); } } - let new_name = str_param(p, "newName").map(str::to_string).filter(|n| !n.is_empty() && *n != name); + let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); if let Some(n) = &new_name && s.doc()?.doc.pattern(n).is_some() { diff --git a/crates/engine/src/tests_brushsym.rs b/crates/engine/src/tests_brushsym.rs index ffed493ad..0e0e57d1a 100644 --- a/crates/engine/src/tests_brushsym.rs +++ b/crates/engine/src/tests_brushsym.rs @@ -140,6 +140,32 @@ fn brush_options_rename_duplicate_and_delete_update_strokes() { assert!(s.execute("brush.delete", &json!({"name": "5 pt. Round"})).is_err()); } +/// New Brush, Duplicate Brush and Brush Options trim the name they are given, as the Brush Options +/// dialog does; the trimmed name is the one compared with the brushes there are, and a blank name +/// gives the name the command gives when there is none. +#[test] +fn new_duplicate_and_renamed_brushes_trim_their_names() { + let mut s = session(); + let new = |s: &mut Session, name: &str| s.execute("brush.new", &json!({"type": "calligraphic", "name": name})).unwrap()["name"].clone(); + assert_eq!(new(&mut s, " Nib \t"), "Nib"); + assert_eq!(new(&mut s, " Nib"), "Nib 2"); + assert_eq!(new(&mut s, " \t "), "New Calligraphic Brush"); + assert_eq!(s.execute("brush.duplicate", &json!({"name": "Nib", "newName": " Quill\t"})).unwrap()["name"], "Quill"); + assert_eq!(s.execute("brush.duplicate", &json!({"name": "Nib", "newName": " "})).unwrap()["name"], "Nib copy"); + assert_eq!(s.execute("brush.options", &json!({"name": "Nib 2", "newName": " Pen "})).unwrap()["name"], "Pen"); + assert_eq!(s.execute("brush.options", &json!({"name": "Pen", "newName": " "})).unwrap()["name"], "Pen"); + let e = s.execute("brush.options", &json!({"name": "Pen", "newName": " Quill"})).unwrap_err().to_string(); + assert!(e.contains("a brush named `Quill` exists"), "{e}"); + // Duplicate Brush names the copy of a brush whose saved name has white space around it, as a + // saved file can hold, from the trimmed name (" Old " gives "Old copy"). + let mut lib = vectorcraft_brush::library(&s.doc().unwrap().doc); + lib.iter_mut().filter(|b| b.name == "Pen").for_each(|b| b.name = " Old ".into()); + vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &lib); + assert_eq!(s.execute("brush.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy"); + let names: Vec = vectorcraft_brush::library(&s.doc().unwrap().doc).into_iter().map(|b| b.name).collect(); + assert_eq!(names[names.len() - 6..], ["Nib", " Old ", "New Calligraphic Brush", "Quill", "Nib copy", "Old copy"]); +} + /// The Brushes panel's multi-selection: `names` deletes or duplicates several brushes in one undo /// step, and an unknown name changes nothing. #[test] diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index f193fa13c..8b451d865 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -130,6 +130,23 @@ fn duplicate_artboard_copies_its_art() { assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1); } +/// New Artboard and Artboard Options trim the name they are given, as the Artboards panel's rename +/// does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard. +#[test] +fn new_and_renamed_artboards_trim_their_names() { + let mut s = session(); + let new = |s: &mut Session, name: &str| s.execute("artboard.new", &json!({"name": name})).unwrap(); + new(&mut s, " Cover \t"); + new(&mut s, " \t "); + new(&mut s, ""); + let names = |s: &Session| boards(s).into_iter().map(|(_, n)| n).collect::>(); + assert_eq!(names(&s), ["Artboard 1", "Cover", "Artboard 3", "Artboard 4"]); + s.execute("artboard.setProps", &json!({"index": 0, "name": " Back\t"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "name": " "})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 3, "name": ""})).unwrap(); + assert_eq!(names(&s), ["Back", "Artboard 2", "Artboard 3", "Artboard 4"]); +} + /// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an /// `index` that names no artboard (past the last, negative, fractional, a string or null), and the /// artboards, the art and the clipboard stay as they were. diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests_pattern.rs index 1c5abb2d9..fb97672d4 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests_pattern.rs @@ -267,6 +267,41 @@ fn save_a_copy_while_editing() { assert!(doc(&s).pattern("Dots copy").is_some()); } +/// Make, Save a Copy and Pattern Options trim the name they are given, as the Pattern Options +/// panel does; the trimmed name is the one compared with the names there are, and a blank name +/// gives the name the command gives when there is none. +#[test] +fn made_copied_and_renamed_patterns_trim_their_names() { + let mut s = session(); + make_dots(&mut s); + let make = |s: &mut Session, name: &str| { + let r = rect(s, 0.0, 0.0, 10.0, 10.0); + s.execute("object.pattern.make", &json!({"ids": [r.0], "name": name, "edit": false})).unwrap()["name"].clone() + }; + assert_eq!(make(&mut s, " Grid \t"), "Grid"); + assert_eq!(make(&mut s, " Dots"), "Dots 2"); + assert_eq!(make(&mut s, ""), "New Pattern 1"); + assert_eq!(make(&mut s, " \t "), "New Pattern 2"); + s.execute("object.pattern.edit", &json!({"name": "Grid"})).unwrap(); + assert_eq!(s.execute("object.pattern.saveCopy", &json!({"name": " Lines\t"})).unwrap()["name"], "Lines"); + assert_eq!(s.execute("object.pattern.saveCopy", &json!({"name": " "})).unwrap()["name"], "Grid copy"); + s.execute("object.pattern.done", &json!({})).unwrap(); + assert_eq!(s.execute("pattern.options", &json!({"name": "Dots 2", "newName": " Spots "})).unwrap()["name"], "Spots"); + assert_eq!(s.execute("pattern.options", &json!({"name": "Spots", "newName": " "})).unwrap()["name"], "Spots"); + let e = s.execute("pattern.options", &json!({"name": "Spots", "newName": " Lines"})).unwrap_err().to_string(); + assert!(e.contains("a pattern named `Lines` exists"), "{e}"); + // Save a Copy names the copy of a pattern whose saved name has white space around it, as a + // saved file can hold, from the trimmed name (" Old " gives "Old copy"). + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.patterns.iter_mut().filter(|p| p.name == "Grid copy").for_each(|p| p.name = " Old ".into()); + s.execute("object.pattern.edit", &json!({"name": " Old "})).unwrap(); + assert_eq!(s.execute("object.pattern.saveCopy", &json!({})).unwrap()["name"], "Old copy"); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let names: Vec<&str> = doc(&s).patterns.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Dots", "Grid", "Spots", "New Pattern 1", "New Pattern 2", "Lines", " Old ", "Old copy"]); + assert!(doc(&s).swatch("Spots").is_some() && doc(&s).swatch(" Spots ").is_none()); +} + #[test] fn undo_make_pattern() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 7f674398e..1b66d57b4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1123,7 +1123,10 @@ already walks Image Trace. Short agent entry points: `symbol.selectInstances {name}`. - Brushes: `brush.list`, `brush.get {name}`, `brush.apply {name, ids?}`, `brush.new {type, name?, params?, ids?}`, `brush.setCurrent {name}`, `brush.options {name, params?}`, `brush.delete {name} | {names}`, - `brush.duplicate {name, newName?} | {names}`, `brush.freehand`, `object.expandBrush {ids?}`. + `brush.duplicate {name, newName?} | {names}`, `brush.freehand`, `object.expandBrush {ids?}`. `brush.new`, + `brush.duplicate` and `brush.options` trim the white space around the name they are given. A blank `name` gives + the type's default name ("New Calligraphic Brush" for a calligraphic brush), and a blank `newName` gives + "`` copy" in `brush.duplicate` and keeps the name in `brush.options`. - Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`, `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic`, `variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph @@ -1548,6 +1551,11 @@ In pattern editing mode the tile edge (`pattern.options {showTileEdge}`) and the {showSwatchBounds}`: the part of the tiling the swatch repeats, dashed) are drawn in the preference `patternTileEdgeColor`. +`object.pattern.make {name?}`, `object.pattern.saveCopy {name?}` and `pattern.options {newName?}` trim the white space +around the name they are given. The first two number a name that a pattern or swatch has ("Dots 2"), and +`pattern.options` refuses a name that another pattern has. A blank name gives "New Pattern N" in +`object.pattern.make` and "`` copy" in `object.pattern.saveCopy`, and `pattern.options` keeps the name. + ```json {"name":"run_command","arguments":{"command":"pattern.options","params":{"showSwatchBounds":true}}} {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"patternTileEdgeColor","value":"#ff4f4f"}}} @@ -3914,6 +3922,8 @@ the selected graph's Graph Type (an error without one), a Symbolism tool's the S Magic Wand tool's its panel. `ui.newArtboard` (Alt-click on the Artboards panel's New Artboard) opens Artboard Options for a new artboard, with `new: true` and the name, position and size that `artboard.new {}` would give it. OK runs `artboard.new` with the fields and makes the new artboard the active one, and Cancel adds no artboard. +`artboard.new` and `artboard.setProps` trim the white space around `name`, and a blank name gives the artboard its +default name, "Artboard N" for the Nth artboard. ```json {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"flare","values":{"rays":24,"ringsOn":false}}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 60f8ef1dd..306d36407 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -416,6 +416,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). +- **Names of new and renamed artboards, brushes and patterns:** `artboard.new` and `artboard.setProps` trim the white space around the name they are given, as the Artboards panel's rename does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard. `brush.new`, `brush.duplicate`, `brush.options`, `object.pattern.make`, `object.pattern.saveCopy` and `pattern.options` trim the name as the Brush Options dialog and the Pattern Options panel do, and compare the trimmed name with the names there are; a blank name gives the name each command gives when there is none, and Brush Options and Pattern Options keep the name. Tests in `tests_panelcmds.rs`, `tests_brushsym.rs` and `tests_pattern.rs` cover each command. Public references: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html), [Brushes](https://web.archive.org/web/20251018101754/https://helpx.adobe.com/illustrator/using/brushes.html) and [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copies). - **Edit Artboards in Properties:** the active artboard's name is a field. Typing a new name and pressing Enter or clicking elsewhere renames that artboard in one undo step, as the Artboards panel's rename does (`artboard.setProps {index, name}`); pressing Escape or leaving the field empty keeps the old name. A click on the Artboard Options icon beside the field opens Artboard Options for the active artboard, as double-clicking the Artboard tool's button does (`tool.options {tool: "artboard"}`). Pointer and key tests in `panels/properties.rs` cover both. Public references: [Resize artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/resize-artboards.html) and [Create and add new artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/add-new-artboards.html). - **Artboard Escape routing:** Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests_distortkeys.rs`. - **Artboard deletion keys:** Delete/Backspace delete selected boards in one Undo step, retaining the original first board in the current order when all are selected, as in the public [Delete artboards guide](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). The last board and active drags stay protected; neither key clears selected artwork. Headless shortcut regressions in `tests_distortkeys.rs` cover reordered boards, artwork, saved state and Undo/Redo.