From 02e93902b30e673913259150db755326af0850a4 Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Fri, 9 Oct 2026 19:22:52 +0000 Subject: [PATCH] Guides: ruler guides as layer objects A ruler guide goes on the current layer (guide.add {layer}), shows, hides, locks and is deleted with its layer, is listed in the Layers panel as a row that selects it, and moves to another layer (guide.setLayer). Guides on a hidden layer neither show nor snap; those on a locked layer can't be picked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/doc/src/lib.rs | 20 +++++++++- crates/doc/src/selection.rs | 18 +++++++++ crates/engine/src/cmd/docmenu.rs | 47 ++++++++++++++++++++-- crates/engine/src/cmd/layer.rs | 3 ++ crates/engine/src/tests_menucmds.rs | 45 +++++++++++++++++++-- crates/tools/src/guides.rs | 8 +++- crates/tools/src/rulerguide.rs | 5 ++- crates/tools/src/slice.rs | 2 +- crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 3 ++ crates/ui-egui/src/i18n/es.tsv | 3 ++ crates/ui-egui/src/i18n/fr.tsv | 3 ++ crates/ui-egui/src/i18n/it.tsv | 3 ++ crates/ui-egui/src/i18n/ja.tsv | 3 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 3 ++ crates/ui-egui/src/i18n/uk.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 ++ crates/ui-egui/src/panels/layers.rs | 61 ++++++++++++++++++++++++++--- docs/control-protocol.md | 5 +++ 22 files changed, 230 insertions(+), 18 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3c555def..9ff6526c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -402,12 +402,17 @@ pub struct Guide { /// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas. #[serde(default, skip_serializing_if = "Option::is_none")] pub artboard: Option, + /// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed + /// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides + /// from before guides had layers, and from files that don't say), always there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer: Option, } impl Guide { /// A canvas guide. pub fn new(vertical: bool, pos: f64) -> Self { - Self { vertical, pos, artboard: None } + Self { vertical, pos, artboard: None, layer: None } } /// Moved by `d` (a vertical guide across, a horizontal one down). @@ -990,6 +995,19 @@ impl Document { let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect; Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }) } + /// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is + /// visible? A guide whose layer is gone shows as one on no layer. + pub fn guide_shown(&self, g: &Guide) -> bool { + g.layer.is_none_or(|l| self.node(l).is_none() || self.is_visible(l)) + } + /// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked? + pub fn guide_editable(&self, g: &Guide) -> bool { + g.layer.is_none_or(|l| self.node(l).is_none() || self.is_editable(l)) + } + /// 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)) + } /// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)? pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool { let along = if g.vertical { p.y } else { p.x }; diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index e1e98de1..8f368b28 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -206,6 +206,24 @@ mod tests { assert_eq!(s.guides, vec![0]); } + #[test] + fn guides_show_and_lock_with_their_layer() { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) }; + assert!(d.guide_shown(&g) && d.guide_editable(&g)); + d.node_mut(layer).unwrap().locked = true; + assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable"); + d.node_mut(layer).unwrap().visible = false; + assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden"); + // On no layer, or a layer that's gone: always there. + assert!(d.guide_shown(&crate::Guide::new(false, 5.0))); + let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) }; + assert!(d.guide_shown(&gone) && d.guide_editable(&gone)); + d.guides.push(g); + assert_eq!(d.guides_on(layer).count(), 1); + } + #[test] fn selection_changes_clear_the_target() { let mut s = Selection::default(); diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 973d501d..5d03a5c3 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -160,7 +160,7 @@ pub fn specs() -> Vec { "Add Guide", [], None, - "{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas)} → {index}", + "{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas), layer?: id (the layer it is on, shown, hidden, locked and deleted with it; default the current layer)} → {index}", has_doc, guide_add ), @@ -169,7 +169,7 @@ pub fn specs() -> Vec { "Guides", [], None, - "{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's)}…] the ruler guides", + "{} → [{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", has_doc, guide_list ), @@ -200,6 +200,15 @@ pub fn specs() -> Vec { guides_unlocked, guide_move ), + cmd!( + "guide.setLayer", + "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}", + guides_unlocked, + guide_set_layer + ), cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all), cmd!( "file.documentColorMode", @@ -507,8 +516,13 @@ fn guide_add(s: &mut Session, p: &Value) -> Result { } None => None, }; + let layer = match id_param(p, "layer") { + Some(l) if s.doc()?.doc.node(l).is_some_and(Node::is_layer) => Some(l), + Some(_) => return Err(bad("guide.add", "layer must name a layer or sublayer")), + None => s.doc()?.current_layer(), + }; let i = s.edit("New Guide", |d, _| { - d.guides.push(Guide { artboard, ..Guide::new(vertical, pos) }); + d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) }); Ok(d.guides.len() - 1) })?; Ok(json!({ "index": i })) @@ -521,6 +535,11 @@ fn guide_list(s: &mut Session, _: &Value) -> Result { 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["shown"] = json!(st.doc.guide_shown(g)); + r["editable"] = json!(st.doc.guide_editable(g)); r }; Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect())) @@ -568,6 +587,28 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { Ok(json!({ "count": gone.len() })) } +/// Put ruler guide `index`, else the selected guides, on layer `layer`, in one undo step. +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(), + }; + if moving.is_empty() { + return Err(bad("guide.setLayer", "no guide selected (or give an index)")); + } + s.edit("Move Guide to Layer", |d, _| { + for i in &moving { + if let Some(g) = d.guides.get_mut(*i) { + g.layer = Some(layer); + } + } + Ok(()) + })?; + Ok(json!({ "count": moving.len() })) +} + /// Put ruler guide `index` at `pos`, else move (or copy) the selected guides, in one undo step. pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("index") { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index a80d3e9d..6905d2d2 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -462,6 +462,9 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result { for id in &ids { d.remove(*id)?; } + // The guides on the deleted layers go with them. + let orphans: Vec = d.guides.iter().enumerate().filter(|(_, g)| g.layer.is_some_and(|l| d.node(l).is_none())).map(|(i, _)| i).collect(); + d.retain_guides(sel, |i, _| !orphans.contains(&i)); sel.prune(d); Ok(()) })?; diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index d852915e..721b4717 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -785,7 +785,11 @@ fn ruler_guides_select_move_and_delete() { assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([0])); assert!(selected(&s).is_empty()); assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1])); - assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1], json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true})); + let layer = s.doc().unwrap().current_layer().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}) + ); assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err()); // A move takes each selected guide along its own axis, in one undo step. let n = undo_len(&s); @@ -866,11 +870,12 @@ fn artboard_guides_go_with_their_artboard() { s.execute("guide.add", &json!({"vertical": true, "pos": 1000, "artboard": 1})).unwrap(); 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; assert_eq!( list(&mut s), json!([ - {"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1}, - {"index": 1, "vertical": false, "pos": 100.0, "selected": false}, + {"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}, ]) ); let spans = |s: &Session| { @@ -951,12 +956,44 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() { s.set_tool_option("active", &json!(1)); 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; assert_eq!( s.execute("guide.list", &json!({})).unwrap()[1], - json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1}) + json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true}) ); } +/// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it, +/// and moved to another layer. +#[test] +fn ruler_guides_live_on_layers() { + let mut s = session(); + let first = s.doc().unwrap().current_layer().unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[0].layer, Some(first), "on the current layer"); + let second = id_of(&s.execute("layer.new", &json!({"name": "Guides"})).unwrap()); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(second), "the new layer is current"); + assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 1, "layer": 99999})).is_err()); + // Hidden or locked with its layer. + s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1]["shown"], false); + s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap(); + let row = s.execute("guide.list", &json!({})).unwrap()[1].clone(); + assert_eq!((row["shown"].clone(), row["editable"].clone()), (json!(true), json!(false))); + s.execute("layer.setProps", &json!({"ids": [second.0], "locked": false})).unwrap(); + // Moved to the first layer, then back. + s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).unwrap(); + 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()); + // 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)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 2); +} + /// #451: art moved with the Selection tool lands flush on the artboard's edges and centre (Smart /// Guides). #[test] diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 519c47ae..065fa41b 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -372,7 +372,13 @@ impl Targets { /// tools): they pull a point into line where they run. fn with_rulers(mut self, cx: &ToolContext) -> Self { if cx.guides { - self.rulers = cx.doc.guides.iter().map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }).collect(); + self.rulers = cx + .doc + .guides + .iter() + .filter(|g| cx.doc.guide_shown(g)) + .map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }) + .collect(); } self } diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index dccdeed1..d798e260 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -5,7 +5,8 @@ //! selected guides (Alt copies them). A dragged guide snaps ([`GuideSnap`]): with Shift to the //! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards' //! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its -//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides) can't be picked. +//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden +//! or locked layer, can't be picked. use serde_json::json; use vectorcraft_geom::Point; @@ -24,7 +25,7 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option { .guides .iter() .enumerate() - .filter(|(_, g)| cx.doc.guide_passes(g, p, tol)) + .filter(|(_, g)| cx.doc.guide_editable(g) && cx.doc.guide_passes(g, p, tol)) .map(|(i, g)| (i, off(g.pos, g.vertical))) .filter(|(_, d)| *d <= tol) .min_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0))) diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs index 41d86438..f1f31643 100644 --- a/crates/tools/src/slice.rs +++ b/crates/tools/src/slice.rs @@ -43,7 +43,7 @@ fn snap(cx: &ToolContext, p: Point) -> (Point, Vec) { cx.doc .guides .iter() - .filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_passes(g, p, tol)) + .filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_shown(g) && cx.doc.guide_passes(g, p, tol)) .min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs())) }; if let Some(g) = nearest(true, p.x) { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 16fc26a5..4e30d209 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -378,7 +378,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Selected guides in the selection colour. let picked = LineLook { color: t.selection, ..look }; let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides); - for (i, g) in doc.guides.iter().enumerate() { + // Guides on a hidden layer hide with it. + for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) { let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) }); // An artboard guide runs across its artboard, a canvas guide across the window. let (a, b) = match doc.guide_span(g) { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e3a0dda6..39ce8fc0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -732,3 +732,5 @@ lighting Light Světlo Spatter… Rozstřik… Sprayed Strokes… Stříkané tahy… Sumi-e… Sumi-e… + Vertical Guide Svislé vodítko + Horizontal Guide Vodorovné vodítko diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fd6bff03..cbfcfe72 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -362,6 +362,9 @@ Position Panel Above Text Bedienfeld über dem Text positionieren Clear Appearance Aussehen löschen Clear Guides Hilfslinien löschen + Vertical Guide Vertikale Hilfslinie + Horizontal Guide Horizontale Hilfslinie + Move Guide to Layer Hilfslinie auf Ebene verschieben Clear Overrides Abweichungen löschen Clear Recent Files Liste der zuletzt geöffneten Dateien leeren Click Klicken diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 22b4dc9b..58260ddd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4097,3 +4097,6 @@ lighting Light Luz Stroke Pressure Presión de pincelada Right Diagonal Diagonal derecha Left Diagonal Diagonal izquierda + Vertical Guide Guía vertical + Horizontal Guide Guía horizontal + Move Guide to Layer Mover guía a capa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0c4c1467..ae1b2a4d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4107,3 +4107,6 @@ lighting Light Lumière Stroke Pressure Pression des traits Right Diagonal Diagonale droite Left Diagonal Diagonale gauche + Vertical Guide Repère vertical + Horizontal Guide Repère horizontal + Move Guide to Layer Déplacer le repère vers le calque diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 070b9318..8d8ee1a5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4099,3 +4099,6 @@ lighting Light Luce Stroke Pressure Pressione pennellata Right Diagonal Diagonale destra Left Diagonal Diagonale sinistra + Vertical Guide Guida verticale + Horizontal Guide Guida orizzontale + Move Guide to Layer Sposta guida nel livello diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index bc7c5ac2..07e37509 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4092,3 +4092,6 @@ lighting Light ライト Stroke Pressure ストロークの筆圧 Right Diagonal 右斜め Left Diagonal 左斜め + Vertical Guide 垂直ガイド + Horizontal Guide 水平ガイド + Move Guide to Layer ガイドをレイヤーへ移動 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b7df5bd0..40d715df 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2790,3 +2790,5 @@ lighting Light Luz Spatter… Respingo… Sprayed Strokes… Traços Borrifados… Sumi-e… Sumi-e… + Vertical Guide Guia vertical + Horizontal Guide Guia horizontal diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b950958f..4489f231 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4095,3 +4095,6 @@ lighting Light Свет Stroke Pressure Нажим мазка Right Diagonal Диагональ вправо Left Diagonal Диагональ влево + Vertical Guide Вертикальная направляющая + Horizontal Guide Горизонтальная направляющая + Move Guide to Layer Переместить направляющую на слой diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8171d890..e9a7c547 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4078,3 +4078,6 @@ lighting Light Світло Stroke Pressure Натиск мазка Right Diagonal Діагональ праворуч Left Diagonal Діагональ ліворуч + Vertical Guide Вертикальна напрямна + Horizontal Guide Горизонтальна напрямна + Move Guide to Layer Перемістити напрямну на шар diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05d705b5..c4ed3cd1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3724,3 +3724,6 @@ lighting Light 光源 Stroke Pressure 描边压力 Right Diagonal 右对角线 Left Diagonal 左对角线 + Vertical Guide 垂直参考线 + Horizontal Guide 水平参考线 + Move Guide to Layer 将参考线移至图层 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e5e99980..2827e614 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3505,3 +3505,6 @@ lighting Light 光源 Stroke Pressure 筆觸壓力 Right Diagonal 右對角線 Left Diagonal 左對角線 + Vertical Guide 垂直參考線 + Horizontal Guide 水平參考線 + Move Guide to Layer 將參考線移至圖層 diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 87c6a0fc..c05500bf 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -220,6 +220,8 @@ struct Row { colour: Color32, /// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer. dim: bool, + /// A ruler guide on the layer `node` (its index in the document's guides): the guide's row. + guide: Option, } /// What the rows were listed from: listed again when any of it changes. @@ -239,7 +241,7 @@ fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str) let mask_layer = doc.mask_edit.map(|m| m.layer); let mut out = vec![]; for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { - push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query), &mut out); + push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, doc), &mut out); } out } @@ -251,10 +253,10 @@ fn push_rows( depth: usize, clip_path: bool, (colour, shown): (Color32, bool), - cx: (&OpenRows, &PanelOptions, &str), + cx: (&OpenRows, &PanelOptions, &str, &Document), out: &mut Vec, ) { - let (open_rows, opts, query) = cx; + let (open_rows, opts, query, doc) = cx; if opts.layers_only && !n.is_layer() { return; } @@ -266,11 +268,18 @@ fn push_rows( _ => colour, }; let shown = shown && n.visible; - let opens = opens(n, opts); let searching = !query.is_empty(); + // A layer's ruler guides are rows in it, above its art (not while searching). + let guides: Vec = if n.is_layer() && !opts.layers_only && !searching { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] }; + let opens = opens(n, opts) || !guides.is_empty(); let open = opens && (searching || open_rows.contains(n.id)); let at = out.len(); - out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template() }); + out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template(), guide: None }); + if open { + for i in guides.into_iter().rev() { + out.push(Row { node: n.clone(), depth: depth + 1, clip_path: false, opens: false, open: false, colour, dim: !shown, guide: Some(i) }); + } + } if open && let Some(children) = n.children() { for (i, c) in children.iter().enumerate().rev() { push_rows(c, depth + 1, i == 0 && n.clips(), (colour, shown), cx, out); @@ -302,6 +311,8 @@ fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions, struct View<'a> { doc: &'a Document, sel: HashSet, + /// The selected ruler guides. + guides: Vec, target: Option, current: Option, rows: Vec, @@ -362,6 +373,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let view = View { doc: &doc, sel: st.selection.objects.iter().copied().collect(), + guides: st.selection.guides.clone(), target: st.selection.target, current: st.current_layer(), rows, @@ -542,8 +554,47 @@ 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. +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 Some(g) = view.doc.guides.get(i) else { return }; + let selected = view.guides.contains(&i); + if resp.hovered() { + 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)); + 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; + // The guide mark: a short line across or down, in the guides' colour. + let c = egui::pos2(x + 8.0, r.center().y); + let half = if g.vertical { vec2(0.0, 6.0) } else { vec2(6.0, 0.0) }; + ui.painter().line_segment([c - half, c + half], Stroke::new(1.5, t.accent)); + x += 22.0; + let name = format!("<{}>", if g.vertical { tl!("Vertical Guide") } else { tl!("Horizontal Guide") }); + let color = if item.dim { t.text_dim } else { t.text }; + ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), color); + let col_x = r.right() - 43.5; + ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); + if selected { + let q = egui::Rect::from_center_size(egui::pos2(r.right() - SQUARE_X, r.center().y), vec2(7.0, 7.0)); + ui.painter().rect_filled(q, 0.0, item.colour); + ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside); + } + if resp.clicked() { + let m = ui.input(|i| i.modifiers); + out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command}))); + } +} + /// The widgets of row `item`. fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { + if let Some(i) = item.guide { + return guide_row(ui, view, item, i, out); + } let n = &*item.node; let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour); let doc = view.doc; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 36cd8364..2f1be11d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -256,6 +256,11 @@ Constrain acts as Shift held (proportional scaling, moves and rotations by 45°) where it is greyed. The modifier keys still work while dragging (Cmd on a corner distorts it freely, Cmd+Alt+Shift in 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`. + 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 floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own;