From 44685d8798bfeeea897cf291562bf2dc32b118d6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 11 Oct 2026 19:54:24 -0700 Subject: [PATCH] =?UTF-8?q?Align:=20Object=20=E2=80=BA=20Align=20follows?= =?UTF-8?q?=20Align=20To,=20and=20the=20choice=20is=20kept=20across=20rest?= =?UTF-8?q?arts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Align ran object.align without to, so it aligned to the key object while there was one and otherwise to the selection. With Align to Artboard chosen it aligned two objects to the selection's bounds, and it left a single selected object where it was, while the align buttons of the Align panel, the Control bar and the Properties panel move that object to the active artboard. The Align To choice was kept in egui's temporary memory, so it went back to Align to Selection at each launch, and ui.inspect did not report it. The choice is now the UiState field align_to, which the desktop app saves with its preferences and ui.inspect reports. A saved value other than "artboard" reads as Selection, and the other preferences still load. Object › Align now sends the to that the align buttons send: the artboard when one object is selected, else the key object while there is one, else the Align To choice. With anchor points selected it sends its parameters as before, and the points align to their own bounds. While one object is selected, the Align To row shows Artboard. Agents' object.align calls without to keep the engine's default. Docs: docs/control-protocol.md, docs/target-app-parity.md. --- crates/ui-egui/src/menus.rs | 7 ++ crates/ui-egui/src/panels/align.rs | 173 ++++++++++++++++++++++++----- crates/ui-egui/src/state.rs | 5 + docs/control-protocol.md | 8 ++ docs/target-app-parity.md | 1 + 5 files changed, 167 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f3f8da39..26884321 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3217,6 +3217,13 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { app.open_link(id); return; } + // Object → Align aligns to what the align buttons align to, unless the call names `to`. With + // anchors selected (Direct Selection) it sends its params unchanged, and the points align to + // their own bounds. + let follows_align_to = id == "object.align" + && p.as_object().is_some_and(|o| !o.contains_key("to")) + && app.session.active().is_some_and(|st| st.selection.anchors.is_empty()); + let p = if follows_align_to { crate::panels::align::with_align_to(app, p) } else { p }; if let Err(e) = app.run(id, p) { app.status(e); } else if matches!(id, "object.pattern.make" | "object.pattern.edit") { diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index a24f191f..759a2ca9 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -2,13 +2,17 @@ //! aligning to a key object) and Align To (selection / key object / artboard). use egui::Ui; +use serde::Serialize; use serde_json::{Value, json}; use super::{pstate, selection_len, set_pstate}; use crate::VectorcraftApp; use crate::widgets::{self, menu_item}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +/// What Align aligns to. The Align To choice kept with the UI state ([`crate::UiState::align_to`]) +/// is Selection or Artboard: Align to Key Object stores no choice. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] pub enum AlignTo { #[default] Selection, @@ -26,14 +30,34 @@ impl AlignTo { } } +/// Serde reader for the saved Align To choice ([`crate::UiState::align_to`]): `"artboard"` is +/// Artboard, and any other value, `"key"` included, is Selection. +pub fn deserialize_align_to<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = ::deserialize(d)?; + Ok(if v == "artboard" { AlignTo::Artboard } else { AlignTo::Selection }) +} + +/// What Align aligns to with `n_selected` objects selected: the artboard for a single object, else +/// `to`. +fn aligns_to(to: AlignTo, n_selected: usize) -> AlignTo { + if n_selected == 1 { AlignTo::Artboard } else { to } +} + /// `object.align` params: a single object aligns to the artboard (Illustrator does the same). pub fn align_params(base: Value, to: AlignTo, n_selected: usize) -> Value { let mut p = base; - let to = if n_selected == 1 { AlignTo::Artboard } else { to }; - p["to"] = json!(to.param()); + p["to"] = json!(aligns_to(to, n_selected).param()); p } +/// `object.align` params `base` with the `to` that the align buttons of the Align panel, the Control +/// bar and the Properties panel send: the artboard for a single selected object, else the key object +/// while there is one, else the Align To choice. Object → Align sends it too while every selected +/// object is selected whole ([`crate::menus::invoke`]). +pub fn with_align_to(app: &VectorcraftApp, base: Value) -> Value { + align_params(base, align_to(app), selection_len(app)) +} + pub const ALIGN: [(&str, &str, &str, &str); 6] = [ ("dc-al-left", "Horizontal Align Left", "horizontal", "left"), ("dc-al-hcenter", "Horizontal Align Center", "horizontal", "center"), @@ -56,8 +80,6 @@ pub const DISTRIBUTE: [(&str, &str, &str, &str); 6] = [ /// They align to what the Align panel's Align To says (the key object while there is one), as /// its own buttons do (#789). pub fn align_buttons(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { - let n = selection_len(app); - let to = align_to(app, ui.ctx()); for (i, (icon, tip, axis, v)) in [ ("align-start-vertical", "Horizontal Align Left", "horizontal", "left"), ("align-center-vertical", "Horizontal Align Center", "horizontal", "center"), @@ -70,10 +92,10 @@ pub fn align_buttons(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { .enumerate() { if i == 3 && widgets::icon_button(ui, "dc-al-center", tl!("Horizontal & Vertical Align Center"), false, size).clicked() { - app.run("object.align", align_params(json!({"horizontal": "center", "vertical": "center"}), to, n)).ok(); + app.run("object.align", with_align_to(app, json!({"horizontal": "center", "vertical": "center"}))).ok(); } if widgets::icon_button(ui, icon, tl!(tip), false, size).clicked() { - app.run("object.align", align_params(json!({axis: v}), to, n)).ok(); + app.run("object.align", with_align_to(app, json!({axis: v}))).ok(); } } } @@ -91,13 +113,13 @@ fn topmost(app: &VectorcraftApp) -> Option { /// What Align aligns to: the key object while there is one (a click on an object of the /// 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") } +fn align_to(app: &VectorcraftApp) -> AlignTo { + if has_key(app) { AlignTo::Key } else { app.ui.align_to } } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let n = selection_len(app); - let to = align_to(app, ui.ctx()); + let to = align_to(app); widgets::subheader(ui, tl!("Align Objects:")); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 5.0; @@ -105,12 +127,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if i == 3 { ui.add_space(6.0); if widgets::icon_button_enabled(ui, "dc-al-center", tl!("Horizontal & Vertical Align Center"), false, n >= 1, 32.0).clicked() { - app.run("object.align", align_params(json!({"horizontal": "center", "vertical": "center"}), to, n)).ok(); + app.run("object.align", with_align_to(app, json!({"horizontal": "center", "vertical": "center"}))).ok(); } ui.add_space(6.0); } if widgets::icon_button_enabled(ui, icon, tl!(tip), false, n >= 1, 32.0).clicked() { - app.run("object.align", align_params(json!({*axis: v}), to, n)).ok(); + app.run("object.align", with_align_to(app, json!({*axis: v}))).ok(); } } }); @@ -155,6 +177,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); ui.vertical(|ui| { widgets::subheader(ui, tl!("Align To:")); + // With one object selected the buttons align it to the artboard, and Align To shows Artboard. + let shown = aligns_to(to, n); ui.horizontal(|ui| { for (v, icon, tip) in [ (AlignTo::Selection, "dc-alignto-selection", tl!("Align to Selection")), @@ -163,7 +187,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ] { // 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 widgets::icon_button_enabled(ui, icon, tl!(tip), shown == 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 @@ -174,7 +198,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("select.key", json!({"id": id.0})).ok(); } } else { - set_pstate(ui.ctx(), "align-to", v); + app.ui.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(); @@ -197,7 +221,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { 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) { + if menu_item(ui, tl!("Cancel Key Object"), align_to(app) == AlignTo::Key, false) { app.run("select.key", json!({})).ok(); } menu_item(ui, tl!("Align to Glyph Bounds"), false, false); @@ -252,7 +276,7 @@ mod tests { frame(&mut app, vec![press(false)]); assert_eq!(bounds(&app).x1, 200.0, "the artboard's right edge"); // The Align panel's Horizontal Align Left. - let p = align_params(json!({"horizontal": "left", "bounds": "geometric"}), align_to(&app, &ctx), selection_len(&app)); + let p = with_align_to(&app, json!({"horizontal": "left", "bounds": "geometric"})); app.run("object.align", p).unwrap(); assert_eq!(bounds(&app).x0, 0.0, "the artboard's left edge"); } @@ -274,12 +298,11 @@ 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(); app.run("select.set", json!({"ids": [a, b]})).unwrap(); - let ctx = egui::Context::default(); - assert_eq!(align_to(&app, &ctx), AlignTo::Selection); + assert_eq!(align_to(&app), AlignTo::Selection); app.run("select.key", json!({"id": b})).unwrap(); - assert_eq!(align_to(&app, &ctx), AlignTo::Key); + assert_eq!(align_to(&app), AlignTo::Key); app.run("select.key", json!({})).unwrap(); - assert_eq!(align_to(&app, &ctx), AlignTo::Selection, "no key: the choice again"); + assert_eq!(align_to(&app), 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 → @@ -326,7 +349,7 @@ mod tests { 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); + assert_eq!(align_to(&app), 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(); @@ -344,20 +367,40 @@ mod tests { 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); + assert_eq!(align_to(&app), 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"); + assert_eq!(align_to(&app), 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"); + assert_eq!(align_to(&app), 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"); + assert_eq!(align_to(&app), AlignTo::Artboard, "another selection"); + } + + /// The Align To choice is kept with the UI state, which the app saves with its preferences and + /// `ui.inspect` reports. A saved value other than "artboard" reads as Selection, and the rest of + /// the state still loads. + #[test] + fn the_align_to_choice_is_kept_with_the_ui_state() { + let (mut app, _, _) = back_and_front(); + let ctx = egui::Context::default(); + let ([_, _, artboard], _) = panel_buttons(&mut app, &ctx); + click(&ctx, &mut app, artboard.center(), show); + assert_eq!(crate::control::inspect(&app, &ctx)["ui"]["align_to"], "artboard"); + let back: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert_eq!(back.align_to, AlignTo::Artboard); + for v in [json!("key"), json!("Artboard"), json!(3), Value::Null] { + let ui: crate::UiState = serde_json::from_value(json!({"align_to": v, "place_link": false})).unwrap(); + assert_eq!((ui.align_to, ui.place_link), (AlignTo::Selection, false), "{v}"); + } + let old: crate::UiState = serde_json::from_value(json!({})).unwrap(); + assert_eq!(old.align_to, AlignTo::Selection, "preferences saved without it"); } /// With one object selected, Align to Key Object is disabled: a click sets no key and keeps the @@ -372,11 +415,36 @@ mod tests { 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); + assert_eq!(align_to(&app), AlignTo::Selection); click(&ctx, &mut app, left.center(), show); assert_eq!(left_edge(&app, &front), 0.0, "the artboard's left edge"); } + /// One frame of the Align panel → the rects of the Align To buttons drawn as chosen. + fn chosen_align_to(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + let fill = crate::theme::Tokens::get(ctx).tool_active; + let chosen = |s: &egui::Shape| match s { + egui::Shape::Rect(r) if r.fill == fill && r.rect.size() == egui::vec2(28.0, 28.0) => Some(r.rect), + _ => None, + }; + out.shapes.iter().filter_map(|c| chosen(&c.shape)).collect() + } + + /// With one object selected the align buttons align it to the artboard, and Align To shows + /// Artboard; with two, it shows the stored choice. + #[test] + fn align_to_shows_artboard_while_one_object_is_selected() { + let (mut app, _, front) = back_and_front(); + let ctx = egui::Context::default(); + let ([selection, _, artboard], _) = panel_buttons(&mut app, &ctx); + assert_eq!(chosen_align_to(&mut app, &ctx), [selection], "two objects"); + app.run("select.set", json!({"ids": [front]})).unwrap(); + assert_eq!(chosen_align_to(&mut app, &ctx), [artboard], "one object"); + assert_eq!(align_to(&app), AlignTo::Selection, "the stored choice"); + } + /// #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() { @@ -386,7 +454,7 @@ mod tests { let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); app.run("select.set", json!({"ids": [a, b]})).unwrap(); let ctx = egui::Context::default(); - set_pstate(&ctx, "align-to", AlignTo::Artboard); + app.ui.align_to = AlignTo::Artboard; // One frame finds the first button (Horizontal Align Right is third); the next clicks it. let at = std::cell::Cell::new(egui::Pos2::ZERO); let frame = |app: &mut VectorcraftApp, events: Vec| { @@ -407,4 +475,55 @@ mod tests { let x1 = |id: &serde_json::Value| st.doc.bounds_of(&[vectorcraft_doc::NodeId(id.as_u64().unwrap())], false).unwrap().x1; assert_eq!((x1(&a), x1(&b)), (200.0, 200.0), "both to the artboard's right edge"); } + + /// Runs the Object → Align item `label` as a click on it does. + fn object_align(app: &mut VectorcraftApp, label: &str) { + let entry = crate::menus::menu_entries(app).into_iter().find(|e| e.path == ["Object", "Align"] && e.label == label).expect("menu item"); + let (id, p) = crate::menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params); + crate::menus::invoke(app, &id, p); + } + + /// Object → Align aligns as the align buttons do: it aligns one object to the active artboard + /// whatever Align To says, and with Align to Artboard chosen it aligns two objects to the active + /// artboard too. + #[test] + fn object_align_follows_align_to() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200, "artboards": 2})).unwrap(); + let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); + 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 bounds = |app: &VectorcraftApp, id: &Value| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap() + }; + app.run("select.set", json!({"ids": [b]})).unwrap(); + object_align(&mut app, "Horizontal Align Left"); + assert_eq!(bounds(&app, &b).x0, 0.0, "one object, Align to Selection: the artboard's left edge"); + app.ui.align_to = AlignTo::Artboard; + app.view_mut().unwrap().artboard = 1; + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + object_align(&mut app, "Horizontal Align Right"); + assert_eq!((bounds(&app, &a).x1, bounds(&app, &b).x1), (boards[1].x1, boards[1].x1), "the active artboard's right edge"); + } + + /// With anchor points selected, Object → Align sends its params unchanged: two anchor points of + /// one path align to their own bounds with Align to Selection and with Align to Artboard chosen. + #[test] + fn object_align_aligns_anchor_points_to_each_other() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("path.create", json!({"d": "M100 40 L200 60 L150 160 Z"})).unwrap()["id"].as_u64().unwrap(); + let anchors = |app: &VectorcraftApp| -> Vec<(f64, f64)> { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + node.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect() + }; + for to in [AlignTo::Selection, AlignTo::Artboard] { + app.ui.align_to = to; + app.run("select.anchors", json!({"id": id, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + object_align(&mut app, "Horizontal Align Left"); + assert_eq!(anchors(&app), [(100.0, 40.0), (100.0, 60.0), (150.0, 160.0)], "{to:?}: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(anchors(&app)[1], (200.0, 60.0), "undone"); + } + } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 28e93f87..3ae28ca7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -360,6 +360,10 @@ pub struct UiState { /// swatches) or "" (none) (`ui.colorGuideLimit`). #[serde(default)] pub color_guide_limit: String, + /// Align panel: the Align To choice, Selection or Artboard, which Object → Align and the align + /// buttons follow ([`crate::panels::align::with_align_to`]). + #[serde(default, deserialize_with = "crate::panels::align::deserialize_align_to")] + pub align_to: crate::panels::align::AlignTo, /// The SVG Options chosen last (`svg` object of the export/save commands; null: never used). #[serde(default)] pub svg_options: Value, @@ -639,6 +643,7 @@ impl Default for UiState { library_panel: None, flattener_preview: Default::default(), color_guide_limit: String::new(), + align_to: Default::default(), svg_options: Value::Null, place_link: true, dialog_file: None, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c7c0fdf5..719d8d0b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -366,6 +366,14 @@ Use Preview Bounds: the Align panel flyout's Use Preview Bounds item shows and t preference (`prefs.set`); while it is on, the bounding box and the Transform panel, Properties and Control bar fields measure visual bounds. +Align To: the Align panel's Align To choice is saved with the preferences, and `ui.inspect` reports it as +`ui.align_to` (`"selection"` or `"artboard"`; Align to Key Object stores no choice). The align buttons of the Align +panel, the Control bar and the Properties panel run `object.align` with `to: "artboard"` when one object is selected, +else with `"key"` while the selection has a key object, else with that choice. The Object → Align items send the same +`to` while every selected object is selected whole. With direct-selected anchor points or segments they send no `to`, +and the points align to their own bounds. `object.align` from `engine.execute` or `ui.menu.invoke` without `to` keeps +the engine's default: the key object while there is one, else the selection. + Width Point Edit: double-clicking a width point with the Width tool, or `ui.widthPointEdit {id, index}`, opens the `widthPoint` dialog (fields `id`, `index`, `t`, `side1` and `side2`: the left and right widths in points, `linked`, `adjustAdjoining`). `ui.dialog.confirm` runs `stroke.widthPoint.set` with them; `ui.dialog.set {field: "discard", diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bee3ea79..10cecee5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -415,6 +415,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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. + Object › Align aligns objects as the align buttons do: it aligns one object to the active artboard whatever Align To is set to, and otherwise it aligns to the key object while there is one, else to the Align To choice, which is saved with the preferences (`ui.inspect` → `ui.align_to`). With direct-selected anchor points, Object › Align aligns the points to their own bounds. While one object is selected, Align To shows Artboard. The command palette lists `select.key` as Cancel Key Object, and a pick cancels the key object (#541). Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). Agents read the key object: `select.key` returns it, and `document.inspect` and the `ui.pointer` and `pointer_gesture` replies report it as `key`. `select.key` with an id that isn't a selected top-level object returns an error and leaves the key unchanged.