Merge pull request #1021 from redrun6767/feat/guides-on-layers

Guides: ruler guides as layer objects
This commit is contained in:
BFlat 2026-10-11 06:04:04 +07:00 committed by GitHub
commit 3ee9543b3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 499 additions and 27 deletions

View File

@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). |
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). |
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |

View File

@ -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<u32>,
/// 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<NodeId>,
}
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,73 @@ 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) })
}
/// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has.
fn guide_layer(&self, g: &Guide) -> Option<NodeId> {
g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer))
}
/// 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 {
self.guide_layer(g).is_none_or(|l| 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 {
self.guide_layer(g).is_none_or(|l| self.is_editable(l))
}
/// Put the ruler guides on no layer this document has (from files saved before guides had
/// layers, or of layers merged into `layer`) on layer `layer`. Returns how many.
pub fn rehome_guides(&mut self, layer: NodeId) -> usize {
let lost: Vec<bool> = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect();
let mut n = 0;
for (g, lost) in self.guides.iter_mut().zip(lost) {
if lost {
g.layer = Some(layer);
n += 1;
}
}
n
}
/// 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 {
self.default_layer().map_or(0, |home| self.rehome_guides(home))
}
/// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in
/// step. Guides on no layer stay. Returns how many went.
pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize {
let gone: Vec<bool> = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect();
self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false))
}
/// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made
/// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies
/// the objects on it. Returns how many.
pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize {
let layers = |n: &Node| {
let mut out = vec![];
n.walk(&mut |c| {
if c.is_layer() {
out.push(c.id);
}
});
out
};
let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect();
let copies: Vec<Guide> = self
.guides
.iter()
.filter_map(|g| {
let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1;
Some(Guide { layer: Some(to), ..g.clone() })
})
.collect();
let n = copies.len();
self.guides.extend(copies);
n
}
/// 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))
}
/// 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 };

View File

@ -125,7 +125,8 @@ impl Selection {
self.target = None;
}
self.slices.retain(|id| doc.is_slice(*id));
self.guides.retain(|i| *i < doc.guides.len());
// Guides on a hidden or locked layer can't stay selected.
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
}
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
/// (the art of its sublayers too), anything else is selected itself.
@ -206,6 +207,36 @@ 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);
// 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");
// A guide saved before guides had layers reads as one on no layer.
let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap();
assert_eq!(old.layer, None);
assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written");
}
#[test]
fn selection_changes_clear_the_target() {
let mut s = Selection::default();

View File

@ -160,7 +160,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
guides_unlocked,
guide_move
),
cmd!(
"guide.setLayer",
"Move Guide to Layer",
[],
None,
"{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
),
cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all),
cmd!(
"file.documentColorMode",
@ -507,8 +516,15 @@ fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
}
None => None,
};
let st = s.doc()?;
let layer = match id_param(p, "layer") {
Some(l) if st.doc.node(l).is_some_and(Node::is_layer) => Some(l),
Some(_) => return Err(bad("guide.add", "layer must name a layer or sublayer")),
// Where new art goes (refused when no layer is shown and unlocked), or that group's layer.
None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }),
};
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 +537,11 @@ fn guide_list(s: &mut Session, _: &Value) -> Result<Value> {
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()))
@ -535,6 +556,16 @@ fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
Ok(i)
}
/// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer.
fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
let i = guide_index(s, v, cmd)?;
let d = &s.doc()?.doc;
if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) {
return Err(EngineError::Other("the guide is on a hidden or locked layer".into()));
}
Ok(i)
}
fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?;
let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::<Result<Vec<_>>>()?;
@ -554,7 +585,7 @@ fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
/// Delete ruler guide `index`, else the selected guides, in one undo step → {count}.
pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
let gone: Vec<usize> = match p.get("index") {
Some(v) => vec![guide_index(s, Some(v), "guide.remove")?],
Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?],
None => s.doc()?.selection.guides.clone(),
};
if gone.is_empty() {
@ -568,10 +599,39 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
const C: &str = "guide.setLayer";
let d = &s.doc()?.doc;
let layer =
id_param(p, "layer").filter(|l| d.node(*l).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "layer must name a layer or sublayer"))?;
// As objects dragged in the Layers panel: never onto a locked layer.
if super::layer::locked_within(d, layer) {
return Err(EngineError::Other("the layer or group is locked".into()));
}
let moving: Vec<usize> = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) {
(Some(v), _) => vec![editable_guide_index(s, Some(v), C)?],
(None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::<Result<Vec<_>>>()?,
(None, None) => s.doc()?.selection.guides.clone(),
};
if moving.is_empty() {
return Err(bad(C, "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<Value> {
if let Some(v) = p.get("index") {
let i = guide_index(s, Some(v), "guide.move")?;
let i = editable_guide_index(s, Some(v), "guide.move")?;
let pos = f64_req(p, "pos", "guide.move")?;
s.edit("Move Guide", |d, _| {
if let Some(g) = d.guides.get_mut(i) {

View File

@ -221,7 +221,7 @@ pub(crate) fn accepts(d: &Document, parent: Option<NodeId>, n: &Node) -> bool {
}
/// Whether `id` or a container around it is locked.
fn locked_within(d: &Document, id: NodeId) -> bool {
pub(crate) fn locked_within(d: &Document, id: NodeId) -> bool {
d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked))
}
@ -462,6 +462,8 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
d.remove(*id)?;
}
// The guides on the deleted layers go with them.
d.drop_guides_of_deleted_layers(sel);
sel.prune(d);
Ok(())
})?;
@ -487,6 +489,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
let mut c = d.reid(&n);
if n.is_layer() {
c.name = Some(format!("{} copy", n.display_name()));
d.copy_layer_guides(&n, &c);
}
out.push(d.insert(par, idx + 1, c)?);
}
@ -679,7 +682,11 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
nodes.push(if copy {
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
d.reid(&n)
let c = d.reid(&n);
if n.is_layer() {
d.copy_layer_guides(&n, &c);
}
c
} else {
Arc::unwrap_or_clone(d.remove(*id)?)
});

View File

@ -263,6 +263,8 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
all.extend(taken);
all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id)));
*ch = all;
// The merged layers' guides go to it too.
d.rehome_guides(into);
Ok(())
}
@ -311,6 +313,8 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
for h in &hidden {
d.remove(*h)?;
}
// Hidden layers are discarded with their guides.
d.drop_guides_of_deleted_layers(sel);
merge_into(d, into, &others)?;
sel.prune(d);
Ok(())

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();

View File

@ -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| {
@ -909,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]));
@ -951,12 +959,120 @@ 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})
);
}
/// 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]
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());
// 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)]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s).len(), 2);
}
/// Guides on a hidden or locked layer leave the selection and can't be moved, deleted or moved to
/// another layer; new guides skip a locked current layer as new art does; no guide goes onto a
/// locked layer.
#[test]
fn guides_on_hidden_or_locked_layers_are_left_alone() {
let mut s = session();
let first = s.doc().unwrap().current_layer().unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
s.execute("guide.select", &json!({"indexes": [0, 1]})).unwrap();
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": true})).unwrap();
assert_eq!(selected_guides(&s), [1], "the locked layer's guide is deselected");
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([]), "and can't be selected");
assert!(s.execute("guide.move", &json!({"index": 0, "pos": 50})).is_err());
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
assert!(s.execute("guide.setLayer", &json!({"index": 0, "layer": second.0})).is_err());
assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).is_err(), "not onto a locked layer");
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
assert!(selected_guides(&s).is_empty(), "the hidden layer's guide is deselected");
// Both layers hidden or locked: a new guide is refused, as new art is.
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 10})).is_err());
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap();
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": false})).unwrap();
// The current layer locked: the new guide goes on the top shown, unlocked layer.
s.execute("guide.add", &json!({"vertical": true, "pos": 20})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides[2].layer, Some(first));
assert_eq!(guides(&s).len(), 3);
}
/// Guides follow their layers through Duplicate, Merge Selected and Flatten Artwork, in one undo
/// step each.
#[test]
fn guides_go_with_their_layers_when_duplicated_merged_and_flattened() {
let mut s = session();
let first = s.doc().unwrap().current_layer().unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).map(|(_, g)| (g.vertical, g.pos)).collect::<Vec<_>>();
// Duplicate Layer copies its guides onto the copy.
let copy = s.execute("layer.duplicate", &json!({"ids": [second.0]})).unwrap()["ids"][0].as_u64().map(NodeId).unwrap();
assert_eq!((on(&s, second), on(&s, copy)), (vec![(false, 300.0)], vec![(false, 300.0)]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s).len(), 2);
// Merged into the first layer, the second layer's guide goes with its art.
s.execute("layer.merge", &json!({"ids": [second.0, first.0]})).unwrap();
assert_eq!(on(&s, first), [(true, 200.0), (false, 300.0)]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((on(&s, first), on(&s, second)), (vec![(true, 200.0)], vec![(false, 300.0)]));
// Flattened into the first layer, a hidden layer is discarded with its guides.
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
s.execute("layer.flatten", &json!({"id": first.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]

View File

@ -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
}

View File

@ -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<usize> {
.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)))

View File

@ -43,7 +43,7 @@ fn snap(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
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) {

View File

@ -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) {

View File

@ -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

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -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
@ -3657,6 +3660,7 @@ theme Appearance Erscheinungsbild
@msg the image's pixels don't match its size die Pixel des Bilds passen nicht zu seiner Größe
@msg the layer has no objects to clip by die Ebene hat keine Objekte, an denen beschnitten werden könnte
@msg the layer or group is locked die Ebene oder Gruppe ist gesperrt
@msg the guide is on a hidden or locked layer die Hilfslinie liegt auf einer ausgeblendeten oder gesperrten Ebene
@msg the target layer is locked or hidden die Zielebene ist gesperrt oder ausgeblendet
@msg the layer to flatten into is hidden die Ebene, auf die reduziert werden soll, ist ausgeblendet
@msg the object already has an opacity mask das Objekt hat bereits eine Deckkraftmaske

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3760,6 +3760,7 @@ axis Both Ambos
@msg the image's pixels don't match its size los píxeles de la imagen no coinciden con su tamaño
@msg the layer has no objects to clip by la capa no tiene objetos con los que recortar
@msg the layer or group is locked la capa o el grupo está bloqueado
@msg the guide is on a hidden or locked layer la guía está en una capa oculta o bloqueada
@msg the target layer is locked or hidden la capa de destino está bloqueada u oculta
@msg the layer to flatten into is hidden la capa en la que acoplar está oculta
@msg the object already has an opacity mask el objeto ya tiene una máscara de opacidad
@ -4097,3 +4098,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

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3757,6 +3757,7 @@ axis Both Les deux
@msg the image's pixels don't match its size les pixels de l’image ne correspondent pas à sa taille
@msg the layer has no objects to clip by le calque n’a aucun objet servant à l’écrêtage
@msg the layer or group is locked le calque ou le groupe est verrouillé
@msg the guide is on a hidden or locked layer le repère se trouve sur un calque masqué ou verrouillé
@msg the target layer is locked or hidden le calque cible est verrouillé ou masqué
@msg the layer to flatten into is hidden le calque de destination de l’aplatissement est masqué
@msg the object already has an opacity mask l’objet a déjà un masque d’opacité
@ -4107,3 +4108,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

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3760,6 +3760,7 @@ axis Both Entrambi
@msg the image's pixels don't match its size i pixel dell'immagine non corrispondono alle sue dimensioni
@msg the layer has no objects to clip by il livello non ha oggetti con cui ritagliare
@msg the layer or group is locked il livello o il gruppo è bloccato
@msg the guide is on a hidden or locked layer la guida si trova su un livello nascosto o bloccato
@msg the target layer is locked or hidden il livello di destinazione è bloccato o nascosto
@msg the layer to flatten into is hidden il livello in cui appiattire è nascosto
@msg the object already has an opacity mask l'oggetto ha già una maschera di opacità
@ -4099,3 +4100,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

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3498,6 +3498,7 @@ theme Appearance 外観
@msg no selection bounds 選択範囲に境界がありません
@msg the layer has no objects to clip by レイヤーにクリッピングマスクとして使えるオブジェクトがありません
@msg the layer or group is locked レイヤーまたはグループがロックされています
@msg the guide is on a hidden or locked layer ガイドが非表示またはロックされたレイヤー上にあります
@msg the target layer is locked or hidden ターゲットレイヤーがロックされているか非表示です
@msg the layer to flatten into is hidden 統合先のレイヤーが非表示になっています
@msg there are no objects to release レイヤーに分配するオブジェクトがありません
@ -4092,3 +4093,6 @@ lighting Light ライト
Stroke Pressure ストロークの筆圧
Right Diagonal 右斜め
Left Diagonal 左斜め
Vertical Guide 垂直ガイド
Horizontal Guide 水平ガイド
Move Guide to Layer ガイドをレイヤーへ移動

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3756,6 +3756,7 @@ axis Both Оба
@msg the image's pixels don't match its size пиксели изображения не соответствуют его размеру
@msg the layer has no objects to clip by в слое нет объектов для обрезки
@msg the layer or group is locked слой или группа заблокированы
@msg the guide is on a hidden or locked layer направляющая находится на скрытом или заблокированном слое
@msg the target layer is locked or hidden целевой слой заблокирован или скрыт
@msg the layer to flatten into is hidden слой для объединения скрыт
@msg the object already has an opacity mask у объекта уже есть непрозрачная маска
@ -4095,3 +4096,6 @@ lighting Light Свет
Stroke Pressure Нажим мазка
Right Diagonal Диагональ вправо
Left Diagonal Диагональ влево
Vertical Guide Вертикальная направляющая
Horizontal Guide Горизонтальная направляющая
Move Guide to Layer Переместить направляющую на слой

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3743,6 +3743,7 @@ axis Both Обидві
@msg the image's pixels don't match its size пікселі зображення не відповідають його розміру
@msg the layer has no objects to clip by у шарі немає об’єктів для обрізки
@msg the layer or group is locked шар чи група заблоковані
@msg the guide is on a hidden or locked layer напрямна розташована на прихованому або заблокованому шарі
@msg the target layer is locked or hidden цільовий шар заблокований або прихований
@msg the layer to flatten into is hidden шар для об’єднання прихований
@msg the object already has an opacity mask об’єкт вже має маску непрозорості.
@ -4078,3 +4079,6 @@ lighting Light Світло
Stroke Pressure Натиск мазка
Right Diagonal Діагональ праворуч
Left Diagonal Діагональ ліворуч
Vertical Guide Вертикальна напрямна
Horizontal Guide Горизонтальна напрямна
Move Guide to Layer Перемістити напрямну на шар

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -3724,3 +3724,6 @@ lighting Light 光源
Stroke Pressure 描边压力
Right Diagonal 右对角线
Left Diagonal 左对角线
Vertical Guide 垂直参考线
Horizontal Guide 水平参考线
Move Guide to Layer 将参考线移至图层

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3505,3 +3505,6 @@ lighting Light 光源
Stroke Pressure 筆觸壓力
Right Diagonal 右對角線
Left Diagonal 左對角線
Vertical Guide 垂直參考線
Horizontal Guide 水平參考線
Move Guide to Layer 將參考線移至圖層

Can't render this file because it contains an unexpected character in line 6 and column 47.

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`.
@ -220,6 +223,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<usize>,
}
/// What the rows were listed from: listed again when any of it changes.
@ -239,7 +244,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 +256,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<Row>,
) {
let (open_rows, opts, query) = cx;
let (open_rows, opts, query, doc) = cx;
if opts.layers_only && !n.is_layer() {
return;
}
@ -266,11 +271,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<usize> = 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 +314,8 @@ fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions,
struct View<'a> {
doc: &'a Document,
sel: HashSet<NodeId>,
/// The selected ruler guides.
guides: Vec<usize>,
target: Option<NodeId>,
current: Option<NodeId>,
rows: Vec<NodeId>,
@ -362,6 +376,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,
@ -537,13 +552,77 @@ 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,
}
}
/// 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. A guide on a hidden or locked layer can't be selected or dragged.
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_and_drag());
let Some(g) = view.doc.guides.get(i) else { return };
let selected = view.guides.contains(&i);
let editable = view.doc.guide_editable(g);
if editable && 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 editable && resp.clicked() {
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 editable && 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`.
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;
@ -777,6 +856,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();
@ -791,7 +877,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 {
@ -811,6 +897,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})));
}
@ -1426,6 +1513,42 @@ mod tests {
assert_eq!(d.layers.len(), 1);
}
/// A layer's ruler guides are rows in it, above its art: a click selects the guide, a drag onto
/// another layer's row moves it there, and a guide on a locked layer is left alone.
#[test]
fn guide_rows_select_and_move_their_guides() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}));
run(&mut app, "guide.add", json!({"vertical": true, "pos": 50}));
run(&mut app, "select.none", json!({}));
let second = NodeId(run(&mut app, "layer.new", json!({}))["id"].as_u64().unwrap());
expand(&mut app, &json!({})).unwrap();
let ctx = egui::Context::default();
// Rows top down: Layer 2, Layer 1, its guide (no target circle), the rectangle.
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 3);
let guide_row = egui::pos2(150.0, (circles[1].y + circles[2].y) / 2.0);
let guides = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone();
click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE);
assert_eq!(guides(&app), [0], "its row selects it");
// Dragged onto Layer 2's row, it goes on Layer 2, in one undo step.
let undo = app.session.active().unwrap().history.undo.len();
drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[0].y), false);
let st = app.session.active().unwrap();
assert_eq!((st.doc.guides[0].layer, st.history.undo.len()), (Some(second), undo + 1));
// Now under Layer 2: locked, its row neither selects nor drags it.
run(&mut app, "layer.setProps", json!({"ids": [second.0], "locked": true}));
assert!(guides(&app).is_empty(), "deselected with its layer locked");
let (circles, _) = frame(&mut app, &ctx, vec![], false);
let guide_row = egui::pos2(150.0, (circles[0].y + circles[1].y) / 2.0);
click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE);
assert!(guides(&app).is_empty());
drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[2].y), false);
assert_eq!(app.session.active().unwrap().doc.guides[0].layer, Some(second));
}
/// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows
/// holding selected art).
fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) {

View File

@ -256,6 +256,14 @@ 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, 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 on a hidden or locked layer leave the selection
and can't be picked, moved or deleted, and no guide goes onto a locked layer; Duplicate copies a layer's guides, Merge
Selected and Flatten Artwork keep them on the merged layer (Flatten discards a hidden layer's). 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
floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own;

View File

@ -411,6 +411,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel.
- **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.
- **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).
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them).

View File

@ -90,7 +90,8 @@ tools, never checked side by side) isn't captured row by row.
| Snapping panel/popover consolidating the snap options (30.0) | missing | |
| Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only |
| Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451 |
| Ruler guides as Layers rows; Alt swaps a ruler guide's orientation while dragging | missing | |
| Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer |
| Alt swaps a ruler guide's orientation while dragging | missing | |
| Global and video rulers | missing | menu stubs |
## Transform handles, bounding box, Free Transform