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