From 2972e2e6bf6f8d7162166a05c5cb979c5a76b471 Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Fri, 9 Oct 2026 19:28:26 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/doc/src/lib.rs | 18 +++++++++++++ crates/doc/src/selection.rs | 8 ++++++ crates/engine/src/cmd/docmenu.rs | 9 ++++--- crates/engine/src/lib.rs | 3 +++ crates/engine/src/tests_menucmds.rs | 23 ++++++++++++++++- crates/ui-egui/src/panels/layers.rs | 39 +++++++++++++++++++++++++++-- docs/control-protocol.md | 7 +++--- 7 files changed, 97 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9ff6526cc..3f4e6df9d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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 = 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 + '_ { self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer)) diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 8f368b280..5c09d95e6 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -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] diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 5d03a5c32..4282905f4 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -205,7 +205,7 @@ pub fn specs() -> Vec { "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 { fn guide_set_layer(s: &mut Session, p: &Value) -> Result { 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 = match p.get("index") { - Some(v) => vec![guide_index(s, Some(v), "guide.setLayer")?], - None => s.doc()?.selection.guides.clone(), + let moving: Vec = 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::>>()?, + (None, None) => s.doc()?.selection.guides.clone(), }; if moving.is_empty() { return Err(bad("guide.setLayer", "no guide selected (or give an index)")); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 95f0ad8f9..7de3ed337 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1041,6 +1041,8 @@ impl Session { /// Add a document and make it active. pub fn add_document(&mut self, mut doc: Document, path: Option) -> 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(); diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 721b4717d..1ac208876 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -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)]); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index c05500bf2..9ac35b880 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -98,6 +98,9 @@ enum LayersDrag { Rows(Vec), /// 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), } /// 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::() + && 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::().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 = 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}))); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2f1be11d6..d50caddc2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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