Merge pull request #2386 from dotCooCoo/feat/release-ruler-guides

Guides: Release Guides turns selected ruler guides into paths
This commit is contained in:
BFlat
2026-10-16 16:13:22 +07:00
committed by GitHub
4 changed files with 128 additions and 9 deletions
+64 -9
View File
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Release Guides",
["View", "Guides"],
Some("Cmd+Alt+5"),
"{} turn the selected guides (or, with none selected, all guide paths) back into paths → {count}",
"{index?: a ruler guide's index, ids?: [guide path id…]} turn guides into paths: index releases that ruler guide and ids release those guide paths (and the guide paths inside them); otherwise the selected guide paths and ruler guides are released, and with no guide selected every guide path is released; a ruler guide becomes a straight path with no fill or stroke, across its artboard or, for a canvas guide, across the bounds of all the artboards; index and ids need guides unlocked; one undo step, the released paths selected → {count}",
has_doc,
release_guides
),
@@ -522,22 +522,77 @@ fn guide_paths(d: &vectorcraft_doc::Document) -> Vec<NodeId> {
v
}
fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
/// Turn guides into paths: ruler guide `index` and the guide paths in `ids`, else the selected
/// guide paths and ruler guides, else every guide path, in one undo step.
fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "view.guides.release";
let (index, ids) = (p.get("index"), p.get("ids"));
if (index.is_some() || ids.is_some()) && s.menu.guides_locked {
return Err(EngineError::Other("guides are locked".into()));
}
let (rulers, paths): (Vec<usize>, Vec<NodeId>) = if index.is_some() || ids.is_some() {
let rulers = match index {
Some(v) => vec![editable_guide_index(s, Some(v), C)?],
None => vec![],
};
let roots = if ids.is_some() { checked_ids_param(s, p, "ids", C)? } else { vec![] };
let d = &s.doc()?.doc;
let mut paths = vec![];
for r in roots {
if let Some(n) = d.node(r) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && d.is_editable(c.id) && !paths.contains(&c.id) {
paths.push(c.id);
}
});
}
}
(rulers, paths)
} else {
let st = s.doc()?;
let all = guide_paths(&st.doc);
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
let rulers: Vec<usize> = if s.menu.guides_locked {
vec![]
} else {
st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect()
};
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| st.doc.is_editable(*id)).collect()) } else { (rulers, sel) }
};
// Find each ruler guide's line and its parent: the guide's layer, else where new art goes.
let st = s.doc()?;
let all = guide_paths(&st.doc);
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
let targets: Vec<NodeId> = if sel.is_empty() { all.into_iter().filter(|id| st.doc.is_editable(*id)).collect() } else { sel };
if targets.is_empty() {
let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b));
let mut lines = Vec::with_capacity(rulers.len());
for &i in &rulers {
let Some(g) = st.doc.guides.get(i) else { continue };
let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }));
let Some((a, b)) = span else { continue };
let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) };
let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) {
Some(l) => Some(l),
None => st.target_parent()?,
};
lines.push((i, parent, a, b));
}
let n = paths.len() + lines.len();
if n == 0 {
return Err(EngineError::Other("Release Guides: no guides".into()));
}
let n = targets.len();
s.edit("Release Guides", |d, sel| {
for id in &targets {
for id in &paths {
if let Some(NodeKind::Path { guide, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
*guide = false;
}
}
sel.set(targets.iter().copied());
let mut made = Vec::with_capacity(lines.len());
for &(_, parent, a, b) in &lines {
let id = d.alloc_id();
let look = vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0);
d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?;
made.push(id);
}
d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i));
sel.set(paths.iter().copied().chain(made));
Ok(())
})?;
Ok(json!({ "count": n }))
+55
View File
@@ -1642,6 +1642,61 @@ fn make_release_clear_guides() {
assert_eq!(undo_len(&s), n + 1);
}
/// The anchor positions of `id`, which must be a path of one open subpath, and whether it is still a guide.
fn line_ends(s: &Session, id: NodeId) -> (Vec<(f64, f64)>, bool) {
let NodeKind::Path { path, guide, .. } = &node(s, id).kind else { panic!("not a path") };
let [sub] = &path.subpaths[..] else { panic!("not one subpath") };
assert!(!sub.closed);
(sub.anchors.iter().map(|a| (a.p.x, a.p.y)).collect(), *guide)
}
/// Release Guides turns ruler guides into paths with no fill or stroke, each in one undo step. An
/// artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the
/// bounds of all the artboards.
#[test]
fn release_guides_turns_ruler_guides_into_paths() {
let mut s = session();
s.execute("artboard.new", &json!({})).unwrap();
let g = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[g]);
s.execute("view.guides.make", &json!({})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 50, "artboard": 0})).unwrap();
let d = &s.doc().unwrap().doc;
let (ab, all) = (d.artboards[0].rect, d.artboards[0].rect.union(d.artboards[1].rect));
assert!(all.x1 > ab.x1);
let layer = s.doc().unwrap().current_layer();
// The selected artboard guide goes, and a selected path across its artboard takes its place.
s.execute("guide.select", &json!({"indexes": [1]})).unwrap();
let n = undo_len(&s);
assert_eq!(s.execute("view.guides.release", &json!({})).unwrap()["count"], 1);
assert_eq!((guides(&s), undo_len(&s)), (vec![(false, 300.0)], n + 1));
let [line] = selected(&s)[..] else { panic!("one path selected") };
assert_eq!(line_ends(&s, line), (vec![(ab.x0, 50.0), (ab.x1, 50.0)], false));
assert_eq!(node(&s, line).appearance, Appearance::basic(Paint::None, Paint::None, 0.0));
assert_eq!(s.doc().unwrap().doc.parent_of(line), layer);
assert!(matches!(node(&s, g).kind, NodeKind::Path { guide: true, .. }), "the guide path stays a guide");
// Undo brings the guide back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s), vec![(false, 300.0), (false, 50.0)]);
assert!(s.doc().unwrap().doc.node(line).is_none());
// index releases the canvas guide as a line across the artboards.
assert_eq!(s.execute("view.guides.release", &json!({"index": 0})).unwrap()["count"], 1);
assert_eq!(guides(&s), vec![(false, 50.0)]);
let [line] = selected(&s)[..] else { panic!("one path selected") };
assert_eq!(line_ends(&s, line), (vec![(all.x0, 300.0), (all.x1, 300.0)], false));
// ids releases those guide paths.
assert_eq!(s.execute("view.guides.release", &json!({"ids": [g.0]})).unwrap()["count"], 1);
assert!(matches!(node(&s, g).kind, NodeKind::Path { guide: false, .. }));
assert_eq!(selected(&s), vec![g]);
// Locked guides are not released by index or ids.
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
for p in [json!({"index": 0}), json!({"ids": [g.0]})] {
assert!(matches!(s.execute("view.guides.release", &p), Err(EngineError::Other(m)) if m == "guides are locked"), "{p}");
}
assert_eq!(guides(&s).len(), 1);
}
#[test]
fn ruler_guides_and_lock() {
let mut s = session();
+8
View File
@@ -992,6 +992,14 @@ moved copies), and `guide.remove {index?}` deletes one guide or the selected one
step. With guides selected, `edit.clear` (Delete) deletes them and `object.nudge` (the arrow keys) nudges them.
View › Guides › Lock Guides (`view.guides.lock`) deselects them and refuses these commands until unlocked.
`view.guides.release {index?, ids?}` (View › Guides › Release Guides) turns guides into paths → `{count}`. With
`index` it releases that ruler guide, and with `ids` it releases those guide paths (and the guide paths inside them).
Otherwise it releases the selected guide paths and ruler guides, and with no guide selected it releases every guide
path. A released ruler guide becomes a straight path with no fill or stroke, at the top of the guide's layer (or where
new art goes, for a guide on no layer). An artboard guide becomes a line across its artboard, and a canvas guide
becomes a line across the bounds of all the artboards. `index` and `ids` are refused while guides are locked. The
release is one undo step, and the released paths are selected.
The Selection, Direct Selection and Group Selection tools pick a guide within the selection tolerance, over the art
(an anchor on it comes first with Direct Selection), and drag the selected guides: `mods.alt` copies them,
`mods.shift` snaps the dragged guide to the ruler's ticks, and otherwise it snaps to whole pixels, the grid or, with
+1
View File
@@ -505,6 +505,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Keep selected in the Pencil and Paintbrush Tool Options is on by default; while it is off, the path a stroke draws or continues is not selected afterwards and the rest of the selection stays as it was (`path.freehand` `keepSelected`).
The Blob Brush Tool Options have Keep selected and Merge only with selection, both off by default: while Keep selected is off, the blob a stroke draws or merges into is not selected afterwards, and while Merge only with selection is on, a stroke merges only with selected blobs of its color that it touches (`path.blob` `keepSelected` and `mergeOnlySelection`).
- **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). 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?}`).
- **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 (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).
The Filter button at the right of Search All keeps the rows that are of an object kind ticked under By Object (paths, shapes, text, images, effects, groups, linked files, guides) and in a state ticked under By Layer (locked, hidden), with the rows that hold them; a section with nothing ticked takes every row. The filter combines with the search, the button is lit while a filter is on, and Clear All in its popup turns the filter off (`ui.layersFilter`). The filter lasts the session.
`object.setProps` with `visible: false` or `locked: true` deselects every selected object that is then hidden or locked, the key object included, as the eye and lock columns do.