mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Align: Align to Key Object makes the topmost selected object the key
With two or more objects selected and no key object, a click on Align to Key Object in the Align panel stored Key as the Align To choice and set no key. The align buttons of the Align panel, the Control bar and the Properties panel then ran object.align with to: "key", which fails with "no key object". Distribute Spacing sent the panel's spacing value and kept the leftmost or top object in place. The stored Key stayed until Align to Selection, Align to Artboard or Cancel Key Object was chosen. The button now runs select.key on the topmost selected object, the last in paint order, whatever order the objects were selected in, and the canvas draws that object with the key-object outline. The align buttons in all three places and Distribute Spacing then use that key, and Cancel Key Object clears it. A key that is already set stays the key. With fewer than two objects selected the button is disabled, as the Distribute buttons are. The button stores no choice, so once the key is cleared by Cancel Key Object, by a click on the key object or by another selection, Align To shows the stored Selection or Artboard choice again. Cancel Key Object no longer sets that choice to Selection. Docs: docs/target-app-parity.md.
This commit is contained in:
1 parent
138d9499a7
commit
208195765c
2 files changed
+127
-11
No files matched your search
@@ -77,8 +77,14 @@ fn has_key(app: &VectorcraftApp) -> bool {
|
||||
app.session.active().is_some_and(|st| st.selection.key.is_some())
|
||||
}
|
||||
|
||||
/// The topmost selected object, the last in paint order.
|
||||
fn topmost(app: &VectorcraftApp) -> Option<vectorcraft_doc::NodeId> {
|
||||
app.session.active().and_then(|st| st.selection.in_paint_order(&st.doc).last().copied())
|
||||
}
|
||||
|
||||
/// What Align aligns to: the key object while there is one (a click on an object of the
|
||||
/// selection makes it the key), else the Align To choice.
|
||||
/// selection makes it the key, and Align to Key Object makes the topmost one the key), else the
|
||||
/// stored Align To choice, Selection or Artboard.
|
||||
fn align_to(app: &VectorcraftApp, ctx: &egui::Context) -> AlignTo {
|
||||
if has_key(app) { AlignTo::Key } else { pstate(ctx, "align-to") }
|
||||
}
|
||||
@@ -145,11 +151,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
(AlignTo::Key, "dc-alignto-key", tl!("Align to Key Object")),
|
||||
(AlignTo::Artboard, "dc-alignto-artboard", tl!("Align to Artboard")),
|
||||
] {
|
||||
if widgets::icon_button(ui, icon, tl!(tip), to == v, 28.0).clicked() {
|
||||
set_pstate(ui.ctx(), "align-to", v);
|
||||
// Aligning to the selection or the artboard lets go of the key object.
|
||||
if v != AlignTo::Key && has_key(app) {
|
||||
app.run("select.key", json!({})).ok();
|
||||
// A key object is one of two or more selected objects.
|
||||
let on = v != AlignTo::Key || n >= 2;
|
||||
if widgets::icon_button_enabled(ui, icon, tl!(tip), to == v, on, 28.0).clicked() {
|
||||
if v == AlignTo::Key {
|
||||
// The topmost selected object becomes the key object, unless
|
||||
// there is one already. The Align To choice stays as it was, and
|
||||
// Align To shows it again once the key is cleared.
|
||||
if !has_key(app)
|
||||
&& let Some(id) = topmost(app)
|
||||
{
|
||||
app.run("select.key", json!({"id": id.0})).ok();
|
||||
}
|
||||
} else {
|
||||
set_pstate(ui.ctx(), "align-to", v);
|
||||
// Aligning to the selection or the artboard lets go of the key object.
|
||||
if has_key(app) {
|
||||
app.run("select.key", json!({})).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,8 +186,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if menu_item(ui, tl!("Use Preview Bounds"), true, pb) {
|
||||
super::transform::set_pref(app, "usePreviewBounds", !pb);
|
||||
}
|
||||
// Cancel Key Object clears the key, and Align To shows its stored choice again.
|
||||
if menu_item(ui, tl!("Cancel Key Object"), align_to(app, ui.ctx()) == AlignTo::Key, false) {
|
||||
set_pstate(ui.ctx(), "align-to", AlignTo::Selection);
|
||||
app.run("select.key", json!({})).ok();
|
||||
}
|
||||
menu_item(ui, tl!("Align to Glyph Bounds"), false, false);
|
||||
@@ -176,7 +195,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::tests_appearance::{click, frame_events, text_rect};
|
||||
use super::*;
|
||||
use crate::toolbar::tests::widget_rects;
|
||||
|
||||
#[test]
|
||||
fn single_object_aligns_to_artboard() {
|
||||
@@ -187,9 +208,9 @@ mod tests {
|
||||
assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "artboard");
|
||||
}
|
||||
|
||||
/// A Shift-click that leaves the key object selected on its own lets the key go. With Align to
|
||||
/// Key Object chosen, the align buttons of the Control bar and the Properties panel, and then
|
||||
/// the Align panel's, align that object to the artboard.
|
||||
/// A Shift-click that leaves the key object selected on its own lets the key go, and the align
|
||||
/// buttons of the Control bar and the Properties panel, and then the Align panel's, align that
|
||||
/// object to the artboard.
|
||||
#[test]
|
||||
fn a_lone_object_aligns_to_the_artboard() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
@@ -197,7 +218,6 @@ mod tests {
|
||||
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
let ctx = egui::Context::default();
|
||||
set_pstate(&ctx, "align-to", AlignTo::Key);
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
app.run("select.key", json!({"id": b})).unwrap();
|
||||
app.run("select.toggle", json!({"id": a})).unwrap();
|
||||
@@ -244,6 +264,101 @@ mod tests {
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Selection, "no key: the choice again");
|
||||
}
|
||||
|
||||
/// Two rectangles, the back one at x 10 and the front one at x 80, selected front first →
|
||||
/// (the app, the back one's id, the front one's id).
|
||||
fn back_and_front() -> (VectorcraftApp, Value, Value) {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let back = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let front = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [front, back]})).unwrap();
|
||||
(app, back, front)
|
||||
}
|
||||
|
||||
/// One frame of the Align panel → its Align To buttons (Selection, Key Object, Artboard) and
|
||||
/// its Horizontal Align Left button.
|
||||
fn panel_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> ([egui::Rect; 3], egui::Rect) {
|
||||
frame_events(ctx, app, vec![], show);
|
||||
// The 28-point buttons are Distribute Spacing's two and then Align To's three.
|
||||
let mut small = widget_rects(ctx, egui::vec2(28.0, 28.0));
|
||||
small.sort_by(|a, b| a.left().total_cmp(&b.left()));
|
||||
let [.., selection, key, artboard] = small[..] else { panic!("no Align To buttons in {small:?}") };
|
||||
let objects = widget_rects(ctx, egui::vec2(32.0, 32.0));
|
||||
let left = objects.iter().copied().filter(|r| r.top() == objects[0].top()).min_by(|a, b| a.left().total_cmp(&b.left())).unwrap();
|
||||
([selection, key, artboard], left)
|
||||
}
|
||||
|
||||
/// The left edge of object `id`.
|
||||
fn left_edge(app: &VectorcraftApp, id: &Value) -> f64 {
|
||||
let id = vectorcraft_doc::NodeId(id.as_u64().unwrap());
|
||||
app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0
|
||||
}
|
||||
|
||||
/// With two objects selected and no key object, Align to Key Object makes the topmost one the
|
||||
/// key, whatever order they were selected in, and Align then aligns to it. A key already set
|
||||
/// stays the key.
|
||||
#[test]
|
||||
fn align_to_key_object_makes_the_topmost_object_the_key() {
|
||||
let (mut app, back, front) = back_and_front();
|
||||
let ctx = egui::Context::default();
|
||||
let ([_, key, _], left) = panel_buttons(&mut app, &ctx);
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
let key_object = |app: &VectorcraftApp| app.session.active().unwrap().selection.key.map(|k| k.0);
|
||||
assert_eq!(key_object(&app), front.as_u64(), "the front rectangle, selected first");
|
||||
app.run("select.set", json!({"ids": [back, front]})).unwrap();
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
assert_eq!(key_object(&app), front.as_u64(), "the front rectangle, selected last");
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Key);
|
||||
click(&ctx, &mut app, left.center(), show);
|
||||
assert_eq!((left_edge(&app, &back), left_edge(&app, &front)), (80.0, 80.0), "to the key's left edge: {}", app.ui.status);
|
||||
app.run("select.key", json!({"id": back})).unwrap();
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
assert_eq!(key_object(&app), back.as_u64(), "a key already set");
|
||||
}
|
||||
|
||||
/// Align to Key Object stores no Align To choice: once the key object is cleared, by Cancel
|
||||
/// Key Object, by a click on the key or by another selection, Align To shows the stored
|
||||
/// choice again.
|
||||
#[test]
|
||||
fn align_to_shows_its_choice_again_once_the_key_is_cleared() {
|
||||
let (mut app, back, front) = back_and_front();
|
||||
let ctx = egui::Context::default();
|
||||
let ([_, key, artboard], _) = panel_buttons(&mut app, &ctx);
|
||||
click(&ctx, &mut app, artboard.center(), show);
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Key);
|
||||
let cancel = text_rect(&frame_events(&ctx, &mut app, vec![], menu), " Cancel Key Object");
|
||||
click(&ctx, &mut app, cancel.center(), menu);
|
||||
assert!(!has_key(&app));
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Artboard, "Cancel Key Object");
|
||||
// The Selection tool's click on the key object runs `select.key {}`.
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
assert!(has_key(&app));
|
||||
app.run("select.key", json!({})).unwrap();
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Artboard, "a click on the key");
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
assert!(has_key(&app));
|
||||
app.run("select.set", json!({"ids": [back, front]})).unwrap();
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Artboard, "another selection");
|
||||
}
|
||||
|
||||
/// With one object selected, Align to Key Object is disabled: a click sets no key and keeps the
|
||||
/// Align To choice, and Horizontal Align Left moves the object to the artboard's left edge.
|
||||
#[test]
|
||||
fn align_to_key_object_needs_two_selected_objects() {
|
||||
let (mut app, _, front) = back_and_front();
|
||||
app.run("select.set", json!({"ids": [front]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let ([_, key, _], left) = panel_buttons(&mut app, &ctx);
|
||||
let clickable = ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).any(|w| w.rect == key && w.sense.senses_click()));
|
||||
assert!(!clickable, "disabled");
|
||||
click(&ctx, &mut app, key.center(), show);
|
||||
assert_eq!(app.session.active().unwrap().selection.key, None);
|
||||
assert_eq!(align_to(&app, &ctx), AlignTo::Selection);
|
||||
click(&ctx, &mut app, left.center(), show);
|
||||
assert_eq!(left_edge(&app, &front), 0.0, "the artboard's left edge");
|
||||
}
|
||||
|
||||
/// #789: the Control bar's and the Properties panel's align buttons follow Align To too.
|
||||
#[test]
|
||||
fn the_other_align_buttons_align_to_the_align_to_choice() {
|
||||
|
||||
@@ -397,6 +397,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
A key object needs another selected object outside it. A Shift-click or any other change that leaves one object selected clears the key, and `select.key` sets none on one object or on a group selected with only its own members. With one object selected, the Align panel, the Control bar and the Properties panel align it to the artboard whatever Align To is set to.
|
||||
With two or more objects selected and no key object, Align to Key Object makes the topmost of them the key, whatever order they were selected in, and the Align panel, the Control bar and the Properties panel align to it. Align To keeps its Selection or Artboard choice and shows it again once the key is cleared.
|
||||
Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784).
|
||||
Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself.
|
||||
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
|
||||
|
||||
Reference in new issue
Block a user