Guides: drag guide rows to another layer; put older guides on a layer

Guide rows in the Layers panel drag (the selected guides when the row is
one of them) onto any row of another layer, which lights up, and go to
that layer in one undo step (guide.setLayer takes indexes). Guides on no
layer, from files saved before guides had layers, go on the top visible,
unlocked layer when a document opens or is reverted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
This commit is contained in:
redrun6767andClaude Opus 5.5 authored and bflatastic committed 2026-10-11 06:03:47 +07:00
1 parent 02e93902b3
commit 2972e2e6bf
7 files changed
+97 -10

No files matched your search

+18
View File
@@ -1004,6 +1004,24 @@ impl Document {
pub fn guide_editable(&self, g: &Guide) -> bool {
g.layer.is_none_or(|l| self.node(l).is_none() || self.is_editable(l))
}
/// Put the ruler guides on no layer (from files saved before guides had layers), or on one that
/// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many.
pub fn adopt_guides(&mut self) -> usize {
let Some(home) = self.default_layer() else { return 0 };
let lost: Vec<usize> = self
.guides
.iter()
.enumerate()
.filter(|(_, g)| g.layer.is_none_or(|l| !self.node(l).is_some_and(Node::is_layer)))
.map(|(i, _)| i)
.collect();
for i in &lost {
if let Some(g) = self.guides.get_mut(*i) {
g.layer = Some(home);
}
}
lost.len()
}
/// The ruler guides on layer `layer` (not its sublayers'), by index.
pub fn guides_on(&self, layer: NodeId) -> impl Iterator<Item = (usize, &Guide)> + '_ {
self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer))
+8
View File
@@ -222,6 +222,14 @@ mod tests {
assert!(d.guide_shown(&gone) && d.guide_editable(&gone));
d.guides.push(g);
assert_eq!(d.guides_on(layer).count(), 1);
// Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer.
d.node_mut(layer).unwrap().visible = true;
d.node_mut(layer).unwrap().locked = false;
d.guides.push(crate::Guide::new(false, 5.0));
d.guides.push(gone);
assert_eq!(d.adopt_guides(), 2);
assert!(d.guides.iter().all(|g| g.layer == Some(layer)));
assert_eq!(d.adopt_guides(), 0, "once");
}
#[test]
+5 -4
View File
@@ -205,7 +205,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Guide to Layer",
[],
None,
"{index?, layer: id} put ruler guide `index`, else the selected guides, on layer `layer` (a layer or sublayer), as for art moved to another layer → {count}",
"{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}",
guides_unlocked,
guide_set_layer
),
@@ -591,9 +591,10 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
fn guide_set_layer(s: &mut Session, p: &Value) -> Result<Value> {
let layer = id_param(p, "layer").filter(|l| s.doc().is_ok_and(|st| st.doc.node(*l).is_some_and(Node::is_layer)));
let layer = layer.ok_or_else(|| bad("guide.setLayer", "layer must name a layer or sublayer"))?;
let moving: Vec<usize> = match p.get("index") {
Some(v) => vec![guide_index(s, Some(v), "guide.setLayer")?],
None => s.doc()?.selection.guides.clone(),
let moving: Vec<usize> = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) {
(Some(v), _) => vec![guide_index(s, Some(v), "guide.setLayer")?],
(None, Some(list)) => list.iter().take(10_000).map(|v| guide_index(s, Some(v), "guide.setLayer")).collect::<Result<Vec<_>>>()?,
(None, None) => s.doc()?.selection.guides.clone(),
};
if moving.is_empty() {
return Err(bad("guide.setLayer", "no guide selected (or give an index)"));
+3
View File
@@ -1041,6 +1041,8 @@ impl Session {
/// Add a document and make it active.
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
Self::refresh_text_bounds(&mut doc);
// Guides from files saved before guides had layers go on a layer.
doc.adopt_guides();
self.reset_tool_for_doc_switch();
let mut st = DocState::new(doc, path);
st.history.limit = self.prefs.history_states as usize;
@@ -1056,6 +1058,7 @@ impl Session {
return false;
}
Self::refresh_text_bounds(&mut doc);
doc.adopt_guides();
if self.active == Some(index) {
// Pending tool work (typing, a drag) belongs to the content being thrown away.
self.reset_tool_for_doc_switch();
+22 -1
View File
@@ -914,7 +914,10 @@ fn artboard_guides_go_with_their_artboard() {
let n = undo_len(&s);
s.execute("artboard.delete", &json!({"index": 0})).unwrap();
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}));
assert_eq!(
list(&mut s)[0],
json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true, "layer": layer, "shown": true, "editable": true})
);
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((guides(&s).len(), selected_guides(&s)), (4, vec![1, 2]));
@@ -963,6 +966,19 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() {
);
}
/// Guides saved before guides had layers are put on a layer when the document opens.
#[test]
fn guides_from_older_files_are_put_on_a_layer() {
let mut s = Session::new();
let mut d = vectorcraft_doc::Document::new(400.0, 300.0);
d.guides.push(vectorcraft_doc::Guide::new(true, 50.0));
d.guides.push(vectorcraft_doc::Guide::new(false, 80.0));
s.add_document(d, None);
let st = s.doc().unwrap();
let top = st.doc.default_layer();
assert!(top.is_some() && st.doc.guides.iter().all(|g| g.layer == top), "{:?}", st.doc.guides);
}
/// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it,
/// and moved to another layer.
#[test]
@@ -987,6 +1003,11 @@ fn ruler_guides_live_on_layers() {
assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first));
s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap();
assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err());
// Several at once, as dragging their rows onto a layer's row does.
s.execute("guide.setLayer", &json!({"indexes": [0, 1], "layer": first.0})).unwrap();
assert!(s.doc().unwrap().doc.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());
// 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)]);
+37 -2
View File
@@ -98,6 +98,9 @@ enum LayersDrag {
Rows(Vec<NodeId>),
/// 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.
Guides(Vec<usize>),
}
/// A drag down the eye or lock column: every row it passes takes `value`.
@@ -549,6 +552,7 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool {
NodeKind::Group { .. } => match drag {
LayersDrag::Rows(ids) => !ids.iter().any(|i| doc.node(*i).is_some_and(Node::is_layer)),
LayersDrag::Art => true,
LayersDrag::Guides(_) => false,
},
_ => false,
}
@@ -559,7 +563,7 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool {
/// from) the selected guides.
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());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag());
let Some(g) = view.doc.guides.get(i) else { return };
let selected = view.guides.contains(&i);
if resp.hovered() {
@@ -588,6 +592,29 @@ fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) {
let m = ui.input(|i| i.modifiers);
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.
if resp.drag_started() {
let guides = if selected { view.guides.clone() } else { vec![i] };
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides));
}
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
// Guides dropped here go to this guide's layer.
if let Some(drag) = resp.dnd_hover_payload::<LayersDrag>()
&& let LayersDrag::Guides(guides) = &*drag
{
guide_drop(ui, view, guides, item.node.id, r, &resp, out);
}
}
/// Guide rows `guides` dragged over row `r` (`resp`) of layer `layer`: the row lights up, and the
/// release puts them on that layer (`guide.setLayer`, one undo step).
fn guide_drop(ui: &Ui, view: &View, guides: &[usize], layer: NodeId, r: egui::Rect, resp: &egui::Response, out: &mut Out) {
ui.painter().rect_stroke(r.shrink(1.0), 0.0, Stroke::new(2.0, view.t.accent), StrokeKind::Inside);
if resp.dnd_release_payload::<LayersDrag>().is_some() {
out.actions.push(("guide.setLayer".into(), json!({"indexes": guides, "layer": layer.0})));
}
}
/// The widgets of row `item`.
@@ -828,6 +855,13 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open:
let moving: Vec<NodeId> = match &*drag {
LayersDrag::Rows(ids) => ids.clone(),
LayersDrag::Art => view.sel.iter().copied().collect(),
LayersDrag::Guides(guides) => {
// Guides go to the layer of the row (the row's own, for a layer).
if let Some(layer) = if n.is_layer() { Some(n.id) } else { doc.layer_containing(n.id) } {
guide_drop(ui, view, guides, layer, r, resp, out);
}
return;
}
};
// Never into itself or a row inside it.
let around = doc.ancestry(n.id).unwrap_or_default();
@@ -842,7 +876,7 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open:
LayersDrag::Art => Place::Above,
LayersDrag::Rows(_) if inside && rel >= 0.25 && (rel <= 0.75 || open) => Place::Inside,
LayersDrag::Rows(_) if rel < 0.5 => Place::Above,
LayersDrag::Rows(_) => Place::Below,
LayersDrag::Rows(_) | LayersDrag::Guides(_) => Place::Below,
};
let stroke = Stroke::new(2.0, view.t.accent);
match place {
@@ -862,6 +896,7 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open:
// The selection's top objects, as Arrange and Group take them.
LayersDrag::Art => doc.paint_order(moving.iter().copied()).iter().map(|i| i.0).collect(),
LayersDrag::Rows(ids) => ids.iter().map(|i| i.0).collect(),
LayersDrag::Guides(_) => return,
};
out.actions.push(("layer.move".into(), json!({"ids": ids, "target": n.id.0, "place": place.id(), "copy": copy})));
}
+4 -3
View File
@@ -257,9 +257,10 @@ where it is greyed. The modifier keys still work while dragging (Cmd on a corner
perspective, Cmd on a side shears).
Ruler guides are layer objects: `guide.add` puts a guide on the current layer (or `layer`), where it shows, hides,
locks and is deleted with its layer and is listed in the Layers panel (a click on its row selects it);
`guide.setLayer {index?, layer}` moves guides to another layer, and `guide.list` gives each
guide's `layer`, `shown` and `editable`.
locks and is deleted with its layer and is listed in the Layers panel (a click on its row selects it, a drag onto
another layer's rows moves it there); `guide.setLayer {index?, indexes?, layer}` moves guides to another layer, and
`guide.list` gives each guide's `layer`, `shown` and `editable`. Guides in files saved before guides had layers go on
the top visible, unlocked layer when the document opens.
Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel
inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A