Layers: ruler guide rows have their own eye and lock, and go to the trash

The eye and lock on a guide's row in the Layers panel hide or lock that
guide alone, in one undo step, and a drag down either column takes guide
rows along with the other rows it passes. A hidden guide is not drawn and
does not snap, and its row is dimmed; a locked guide is drawn and snaps
but can't be selected, moved or deleted. Both can be changed while the
guide's layer is hidden or locked, and the guide shows or can be edited
only when its layer allows it as well. The state is kept through save
and reopen.

- layer.setProps takes ruler guide node ids; every other command still
  refuses them.
- guide.list rows carry the guide's node id and its own hidden and
  locked state.
- Alt-click and Ctrl/Cmd+Alt-click on a guide row's eye or lock act on
  the other layers, as on any row in that layer.
- Object > Lock > Selection and Object > Hide > Selection lock or hide the
  selected ruler guides with the selected art, in one undo step, outside
  isolation mode. Unlock All and Show All, document scope, also unlock or
  show guides locked or hidden on their own and select them as guides.
- Guide rows dropped on the Layers panel's trash delete their guides
  (guide.remove {indexes}). A click on a guide row clears the highlighted
  rows, so Delete deletes the guide; with no row highlighted the trash
  deletes the selected guides.
- guide.remove and guide.setLayer refuse index together with indexes and
  an indexes value that is not a list, and count a repeated index once.
  guide.select, guide.remove and guide.setLayer refuse an indexes list of
  more than 10,000 entries; guide.setLayer cut such a list short before.
- Moving, deleting or moving to another layer a guide that is hidden or
  locked on its own fails with "the guide is hidden or locked"; a guide
  on a hidden or locked layer still fails with "the guide is on a hidden
  or locked layer".

Docs: docs/mcp.md, docs/target-app-parity.md, docs/ui-parity.md.
This commit is contained in:
dotCooCoo
2026-11-13 06:07:27 -08:00
parent 1dbdbce91f
commit fb41d79f28
19 changed files with 553 additions and 93 deletions
+29 -4
View File
@@ -245,6 +245,12 @@ pub fn has_selected_member(s: &Session) -> std::result::Result<(), String> {
let Some(root) = ordinary_isolation(st) else { return has_selection(s) };
if st.selection.objects.iter().any(|id| inside_isolation(&st.doc, root, *id)) { Ok(()) } else { Err("nothing selected".into()) }
}
/// [`has_selected_member`], or a ruler guide is selected outside isolation mode: Object › Lock ›
/// Selection and Object › Hide › Selection.
pub fn has_selected_member_or_guide(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if ordinary_isolation(st).is_none() && !st.selection.guides.is_empty() { Ok(()) } else { has_selected_member(s) }
}
/// Whether `id` lies inside the isolated object `root` (the root itself does not).
pub(crate) fn inside_isolation(d: &vectorcraft_doc::Document, root: vectorcraft_doc::NodeId, id: vectorcraft_doc::NodeId) -> bool {
id != root && d.ancestry(id).is_some_and(|a| a.contains(&root))
@@ -506,8 +512,13 @@ fn id_entry(value: &Value, key: &str, index: usize, cmd: &str) -> Result<NodeId>
/// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error,
/// never the selection. `None` when it gives neither (or gives them as `null`).
pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result<Option<Vec<NodeId>>> {
given_nodes(s, p, command, false)
}
/// The nodes `p` gives `command`, as [`given_objects`] reads them; ruler guide nodes are taken
/// too when `guides`, else refused.
pub(crate) fn given_nodes(s: &Session, p: &Value, command: &str, guides: bool) -> Result<Option<Vec<NodeId>>> {
if p.get("ids").is_some_and(|v| !v.is_null()) {
return checked_ids_param(s, p, "ids", command).map(Some);
return checked_nodes_param(s, p, "ids", command, guides).map(Some);
}
match p.get("id").filter(|v| !v.is_null()) {
None => Ok(None),
@@ -515,7 +526,7 @@ pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result<Opt
Some(Value::String(r)) if r.starts_with('$') => {
Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
}
Some(v) => Ok(Some(vec![checked_id(s, v, command)?])),
Some(v) => Ok(Some(vec![checked_node(s, v, command, guides)?])),
}
}
/// The objects `p` gives `command`, as [`given_objects`] reads them, checked only for their form:
@@ -541,9 +552,18 @@ pub(crate) fn index(v: u64) -> usize {
}
/// Validate all explicit targets before a command changes the document or selection.
pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result<Vec<NodeId>> {
checked_nodes_param(s, p, key, command, false)
}
/// The `key` list as [`checked_ids_param`] checks it; ruler guide nodes are taken too when
/// `guides`, else refused.
pub(crate) fn checked_nodes_param(s: &Session, p: &Value, key: &str, command: &str, guides: bool) -> Result<Vec<NodeId>> {
let values =
p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?;
values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_id(s, value, command))).collect()
values
.iter()
.enumerate()
.map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_node(s, value, command, guides)))
.collect()
}
/// The objects a command acts on: `ids` when given (every one checked, and those inside another
@@ -557,11 +577,16 @@ pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) ->
}
pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<NodeId> {
checked_node(s, value, command, false)
}
/// `value` as the id of a node of the active document; a ruler guide's is taken only when
/// `guides`.
pub(crate) fn checked_node(s: &Session, value: &Value, command: &str, guides: bool) -> Result<NodeId> {
let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?;
match s.doc()?.doc.node(id) {
None => Err(bad(command, format!("no such object id {value}"))),
// The guide commands take ruler guides by index.
Some(n) if n.is_ruler_guide() => Err(bad(command, format!("id {value} is a ruler guide, not an object"))),
Some(n) if n.is_ruler_guide() && !guides => Err(bad(command, format!("id {value} is a ruler guide, not an object"))),
Some(_) => Ok(id),
}
}
+56 -17
View File
@@ -210,7 +210,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Guides",
[],
None,
"{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), shown, editable (its layer shown and unlocked)}…] the ruler guides",
"{} → [{index, id (its node, which `layer.setProps` hides or locks on its own), vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), hidden, locked (its own visibility and lock), shown, editable (it and its layer shown and unlocked)}…] the ruler guides",
has_doc,
guide_list
),
@@ -219,7 +219,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Guides",
[],
None,
"{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}",
"{indexes: [index…] (at most 10,000), toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}",
guides_unlocked,
guide_select
),
@@ -228,7 +228,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Guide",
[],
None,
"{index?} delete ruler guide `index`, else the selected ones (as Delete / edit.clear does) → {count}",
"{index?, indexes?: [index…] (at most 10,000)} delete ruler guide `index` (or guides `indexes`, as dragging their rows onto the Layers panel's trash does), else the selected ones (as Delete / edit.clear does), in one undo step; give `index` or `indexes`, not both, and an index given twice counts once; a hidden or locked guide, or one on a hidden or locked layer, is an error → {count}",
guides_unlocked,
guide_remove
),
@@ -246,7 +246,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Guide to Layer",
[],
None,
"{index?, indexes?: [index…], layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does → {count}",
"{index?, indexes?: [index…] (at most 10,000), layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does; give `index` or `indexes`, not both, and an index given twice counts once → {count}",
guides_unlocked,
guide_set_layer
),
@@ -805,12 +805,17 @@ fn guide_list(s: &mut Session, _: &Value) -> Result<Value> {
let row = |(i, g): (usize, &Guide)| {
let selected = g.id.is_some_and(|id| st.selection.guides.contains(&id));
let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": selected });
if let Some(id) = g.id {
r["id"] = json!(id.0);
}
if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) {
r["artboard"] = json!(ab);
}
if let Some(l) = g.layer {
r["layer"] = json!(l.0);
}
r["hidden"] = json!(g.hidden);
r["locked"] = json!(g.locked);
r["shown"] = json!(st.doc.guide_shown(g));
r["editable"] = json!(st.doc.guide_editable(g));
r
@@ -824,27 +829,44 @@ fn guide_index_in(ids: &[NodeId], v: Option<&Value>, cmd: &str) -> Result<NodeId
ids.get(i).copied().ok_or_else(|| bad(cmd, format!("no guide {i}")))
}
/// The most guide indexes one `indexes` list may hold.
const MAX_GUIDE_INDEXES: usize = 10_000;
/// `indexes` as a list of at most [`MAX_GUIDE_INDEXES`] entries.
fn guide_index_list<'a>(v: &'a Value, cmd: &str) -> Result<&'a Vec<Value>> {
let list = v.as_array().ok_or_else(|| bad(cmd, "indexes must be an array of guide indexes"))?;
if list.len() > MAX_GUIDE_INDEXES {
return Err(bad(cmd, format!("indexes may hold at most {MAX_GUIDE_INDEXES} entries")));
}
Ok(list)
}
/// The id of ruler guide `index` of the active document (`index` validated).
fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<NodeId> {
guide_index_in(&guide_ids(&s.doc()?.doc), v, cmd)
}
/// Is ruler guide `id` one that can be moved or deleted: not on a hidden or locked layer?
/// Is ruler guide `id` one that can be moved or deleted: not hidden or locked itself, nor on a
/// hidden or locked layer?
fn check_editable_guide(s: &Session, id: NodeId) -> Result<NodeId> {
let d = &s.doc()?.doc;
if !d.ruler_guide(id).is_some_and(|g| d.guide_editable(&g)) {
let guide = d.ruler_guide(id);
if guide.as_ref().is_some_and(|g| g.hidden || g.locked) {
return Err(EngineError::Other("the guide is hidden or locked".into()));
}
if !guide.is_some_and(|g| d.guide_editable(&g)) {
return Err(EngineError::Other("the guide is on a hidden or locked layer".into()));
}
Ok(id)
}
/// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer.
/// [`guide_index`] of a guide that can be moved or deleted ([`check_editable_guide`]).
fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<NodeId> {
check_editable_guide(s, guide_index(s, v, cmd)?)
}
fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?;
let list = guide_index_list(p.get("indexes").ok_or_else(|| bad("guide.select", "missing indexes"))?, "guide.select")?;
let ids = guide_ids(&s.doc()?.doc);
let picked = list.iter().map(|v| guide_index_in(&ids, Some(v), "guide.select")).collect::<Result<Vec<_>>>()?;
if switch_or(p, "toggle", false, "guide.select")? {
@@ -861,18 +883,30 @@ fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "selected": guide_indexes(&st.doc, &st.selection.guides) }))
}
/// Delete ruler guide `index`, else the selected guides, in one undo step → {count}.
/// Delete ruler guide `index` (or guides `indexes`), else the selected guides, in one undo step
/// → {count}.
pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
let gone: Vec<NodeId> = match p.get("index") {
Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?],
None => s.doc()?.selection.guides.clone(),
const C: &str = "guide.remove";
let mut gone: Vec<NodeId> = match (p.get("index"), p.get("indexes")) {
(Some(_), Some(_)) => return Err(bad(C, "give index or indexes, not both")),
(Some(v), None) => vec![editable_guide_index(s, Some(v), C)?],
(None, Some(list)) => {
let list = guide_index_list(list, C)?;
let ids = guide_ids(&s.doc()?.doc);
// A set, so an index given twice deletes its guide once and the ids held never outnumber the guides.
let set = list.iter().map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::<Result<BTreeSet<_>>>()?;
set.into_iter().collect()
}
(None, None) => s.doc()?.selection.guides.clone(),
};
gone.sort_unstable();
gone.dedup();
if gone.is_empty() {
return Err(bad("guide.remove", "no guide selected"));
return Err(bad(C, "no guide selected"));
}
let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" };
s.edit(label, |d, sel| {
d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id)));
d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.binary_search(&id).is_ok()));
Ok(())
})?;
Ok(json!({ "count": gone.len() }))
@@ -888,14 +922,19 @@ fn guide_set_layer(s: &mut Session, p: &Value) -> Result<Value> {
if super::layer::locked_within(d, layer) {
return Err(EngineError::Other("the layer or group is locked".into()));
}
let moving: Vec<NodeId> = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) {
(Some(v), _) => vec![editable_guide_index(s, Some(v), C)?],
let mut moving: Vec<NodeId> = match (p.get("index"), p.get("indexes")) {
(Some(_), Some(_)) => return Err(bad(C, "give index or indexes, not both")),
(Some(v), None) => vec![editable_guide_index(s, Some(v), C)?],
(None, Some(list)) => {
let list = guide_index_list(list, C)?;
let ids = guide_ids(&s.doc()?.doc);
list.iter().take(10_000).map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::<Result<Vec<_>>>()?
list.iter().map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::<Result<Vec<_>>>()?
}
(None, None) => s.doc()?.selection.guides.clone(),
};
// An index given twice moves (and counts) its guide once; the others keep their order.
let mut seen = HashSet::new();
moving.retain(|id| seen.insert(*id));
if moving.is_empty() {
return Err(bad(C, "no guide selected (or give an index)"));
}
+12 -6
View File
@@ -14,8 +14,8 @@ use vectorcraft_doc::{Artboard, ArtboardSide, Document, LAYER_COLORS, LayerColor
use vectorcraft_geom::{Affine, Rect};
use super::{
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc,
has_selection, ids_param, index, object_param, ok, str_param, switch_or,
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, checked_node, checked_nodes_param, cmd,
color_value, f64_or, has_doc, has_selection, ids_param, index, object_param, ok, str_param, switch_or,
};
use crate::EngineError;
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layer Options…",
["Window", "Layers"],
None,
"{ids?|id? (default: the highlighted rows, else the current layer), name? (with prefs.identifyObjectsBy xmlId: a valid XML ID, stored as the object name), visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name (a hex color with an alpha part, #rrggbbaa or #rgba, is an error)} Layer Options; rows that are not layers take name, visible and locked. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step",
"{ids?|id? (default: the highlighted rows, else the current layer), name? (with prefs.identifyObjectsBy xmlId: a valid XML ID, stored as the object name), visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name (a hex color with an alpha part, #rrggbbaa or #rgba, is an error)} Layer Options; rows that are not layers take name, visible and locked, and `ids` may name ruler guides (their `guide.list` id), which hide or lock on their own. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step",
has_doc,
set_props
),
@@ -210,11 +210,16 @@ pub fn specs() -> Vec<CommandSpec> {
/// The rows a panel command acts on: `ids` or `id`, else the highlighted rows, else the current
/// layer (capped at [`MAX_ROWS`]).
pub(crate) fn rows_param(s: &Session, p: &Value, command: &str) -> Result<Vec<NodeId>> {
rows_or_guides_param(s, p, command, false)
}
/// The rows [`rows_param`] reads; given ruler guide ids are taken too when `guides`, else refused.
fn rows_or_guides_param(s: &Session, p: &Value, command: &str, guides: bool) -> Result<Vec<NodeId>> {
// Presence is explicit intent: validate every target before fallback, truncation or edits.
let mut ids = if p.get("ids").is_some() {
checked_ids_param(s, p, "ids", command)?
checked_nodes_param(s, p, "ids", command, guides)?
} else if let Some(id) = p.get("id") {
vec![checked_id(s, id, command)?]
vec![checked_node(s, id, command, guides)?]
} else {
let st = s.doc()?;
let rows = st.highlighted_rows();
@@ -637,7 +642,8 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.setProps";
let xml_id = s.prefs.identify_objects_by == "xmlId";
check_options(C, p, xml_id)?;
let ids = rows_param(s, p, C)?;
// A ruler guide's row takes visible and locked: its own eye and lock.
let ids = rows_or_guides_param(s, p, C, true)?;
if ids.is_empty() {
return Err(bad(C, "no rows"));
}
+28 -9
View File
@@ -169,28 +169,47 @@ pub(super) fn lock(s: &mut Session, _: &Value) -> Result<Value> {
ok()
}
/// The selected objects ([`selected_roots`]) that Lock › Selection and Hide › Selection change: in
/// isolation mode, only those inside the isolated object, where a path of the isolated compound
/// path is an object of its own.
/// The selected objects ([`selected_roots`]) and ruler guides that Lock › Selection and Hide ›
/// Selection change: in isolation mode, only the objects inside the isolated object, where a path
/// of the isolated compound path is an object of its own. A guide counts while it can be edited
/// (it and its layer shown and unlocked, and Lock Guides off).
fn selected_lock_hide_roots(s: &Session) -> Result<Vec<NodeId>> {
match crate::cmd::ordinary_isolation(s.doc()?) {
let st = s.doc()?;
match crate::cmd::ordinary_isolation(st) {
Some(root) => crate::cmd::edit::selected_roots_inside(s, root, false),
None => selected_roots(s),
None => {
let mut ids = selected_roots(s)?;
if !s.guides_locked() {
let d = &st.doc;
ids.extend(st.selection.guides.iter().copied().filter(|id| d.ruler_guide(*id).is_some_and(|g| d.guide_editable(&g))));
}
Ok(ids)
}
}
}
/// The objects and ruler guides in `d` that `f` picks (layers never).
fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
let mut v = vec![];
d.walk(|n| {
if !n.is_layer() && !n.is_ruler_guide() && f(n) {
if !n.is_layer() && f(n) {
v.push(n.id)
}
});
v
}
/// Select what Unlock All or Show All changed (`ids`): the objects, and the ruler guides among
/// them as guides (a guide that still can't be edited is then dropped from the selection).
fn select_changed(d: &Document, sel: &mut vectorcraft_doc::Selection, ids: &[NodeId]) {
let guides: std::collections::HashSet<NodeId> = d.ruler_guides().into_iter().filter_map(|g| g.id).collect();
let (on_guides, objects): (Vec<NodeId>, Vec<NodeId>) = ids.iter().copied().partition(|id| guides.contains(id));
sel.set(objects);
sel.guides = on_guides;
}
/// The objects that `f` picks for `object.unlockAll` and `object.showAll` (`cmd`): in the whole
/// document, inside the isolated object in isolation mode, or with `scope: "group"` inside the
/// document (its ruler guides included), inside the isolated object in isolation mode, or with `scope: "group"` inside the
/// groups that hold the selected objects (in isolation mode, only the objects of those groups
/// that are inside the isolated object). A selected group is the group that holds its own
/// members, and the objects of a group inside another such group are taken once. With
@@ -253,7 +272,7 @@ pub(super) fn unlock_all(s: &mut Session, p: &Value) -> Result<Value> {
n.locked = false;
}
}
sel.set(ids.iter().copied());
select_changed(d, sel, &ids);
Ok(())
})?;
Ok(json!({ "count": ids.len() }))
@@ -292,7 +311,7 @@ pub(super) fn show_all(s: &mut Session, p: &Value) -> Result<Value> {
n.visible = true;
}
}
sel.set(ids.iter().copied());
select_changed(d, sel, &ids);
Ok(())
})?;
Ok(json!({ "count": ids.len() }))
+7 -7
View File
@@ -7,7 +7,7 @@ use super::props::set_props;
use super::shape::{expand_shape, set_live_shape};
use super::transform::{move_cmd, nudge, reflect, rotate, scale, shear, transform, transform_again};
use super::{CommandSpec, cmd, has_doc, has_multi, has_selection, has_selection_or_guides};
use crate::cmd::has_selected_member;
use crate::cmd::has_selected_member_or_guide;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -151,8 +151,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Selection",
["Object", "Lock"],
Some("Cmd+2"),
"{ids?: [id…] (selected first; default: the selection; in isolation mode, only those inside the isolated object)}",
has_selected_member,
"{ids?: [id…] (selected first; default: the selection, selected ruler guides included; in isolation mode, only the objects inside the isolated object)}",
has_selected_member_or_guide,
lock
),
cmd!(
@@ -160,7 +160,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Unlock All",
["Object"],
Some("Cmd+Alt+2"),
"{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} unlock the locked objects and select them → {count}",
"{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} unlock the locked objects and select them; document scope outside isolation mode also unlocks ruler guides locked on their own → {count}",
has_doc,
unlock_all
),
@@ -169,8 +169,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Selection",
["Object", "Hide"],
Some("Cmd+3"),
"{ids?: [id…] (selected first; default: the selection; in isolation mode, only those inside the isolated object)}",
has_selected_member,
"{ids?: [id…] (selected first; default: the selection, selected ruler guides included; in isolation mode, only the objects inside the isolated object)}",
has_selected_member_or_guide,
hide
),
cmd!(
@@ -178,7 +178,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Show All",
["Object"],
Some("Cmd+Alt+3"),
"{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} show the hidden objects and select them → {count}",
"{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} show the hidden objects and select them; document scope outside isolation mode also shows ruler guides hidden on their own → {count}",
has_doc,
show_all
),
+5
View File
@@ -162,6 +162,11 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[
"object.ungroup",
];
/// Commands whose `ids` (or `id`) may name ruler guide nodes as well as objects: the Layers panel's
/// eye and lock on a guide's row, and a drag down either column that passes guide rows. Every other
/// command given a ruler guide's id refuses it (the guide commands take guides by index).
pub(crate) const TAKES_GUIDE_IDS: &[&str] = &["layer.setProps"];
/// The non-empty `ids` given to a command whose params description names `ids` as a key (the
/// key callers outside the app may pass it, [`cmd::params::takes_key`]), or its `id` as `ids` of
/// one when no `ids` are given and the description takes `id` so
+1 -1
View File
@@ -1,4 +1,4 @@
use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only, waits_for_tool_edit};
use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, TAKES_GUIDE_IDS, given_ids, strip_replay_only, waits_for_tool_edit};
use super::doc_state::DocState;
use super::error::{EngineError, Result};
use super::history::NamedStep;
+5 -4
View File
@@ -1,6 +1,6 @@
use super::{
CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard,
strip_replay_only, waits_for_tool_edit,
CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, TAKES_GUIDE_IDS, Value, cmd, find_command,
given_ids, guard, strip_replay_only, waits_for_tool_edit,
};
impl Session {
@@ -87,7 +87,7 @@ impl Session {
if id == "effect.refreshDeferred" {
cmd::given_object_ids(params, id)?;
} else {
cmd::given_objects(self, params, id)?;
cmd::given_nodes(self, params, id, TAKES_GUIDE_IDS.contains(&id))?;
}
}
if SELECTS_GIVEN_IDS.contains(&id) {
@@ -106,7 +106,8 @@ impl Session {
// whichever tool is chosen.
let given = given_ids(spec.params, params);
// The guide commands take ruler guides by index; an object command given one's id is refused.
if let (Some(ids), Some(st)) = (&given, self.active())
if !TAKES_GUIDE_IDS.contains(&id)
&& let (Some(ids), Some(st)) = (&given, self.active())
&& let Some(guide) = ids.iter().find(|i| st.doc.node(**i).is_some_and(|n| n.is_ruler_guide()))
{
let value = guide.0;
+135 -5
View File
@@ -4040,9 +4040,11 @@ fn ruler_guides_select_move_and_delete() {
assert!(selected(&s).is_empty());
assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1]));
let layer = s.doc().unwrap().current_layer().unwrap().0;
let id = s.doc().unwrap().doc.ruler_guides()[1].id.unwrap().0;
assert_eq!(
s.execute("guide.list", &json!({})).unwrap()[1],
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true, "layer": layer, "shown": true, "editable": true})
json!({"index": 1, "id": id, "vertical": false, "pos": 50.0, "selected": true, "layer": layer,
"hidden": false, "locked": false, "shown": true, "editable": true})
);
assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err());
// A move takes each selected guide along its own axis, in one undo step.
@@ -4323,11 +4325,14 @@ fn artboard_guides_go_with_their_artboard() {
s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap();
let list = |s: &mut Session| s.execute("guide.list", &json!({})).unwrap();
let layer = s.doc().unwrap().current_layer().unwrap().0;
let ids: Vec<u64> = s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.id.unwrap().0).collect();
assert_eq!(
list(&mut s),
json!([
{"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true},
{"index": 1, "vertical": false, "pos": 100.0, "selected": false, "layer": layer, "shown": true, "editable": true},
{"index": 0, "id": ids[0], "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer,
"hidden": false, "locked": false, "shown": true, "editable": true},
{"index": 1, "id": ids[1], "vertical": false, "pos": 100.0, "selected": false, "layer": layer,
"hidden": false, "locked": false, "shown": true, "editable": true},
])
);
let spans = |s: &Session| {
@@ -4368,7 +4373,8 @@ fn artboard_guides_go_with_their_artboard() {
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1]));
assert_eq!(
list(&mut s)[0],
json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true, "layer": layer, "shown": true, "editable": true})
json!({"index": 0, "id": ids[1], "vertical": false, "pos": 100.0, "selected": true, "layer": layer,
"hidden": false, "locked": false, "shown": true, "editable": true})
);
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
@@ -4412,9 +4418,11 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() {
s.ruler_guide(false, &ev(PointerKind::Drag, 1000.0, 50.0), true, v).unwrap();
s.ruler_guide(false, &ev(PointerKind::Up, 1000.0, 50.0), true, v).unwrap();
let layer = s.doc().unwrap().current_layer().unwrap().0;
let id = s.doc().unwrap().doc.ruler_guides()[1].id.unwrap().0;
assert_eq!(
s.execute("guide.list", &json!({})).unwrap()[1],
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true})
json!({"index": 1, "id": id, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer,
"hidden": false, "locked": false, "shown": true, "editable": true})
);
}
@@ -4569,6 +4577,12 @@ fn ruler_guides_live_on_layers() {
assert!(s.doc().unwrap().doc.ruler_guides().iter().all(|g| g.layer == Some(first)));
s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap();
assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err());
// `indexes` that is not a list, or given with `index`, is an error; an index given twice counts once.
assert!(s.execute("guide.setLayer", &json!({"indexes": 0, "layer": first.0})).is_err());
assert!(s.execute("guide.setLayer", &json!({"index": 0, "indexes": [1], "layer": first.0})).is_err());
assert!(s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.layer).eq([Some(first), Some(second)]));
assert_eq!(s.execute("guide.setLayer", &json!({"indexes": [1, 1], "layer": first.0})).unwrap()["count"], 1);
s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap();
// Deleting a layer deletes its guides; undo brings them back.
s.execute("layer.delete", &json!({"ids": [second.0]})).unwrap();
assert_eq!(guides(&s), [(true, 200.0)]);
@@ -4606,6 +4620,122 @@ fn guides_on_hidden_or_locked_layers_are_left_alone() {
assert_eq!(guides(&s).len(), 3);
}
/// A guide hidden or locked on its own can't be moved, deleted or moved to another layer, and the
/// error names the guide, not its layer; a guide on a hidden or locked layer names the layer.
#[test]
fn a_guide_hidden_or_locked_on_its_own_is_named_in_the_error() {
let mut s = session();
let first = s.doc().unwrap().current_layer().unwrap();
for pos in [100.0, 200.0] {
s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap();
}
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
let ids: Vec<u64> = s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.id.unwrap().0).collect();
s.execute("layer.setProps", &json!({"ids": [ids[0]], "locked": true})).unwrap();
s.execute("layer.setProps", &json!({"ids": [ids[1]], "visible": false})).unwrap();
let own = "the guide is hidden or locked";
for i in [0, 1] {
assert_eq!(s.execute("guide.move", &json!({"index": i, "pos": 50})).unwrap_err().to_string(), own, "{i}");
assert_eq!(s.execute("guide.remove", &json!({"index": i})).unwrap_err().to_string(), own, "{i}");
assert_eq!(s.execute("guide.remove", &json!({"indexes": [i]})).unwrap_err().to_string(), own, "{i}");
let p = json!({"index": i, "layer": second.0});
assert_eq!(s.execute("guide.setLayer", &p).unwrap_err().to_string(), own, "{i}");
}
// Shown and unlocked, but on a locked layer: the error names the layer.
s.execute("layer.setProps", &json!({"ids": [ids[0]], "locked": false})).unwrap();
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": true})).unwrap();
let e = s.execute("guide.move", &json!({"index": 0, "pos": 50})).unwrap_err().to_string();
assert_eq!(e, "the guide is on a hidden or locked layer");
}
/// Object › Lock › Selection and Hide › Selection lock and hide a selected ruler guide on its own,
/// with the selected art, in one undo step; Unlock All and Show All unlock and show it again and
/// select it as a guide. While Lock Guides is on nothing selects it, and in isolation mode Lock
/// leaves it alone.
#[test]
fn lock_hide_unlock_and_show_all_reach_ruler_guides() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
for pos in [100.0, 200.0] {
s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap();
}
let flags = |s: &Session| s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.hidden, g.locked)).collect::<Vec<_>>();
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let clear = (false, false);
// A guide selected on its own: Lock locks it alone and deselects it.
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
let n = undo(&s);
s.execute("object.lock", &json!({})).unwrap();
assert_eq!((flags(&s), undo(&s)), (vec![(false, true), clear], n + 1));
assert!(selected_guides(&s).is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((flags(&s), selected_guides(&s)), (vec![clear, clear], vec![0]));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(flags(&s)[0], (false, true));
// A guide and art selected together: Hide hides both, in one step.
sel(&mut s, &[a]);
s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap();
let n = undo(&s);
s.execute("object.hide", &json!({})).unwrap();
assert_eq!((flags(&s), undo(&s)), (vec![(false, true), (true, false)], n + 1));
assert!(!s.doc().unwrap().doc.node(a).unwrap().visible);
// Unlock All and Show All reach the guides and select them as guides.
assert_eq!(s.execute("object.unlockAll", &json!({})).unwrap()["count"], 1);
assert_eq!((flags(&s)[0], selected_guides(&s), selected(&s)), (clear, vec![0], vec![]));
assert_eq!(s.execute("object.showAll", &json!({})).unwrap()["count"], 2);
assert_eq!((flags(&s), selected_guides(&s), selected(&s)), (vec![clear, clear], vec![1], vec![a]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(flags(&s)[1], (true, false), "Show All is one undo step");
s.execute("edit.redo", &json!({})).unwrap();
// With Lock Guides on, Unlock All unlocks a guide but leaves it unselected.
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
s.execute("object.lock", &json!({})).unwrap();
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
s.execute("object.unlockAll", &json!({})).unwrap();
assert_eq!((flags(&s)[0], selected_guides(&s)), (clear, vec![]));
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
// In isolation mode Lock takes only the objects inside the isolated group.
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
sel(&mut s, &[a]);
let _ = s.execute("guide.select", &json!({"indexes": [0], "toggle": true}));
s.execute("object.lock", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(a).unwrap().locked);
assert_eq!(flags(&s)[0], clear);
}
/// `guide.remove` with `indexes` deletes those guides in one undo step, an index given twice
/// once; a locked guide among them, or an unknown index, deletes nothing.
#[test]
fn guide_remove_takes_several_indexes() {
let mut s = session();
for pos in [100.0, 200.0, 300.0] {
s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap();
}
let id = s.doc().unwrap().doc.ruler_guides()[2].id.unwrap();
s.execute("layer.setProps", &json!({"ids": [id.0], "locked": true})).unwrap();
assert!(s.execute("guide.remove", &json!({"indexes": [0, 2]})).is_err());
assert!(s.execute("guide.remove", &json!({"indexes": [0, 7]})).is_err());
assert!(s.execute("guide.remove", &json!({"indexes": 0})).is_err());
assert!(s.execute("guide.remove", &json!({"index": 0, "indexes": [1]})).is_err());
// A list longer than 10,000 entries is refused by every guide command that takes one.
let long = json!(vec![0; 10_001]);
assert!(s.execute("guide.remove", &json!({"indexes": long})).is_err());
assert!(s.execute("guide.select", &json!({"indexes": long})).is_err());
let layer = s.doc().unwrap().doc.layers[0].id;
assert!(s.execute("guide.setLayer", &json!({"indexes": long, "layer": layer.0})).is_err());
assert_eq!(s.execute("guide.select", &json!({"indexes": vec![0; 10_000]})).unwrap()["selected"], json!([0]));
s.execute("select.none", &json!({})).unwrap();
assert_eq!(guides(&s).len(), 3);
let n = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("guide.remove", &json!({"indexes": [1, 0, 1]})).unwrap()["count"], 2);
assert_eq!((guides(&s), s.doc().unwrap().history.undo.len()), (vec![(true, 300.0)], n + 1));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s).len(), 3);
}
/// Guides follow their layers through Duplicate, Merge Selected and Flatten Artwork, in one undo
/// step each.
#[test]
+26
View File
@@ -789,3 +789,29 @@ fn units_and_ruler_guides_survive_save_and_reopen() {
}
let _ = std::fs::remove_dir_all(d);
}
/// A ruler guide hidden or locked on its own (the Layers panel's eye and lock on its row,
/// `layer.setProps` on its node) stays so through Save, Close and Open, and the other guide stays
/// visible and unlocked.
#[test]
fn a_guide_hidden_or_locked_on_its_own_survives_save_and_reopen() {
let d = dir("guide-flags");
for ext in ["vectorcraft", "pdf", "ai", "eps"] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
for pos in [10, 20, 30] {
s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap();
}
rect(&mut s);
let ids: Vec<u64> = s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.id.unwrap().0).collect();
s.execute("layer.setProps", &json!({"ids": [ids[1]], "visible": false})).unwrap();
s.execute("layer.setProps", &json!({"ids": [ids[2]], "locked": true})).unwrap();
let p = path(&d, &format!("a.{ext}"));
s.execute("document.save", &json!({"path": p})).unwrap();
s.execute("file.close", &json!({})).unwrap();
s.execute("document.open", &json!({"path": p})).unwrap();
let flags: Vec<_> = s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.pos, g.hidden, g.locked)).collect();
assert_eq!(flags, [(10.0, false, false), (20.0, true, false), (30.0, false, true)], "{ext}");
}
let _ = std::fs::remove_dir_all(d);
}
+4 -2
View File
@@ -103,10 +103,12 @@ fn ruler_guides_are_not_art_to_select_paint_or_inspect() {
let layer_row = s.execute("document.inspect", &json!({"ids": [layer.0]})).unwrap();
let listed = layer_row.to_string();
assert!(guides.iter().all(|g| !listed.contains(&format!("\"id\":{}", g.0))), "{listed}");
// Unlock All does not reach a locked guide.
// Unlock All unlocks a guide locked on its own and selects it as a guide, not as art.
Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(guides[0]).unwrap().locked = true;
s.execute("object.unlockAll", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(guides[0]).unwrap().locked);
let st = s.doc().unwrap();
assert!(!st.doc.node(guides[0]).unwrap().locked);
assert_eq!((st.selection.guides.clone(), st.selection.objects.clone()), (vec![guides[0]], vec![]));
}
#[test]
+12 -8
View File
@@ -11,13 +11,17 @@
//! - The selection column selects a row's art (`layer.selectAll`, Shift adds or removes); a row
//! whose art is selected shows a square in its layer's colour, and dragging that square onto
//! another row moves the selected art there (Alt copies it).
//! - The eye and lock columns toggle a row (`layer.setProps`); dragging down a column sets every
//! row it passes to the same state, in one undo step. Ctrl/Cmd-click an eye switches the layer
//! between Preview and Outline. Alt-click an eye hides the other layers (`layer.hideOthers`), or
//! shows every layer when they are hidden (`layer.showAll`); Alt-click a lock does the same with
//! `layer.lockOthers` and `layer.unlockAll`. Ctrl/Cmd+Alt-click an eye switches the other layers
//! to Outline and the row's layer to Preview (`layer.outlineOthers`), or switches every layer to
//! Preview when the others are in Outline (`layer.previewAll`).
//! - The eye and lock columns toggle a row (`layer.setProps`), a ruler guide's row included;
//! dragging down a column sets every row it passes to the same state, in one undo step.
//! Ctrl/Cmd-click a layer's eye switches it between Preview and Outline. Alt-click an eye hides
//! the other layers (`layer.hideOthers`), or shows every layer when they are hidden
//! (`layer.showAll`); Alt-click a lock does the same with `layer.lockOthers` and
//! `layer.unlockAll`. Ctrl/Cmd+Alt-click an eye switches the other layers to Outline and the
//! row's layer to Preview (`layer.outlineOthers`), or switches every layer to Preview when the
//! others are in Outline (`layer.previewAll`).
//! - A ruler guide's row selects the guide (`guide.select`) and leaves no row highlighted, so
//! Delete then deletes the guide; dragged onto another layer's row the guide goes there
//! (`guide.setLayer`), and onto the trash it is deleted (`guide.remove`).
//! - Dragging rows moves them above, below or into a layer or group (`layer.move`, a drop line or
//! a box shows where); Alt copies them. A dimmed copy of the row follows the pointer. Layers
//! dropped in the empty space under the last row go below the bottom layer. Dropped on the trash
@@ -134,7 +138,7 @@ enum LayersDrag {
/// The selected-art square: the selected objects go to the row it is dropped on.
Art,
/// Ruler guide rows (indexes into the document's guides): they go to the layer of the row
/// they are dropped on.
/// they are dropped on, or are deleted when dropped on the trash.
Guides(Vec<usize>),
}
+16 -8
View File
@@ -63,7 +63,8 @@ pub(super) fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc
{
actions.push(("appearance.clear".into(), json!({"ids": [id.0]})));
} else if trash.clicked() {
if view.rows.is_empty() && app.session.active().is_some_and(|d| !d.selection.is_empty()) {
// With no row highlighted, the selected art and ruler guides go.
if view.rows.is_empty() && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) {
actions.push(("edit.clear".into(), json!({})));
} else {
actions.push(("ui.layerDelete".into(), json!({})));
@@ -82,15 +83,22 @@ pub(super) fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc
}
}
// Rows dropped on the trash are deleted, on New Layer duplicated, as in Illustrator. Both are
// outlined while rows (or, on the trash, a target circle) hang over them.
// outlined while rows (or, on the trash, a target circle) hang over them. Guide rows dropped on
// the trash delete their guides (`guide.remove`); New Layer does not take them.
for (button, cmd) in [(&trash, "ui.layerDelete"), (&new_layer, "layer.duplicate")] {
if let Some(d) = button.dnd_release_payload::<LayersDrag>()
&& let LayersDrag::Rows(ids) = &*d
{
actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})));
let deletes = cmd == "ui.layerDelete";
if let Some(d) = button.dnd_release_payload::<LayersDrag>() {
match &*d {
LayersDrag::Rows(ids) => actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))),
LayersDrag::Guides(guides) if deletes => actions.push(("guide.remove".into(), json!({"indexes": guides}))),
_ => {}
}
}
let over = button.dnd_hover_payload::<LayersDrag>().is_some_and(|d| matches!(*d, LayersDrag::Rows(_)))
|| (cmd == "ui.layerDelete" && button.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_))));
let over = button.dnd_hover_payload::<LayersDrag>().is_some_and(|d| match *d {
LayersDrag::Rows(_) => true,
LayersDrag::Guides(_) => deletes,
LayersDrag::Art => false,
}) || (deletes && button.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_))));
if over {
widgets::drop_target_outline(&child, button.rect);
}
+13 -7
View File
@@ -62,7 +62,8 @@ pub(super) struct Row {
pub(super) colour: Color32,
/// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer.
pub(super) dim: bool,
/// A ruler guide on the layer `node` (its index in the document's guides): the guide's row.
/// A ruler guide on the layer `node` (its index in the document's guides): the guide's row,
/// dimmed when the guide or its layer is hidden.
pub(super) guide: Option<usize>,
/// The spine of blend `node`: its row, below the blend's key objects (#4820).
pub(super) spine: bool,
@@ -263,10 +264,15 @@ pub(super) fn push_rows(
let searching = !query.is_empty();
let narrowing = searching || filter.is_on();
// A layer's ruler guides are rows in it, above its art, the highest `guide.list` index first
// (not while searching, nor while the filter leaves them out).
let guides: Vec<usize> = if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() {
let mut found: Vec<usize> =
n.children().into_iter().flatten().filter(|c| c.is_ruler_guide()).filter_map(|c| all_guides.binary_search(&c.id).ok()).collect();
// (not while searching, nor while the filter leaves them out), each with whether it is visible.
let guides: Vec<(usize, bool)> = if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() {
let mut found: Vec<(usize, bool)> = n
.children()
.into_iter()
.flatten()
.filter(|c| c.is_ruler_guide())
.filter_map(|c| all_guides.binary_search(&c.id).ok().map(|i| (i, c.visible)))
.collect();
found.sort_unstable();
found
} else {
@@ -277,7 +283,7 @@ pub(super) fn push_rows(
let at = out.len();
out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template(), guide: None, spine: false });
if open {
for i in guides.into_iter().rev() {
for (i, visible) in guides.into_iter().rev() {
out.push(Row {
node: n.clone(),
depth: depth + 1,
@@ -285,7 +291,7 @@ pub(super) fn push_rows(
opens: false,
open: false,
colour,
dim: !shown,
dim: !shown || !visible,
guide: Some(i),
spine: false,
});
+28 -8
View File
@@ -116,9 +116,14 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool {
}
}
/// The row of ruler guide `i` on the layer of `item`: a guide mark and its name, the selection
/// square when it is selected. A click selects the guide; Shift or Cmd adds it to (or takes it
/// from) the selected guides. A guide on a hidden or locked layer can't be selected or dragged.
/// The row of ruler guide `i` on the layer of `item`: the guide's own eye and lock, a guide mark
/// and its name, the selection square when it is selected. The eye and lock hide and lock this
/// guide alone (`layer.setProps` on its node, one undo step), and take part in a drag down either
/// column, as other rows do; they work while its layer is hidden or locked, and the guide shows or
/// can be edited only when its layer allows it too. A click elsewhere selects the guide and leaves
/// no row highlighted; Shift or Cmd adds it to (or takes it from) the selected guides. Dragged onto
/// another layer's row the guide goes to that layer, and onto the trash it is deleted. A hidden or
/// locked guide, or one on a hidden or locked layer, can't be selected or dragged.
fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) {
let (t, h) = (&view.t, view.h);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag());
@@ -129,6 +134,9 @@ fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6));
}
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border));
if let Some(node) = g.id.and_then(|id| view.doc.node(id)) {
eye_and_lock(ui, view, node, r, out);
}
let mut x = r.left() + 2.0 * COLUMN + 2.0;
colour_bar(ui, &item.node, r, x, item.colour);
x += 6.0 + item.depth as f32 * INDENT + 16.0;
@@ -149,11 +157,17 @@ fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) {
}
if editable && resp.clicked() {
let m = ui.input(|i| i.modifiers);
// The highlighted rows are let go, so Delete then deletes the selected guide (Edit › Clear)
// rather than those rows.
if !view.rows.is_empty() {
out.actions.push(("layer.highlight".into(), json!({"ids": [], "mode": "set"})));
out.rows_after_click = Some(vec![]);
}
out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command})));
}
// Dragged: the selected guides when this is one of them, else this one, to another layer's row.
// Only the primary button drags a guide row.
if editable && resp.drag_started_by(egui::PointerButton::Primary) {
// Only the primary button drags a guide row, and not while a drag down a column passes it.
if editable && resp.drag_started_by(egui::PointerButton::Primary) && view.column.is_none() {
let guides = if selected { view.guides.clone() } else { vec![i] };
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides));
}
@@ -426,8 +440,8 @@ fn others_action(doc: &Document, n: &Node, eye: bool) -> (String, Value) {
(false, true) => "layer.lockOthers",
(false, false) => "layer.unlockAll",
};
// Show All and Unlock All take no rows.
let params = if cmd.ends_with("Others") { json!({ "ids": [n.id.0] }) } else { json!({}) };
// Show All and Unlock All take no rows; a ruler guide's row names its layer.
let params = if cmd.ends_with("Others") { json!({ "ids": [layer_row(n, own).0] }) } else { json!({}) };
(cmd.into(), params)
}
@@ -437,13 +451,19 @@ fn others_action(doc: &Document, n: &Node, eye: bool) -> (String, Value) {
fn outline_others_action(doc: &Document, n: &Node) -> (String, Value) {
let own = doc.layer_of(n.id);
if doc.layers.iter().any(|l| Some(l.id) != own && !matches!(l.kind, NodeKind::Layer { preview: false, .. })) {
("layer.outlineOthers".into(), json!({ "ids": [n.id.0] }))
("layer.outlineOthers".into(), json!({ "ids": [layer_row(n, own).0] }))
} else {
// Preview All takes no rows.
("layer.previewAll".into(), json!({}))
}
}
/// The row the layer commands of an Alt-click on row `n`'s eye or lock name: `n` itself, or for a
/// ruler guide's row the layer `own` that holds the guide (the layer commands refuse a guide).
fn layer_row(n: &Node, own: Option<NodeId>) -> NodeId {
if n.is_ruler_guide() { own.unwrap_or(n.id) } else { n.id }
}
/// The eye and lock columns of row `n` at `r`.
fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out) {
let t = &view.t;
+152
View File
@@ -835,6 +835,158 @@ fn guide_rows_select_and_move_their_guides() {
assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].layer, Some(second));
}
/// A guide row's eye and lock hide and lock that guide alone, each click one undo step: a hidden
/// guide neither shows nor stays selected and its row dims, a locked one shows but can't be
/// selected. They work while the guide's layer is hidden, and a drag down the eye column passes
/// the guide row like any other.
#[test]
fn guide_rows_have_their_own_eye_and_lock() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
let rect = NodeId(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].as_u64().unwrap());
run(&mut app, "guide.add", json!({"vertical": true, "pos": 50}));
run(&mut app, "select.none", json!({}));
expand(&mut app, &json!({})).unwrap();
let ctx = egui::Context::default();
// Rows top down: Layer 1, its guide (no target circle), the rectangle.
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 2);
let y = (circles[0].y + circles[1].y) / 2.0;
let (name, eye, lock) = (egui::pos2(150.0, y), egui::pos2(12.0, y), egui::pos2(37.0, y));
let st = app.session.active().unwrap();
let (layer, guide) = (st.doc.layers[0].id, st.doc.ruler_guides()[0].id.unwrap());
let node = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().clone();
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone();
let shown_and_editable = |app: &VectorcraftApp| {
let d = &app.session.active().unwrap().doc;
let g = d.ruler_guide(guide).unwrap();
(d.guide_shown(&g), d.guide_editable(&g))
};
let dimmed = |app: &VectorcraftApp| {
let st = app.session.active().unwrap();
list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "", &Filter::default()).iter().find(|r| r.guide.is_some()).unwrap().dim
};
click_with(&mut app, &ctx, name, egui::Modifiers::NONE);
assert_eq!(selected(&app), [guide]);
// The eye hides the guide alone, in one step, and deselects it.
let before = undo(&app);
click_with(&mut app, &ctx, eye, egui::Modifiers::NONE);
assert!(!node(&app, guide).visible && node(&app, layer).visible && node(&app, rect).visible);
assert_eq!((undo(&app), shown_and_editable(&app), dimmed(&app)), (before + 1, (false, false), true));
assert!(selected(&app).is_empty(), "a hidden guide can't stay selected");
click_with(&mut app, &ctx, name, egui::Modifiers::NONE);
assert!(selected(&app).is_empty(), "its row doesn't select a hidden guide");
click_with(&mut app, &ctx, eye, egui::Modifiers::NONE);
assert_eq!((node(&app, guide).visible, shown_and_editable(&app), dimmed(&app)), (true, (true, true), false));
// The lock locks it alone: it still shows, and its row no longer selects it.
click_with(&mut app, &ctx, lock, egui::Modifiers::NONE);
assert!(node(&app, guide).locked && !node(&app, layer).locked);
assert_eq!((undo(&app), shown_and_editable(&app), dimmed(&app)), (before + 3, (true, false), false));
click_with(&mut app, &ctx, name, egui::Modifiers::NONE);
assert!(selected(&app).is_empty(), "its row doesn't select a locked guide");
run(&mut app, "edit.undo", json!({}));
assert!(!node(&app, guide).locked);
run(&mut app, "edit.redo", json!({}));
assert!(node(&app, guide).locked);
click_with(&mut app, &ctx, lock, egui::Modifiers::NONE);
assert_eq!(shown_and_editable(&app), (true, true));
// With its layer hidden, the guide's own eye still changes the guide, which shows again with
// the layer only when its own eye is on.
run(&mut app, "layer.setProps", json!({"ids": [layer.0], "visible": false}));
click_with(&mut app, &ctx, eye, egui::Modifiers::NONE);
assert!(!node(&app, guide).visible);
run(&mut app, "layer.setProps", json!({"ids": [layer.0], "visible": true}));
assert_eq!(shown_and_editable(&app), (false, false));
click_with(&mut app, &ctx, eye, egui::Modifiers::NONE);
assert_eq!(shown_and_editable(&app), (true, true));
// A drag down the eye column from the guide row to the rectangle hides both, in one step.
let before = undo(&app);
let below = egui::pos2(12.0, circles[1].y);
let steps = [
vec![egui::Event::PointerMoved(eye)],
vec![button(eye, true)],
vec![egui::Event::PointerMoved(eye + vec2(0.0, 6.0))],
vec![egui::Event::PointerMoved(below)],
vec![button(below, false)],
vec![],
];
for e in steps {
frame(&mut app, &ctx, e, false);
}
assert!(!node(&app, guide).visible && !node(&app, rect).visible && node(&app, layer).visible);
assert_eq!(undo(&app), before + 1, "one undo step");
assert!(!app.session.in_interaction());
run(&mut app, "edit.undo", json!({}));
assert!(node(&app, guide).visible && node(&app, rect).visible);
// Alt-click on the guide's eye or lock acts on the other layers, as on any row in its layer.
run(&mut app, "layer.new", json!({}));
let other = app.session.active().unwrap().doc.layers.iter().map(|l| l.id).find(|l| *l != layer).unwrap();
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 3, "the new layer, Layer 1 and the rectangle");
let y = (circles[1].y + circles[2].y) / 2.0;
click_with(&mut app, &ctx, egui::pos2(12.0, y), egui::Modifiers::ALT);
assert!(!node(&app, other).visible && node(&app, layer).visible && node(&app, guide).visible);
click_with(&mut app, &ctx, egui::pos2(37.0, y), egui::Modifiers::ALT);
assert!(node(&app, other).locked && !node(&app, layer).locked && !node(&app, guide).locked);
}
/// A guide row dropped on the trash deletes its guide, in one undo step; with no row highlighted
/// the trash deletes the guide its row selected; and a click on a guide row lets go of the
/// highlighted rows, so Delete goes to Edit › Clear, which deletes the guide and not the layer.
#[test]
fn guide_rows_are_deleted_with_the_trash_and_delete() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
let rect = NodeId(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].as_u64().unwrap());
for pos in [50, 80] {
run(&mut app, "guide.add", json!({"vertical": true, "pos": pos}));
}
run(&mut app, "select.none", json!({}));
expand(&mut app, &json!({})).unwrap();
let ctx = egui::Context::default();
// Rows top down: Layer 1, the 80 pt guide, the 50 pt guide, the rectangle.
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 2);
let h = (circles[1].y - circles[0].y) / 3.0;
let (row_80, row_50) = (egui::pos2(150.0, circles[0].y + h), egui::pos2(150.0, circles[0].y + 2.0 * h));
let positions = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::<Vec<_>>();
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
let kept = |app: &VectorcraftApp| {
let d = &app.session.active().unwrap().doc;
d.layers.len() == 1 && d.node(rect).is_some()
};
let trash = trash_pos(&mut app, &ctx);
let before = undo(&app);
drag(&mut app, &ctx, row_80, trash, false);
assert_eq!((positions(&app), undo(&app)), (vec![50.0], before + 1));
assert!(kept(&app));
run(&mut app, "edit.undo", json!({}));
assert_eq!(positions(&app).len(), 2);
// The trash, with the guide selected through its row and no row highlighted.
click_with(&mut app, &ctx, row_50, egui::Modifiers::NONE);
assert!(app.session.active().unwrap().highlighted_rows().is_empty());
click_with(&mut app, &ctx, trash, egui::Modifiers::NONE);
assert_eq!(positions(&app), [80.0]);
assert!(kept(&app) && app.ui.dialog.is_none());
run(&mut app, "edit.undo", json!({}));
// Layer 1 highlighted, then the guide's row clicked: the panel lets Delete go.
let layer = app.session.active().unwrap().doc.layers[0].id;
click_with(&mut app, &ctx, egui::pos2(150.0, circles[0].y), egui::Modifiers::NONE);
assert_eq!(app.session.active().unwrap().highlighted_rows(), [layer]);
click_with(&mut app, &ctx, row_80, egui::Modifiers::NONE);
assert!(app.session.active().unwrap().highlighted_rows().is_empty());
let del = egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
frame(&mut app, &ctx, vec![del], false);
assert!(kept(&app) && app.ui.dialog.is_none() && !crate::panels::rows_take_delete(&ctx));
assert_eq!(positions(&app).len(), 2);
app.run("edit.clear", json!({})).unwrap();
assert_eq!(positions(&app), [50.0]);
assert!(kept(&app));
}
/// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows
/// holding selected art).
fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) {
+22 -5
View File
@@ -2473,8 +2473,8 @@ them again with the key object. When the call changes nothing, it records no und
Other `object.setProps` calls leave the selection unchanged.
`object.unlockAll` and `object.showAll` unlock or show every locked or hidden object in the document and select
them → `{count}`. In isolation mode they take only the objects inside the isolated object. With `scope: "group"` they
take only the objects inside the groups that hold the selected objects:
them → `{count}`. Ruler guides locked or hidden on their own are counted, and are selected as guides. In isolation
mode they take only the objects inside the isolated object. With `scope: "group"` they take only the objects inside the groups that hold the selected objects:
for each selected object, the group nearest above it, or the object itself when it is a group. The objects of a
group inside another such group are counted once. With no selected object in a group, the call returns the error
"nothing selected is in a group" and changes nothing. `scope: "document"` is the default, and any other value is
@@ -2721,12 +2721,22 @@ vertical guide, the y of a horizontal one, in points) → `{index}`. With `artbo
artboard guide: it runs across that artboard only, moves with it (`artboard.move`, a pure move in
`artboard.setProps`, `artboard.rearrange`), scales with it (`artboard.setProps {scaleArt}`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is
deleted with it (`artboard.delete`); without, a canvas guide runs across the whole canvas. `guide.list` →
`[{index, vertical, pos, selected, artboard?}…]` (`artboard` only for artboard guides).
`[{index, id, vertical, pos, selected, artboard?, layer?, hidden, locked, shown, editable}…]` (`artboard` only for
artboard guides; `id` is the guide's node and `layer` the layer it is on; `hidden` and `locked` are its own state,
`shown` and `editable` also take its layer into account). `layer.setProps {ids: [id…], visible?, locked?}` hides or
locks guides on their own, as the eye and lock on a guide's row in the Layers panel do; a hidden guide is not drawn
and does not snap, and a locked one is drawn and snaps but can't be selected, moved or deleted.
`guide.select {indexes: [index…], toggle?}` selects guides on their own (the art is deselected; `toggle` adds or
removes them) → `{selected}`. `guide.move {index, pos}` puts one guide somewhere, `guide.move {dx?, dy?, copy?}`
moves the selected ones (vertical guides by `dx`, horizontal ones by `dy`; `copy` leaves them and selects the
moved copies), and `guide.remove {index?}` deletes one guide or the selected ones → `{count}`; each is one undo
step. With guides selected, `edit.clear` (Delete) deletes them and `object.nudge` (the arrow keys) nudges them.
moved copies), and `guide.remove {index?, indexes?}` deletes one guide, the guides `indexes` (not with `index`; an
index given twice counts once; a hidden or locked guide among them is an error and nothing is deleted) or the
selected ones → `{count}`; each is one undo step. An `indexes` list of `guide.select`, `guide.remove` or
`guide.setLayer` holds at most 10,000 entries; a longer one is an error. With guides selected, `edit.clear` (Delete) deletes them and `object.nudge` (the
arrow keys) nudges them. Object › Lock › Selection (`object.lock`) and Object › Hide › Selection (`object.hide`) lock
or hide the selected guides on their own, with the selected art, in one undo step, except in isolation mode.
`object.unlockAll` and `object.showAll` with the default scope, outside isolation mode, also unlock or show the
guides locked or hidden on their own and select them as guides.
View › Guides › Lock Guides (`view.guides.lock`) deselects them and the paths made into guides, in every open
document, and refuses these commands until unlocked. While it is on, a command that selects art, such as `edit.undo`,
`edit.redo`, `object.exitIsolation`, `object.unlockAll`, `object.showAll` or `edit.paste`, leaves the guides out.
@@ -4298,6 +4308,13 @@ and `layerRows`, the rows highlighted in the panel.
draws and is clicked in outline, Ctrl/Cmd-click its eye), `dimImages` (0–100, or false) and `color` (an index 0–26,
`#rrggbb` or a preset name; a hex color with an alpha part, `#rrggbbaa` or `#rgba`, is an error, and so it is in
`layer.new` and `layer.newSublayer`). Several rows change in one undo step (a drag down the eye or lock column).
A ruler guide's row has its own eye and lock: `ids` may name ruler guides (their `id` in `guide.list`), which take
`visible` and `locked`. They can be changed while the guide's layer is hidden or locked, and the guide shows
or can be edited only when its layer allows it as well. Alt-click on a guide row's eye or lock acts on the other
layers, as on any row in that layer. A click on a guide row selects the guide (`guide.select`) and clears the
highlighted rows (`layer.highlight {ids: [], mode: "set"}`), so Delete then runs `edit.clear` on the guide; with no
row highlighted, the trash button runs `edit.clear` on the selected guides and art, and guide rows dropped on the
trash are deleted (`guide.remove {indexes}`).
- **Dragging rows**: `layer.move {ids, target, place?: above|below|inside, copy?}` moves rows beside a row or into a
layer or group (on top of its contents), keeping their stacking order; `copy` (Alt-drag) moves copies. Layers go
only in layers or at the top level and objects in layers and groups (an object placed beside a top-level layer goes
+1 -1
View File
@@ -1101,7 +1101,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Blob Brush Tool Options' tip preview is the Angle slider for screen readers, with the Angle in degrees as its value: a click or a drag in it points the Angle at the pointer and gives it the keyboard focus, Tab reaches it after the Roundness field, and the arrow keys step the Angle by 1° (10° with Shift, Home and End to −180° and 180°).
The Blob Brush Tool Options also have Fidelity, which the reference app's [Blob Brush options](https://web.archive.org/web/20261012042029/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/blob-brush-options-and-best-practices.html) list as setting how far the pointer moves before the path gets a new anchor point. The range and default are VectorCraft's own values: the Pencil's logarithmic slider from Accurate (0.01 pt) to Smooth (20 pt) and its field (up to 100 pt), 1.5 pt by default and kept with the tool's options. The brush follows the stroke fitted at the Fidelity, as a Pencil stroke is fitted: a low value follows the drag closely, jitter included, and a higher one gives smoother curves (`path.blob` `fidelity`).
A Blob Brush stroke merges with the topmost path of its fill color and no stroke that it touches, and with the other such paths it touches in the same group or layer that are next to that path in stacking order with no other kind of object between them; matching paths in other groups or layers are left alone, as the Blob Brush tool guidelines in [Paint with fills and strokes](https://web.archive.org/web/20241219085603/https://helpx.adobe.com/illustrator/using/painting-fills-strokes.html) describe (`path.blob`).
- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Preferences › Guides & Grid › Lock Guides by Default locks guides when the app starts; it is off by default, and new guides can still be dragged out of a ruler while guides are locked. The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score.
- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. A guide's row has its own eye and lock, which hide or lock that guide alone in one undo step and take part in a drag down either column ([help page](https://helpx.adobe.com/in_hi/tutorial-builder/user-guide/illustrator-tutorial-builder/-ai--examples-of-common-coach-marks.html) lists a Guide among the Layers panel's items); a hidden guide is not drawn and does not snap, a locked one is drawn and snaps but can't be selected, moved or deleted, and its own state is kept through save and reopen and while its layer is hidden or locked (`layer.setProps` on its `guide.list` id). Object › Lock › Selection and Object › Hide › Selection lock or hide a selected guide with the selected art, and Unlock All and Show All unlock or show such guides and select them ([help page](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) for Lock and Hide Selection). A guide row dropped on the panel's trash deletes its guide, the trash deletes a guide its row selected, and after a click on a guide row Delete deletes the guide rather than the rows highlighted before ([help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) for deleting a selected guide). Not done: guides stacked among the art of their layer (they are listed above it). Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Preferences › Guides & Grid › Lock Guides by Default locks guides when the app starts; it is off by default, and new guides can still be dragged out of a ruler while guides are locked. The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score.
- **Releasing ruler guides:** View › Guides › Release Guides turns the selected ruler guides into paths with no fill or stroke. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the artboards (`view.guides.release {index?, ids?}`). A Cmd/Ctrl+Shift double-click with the Selection, Direct Selection or Group Selection tool releases the ruler guide or guide path under the pointer.
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (layers dropped in the empty space under the last row go below the bottom layer, #2260; dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).
With Paste Remembers Layers on, art copied from a sublayer pastes back into that sublayer, as [the help page](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=223) describes art going back to the layer it was copied from: in the same document while the sublayer is still there, two sublayers of the same name in different layers kept apart; in another document by the names of the sublayer and the layers around it. These rules are VectorCraft's own: a missing sublayer is made inside its layer (a missing layer on top, with the sublayer inside it), in the paste's one undo step; of layers with the same names, the topmost chain that matches the most names wins, a shown and unlocked one first; a matched layer that is hidden or locked, or inside one that is, refuses the paste, and nothing is pasted.
+1 -1
View File
@@ -249,7 +249,7 @@ tools, never checked side by side) isn't captured row by row.
| A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); both clicks of the pair must be on that ruler, and the first adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position`, `a_canvas_click_then_a_ruler_click_adds_no_guide`, `a_ruler_double_click_follows_a_rotated_view` |
| Escape cancels an existing ruler-guide press, move or copy | done | Selection and Direct/Group Selection; app-input regressions cover preselected guides, document/interaction-start selection, Undo/Redo and saved state, later on-canvas motion/release and temporary borrowing from idle Pen. All eight checks and the Direct Selection Escape control passed in current full CI. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) |
| Ruler origin: corner drag with preview, snapped as a drawn point is (Smart Guides, Snap to Point and the ruler guides, pixels, the grid), double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute |
| Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer |
| Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer; each row's own eye and lock; a row dropped on the trash deletes its guide | done | `guide.setLayer`; guides from older files go on a layer; `layer.setProps` on a guide's id hides or locks it alone; Object › Lock/Hide › Selection, Unlock All and Show All reach guides; `guide.remove {indexes}` |
| Objects made into guides: drawn in the guide colour and style of Preferences; Lock Guides locks them too (no select, move or delete); a dragged selection snaps to ruler guides with Smart Guides on, by the nearest point of the path when pressed on its stroke or beside its edge, so the path lands exactly on the guide | done | #1534 (#1543, #1583); #3701 |
| A selected ruler guide's X or Y in the Transform panel; typing a value moves it | not scored | #1659 (part of #1597); several guides move together; not in Properties or the Control bar. Not established by the documentation we could find: the help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) don't describe it, and the one public statement is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. Kept as a convenience pending the owner's decision; no parity claim |
| Alt swaps a ruler guide's orientation while dragging | missing | Alt-drag copies the selected guides ([Use rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html)); `guide.move` `{copy: true}` |