Arrange: Align to Slide and Align Selected Objects in the Align menu, as in PowerPoint

This commit is contained in:
Marco Arnulfo
2026-10-16 07:47:16 +02:00
parent 1dc69720db
commit 8c41140abe
6 changed files with 156 additions and 5 deletions
+1
View File
@@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
| 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
+103
View File
@@ -0,0 +1,103 @@
//! Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns
//! to the slide and several to each other, until the other choice is picked for that selection.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use deckcraft_engine::Session;
use deckcraft_model::ShapeId;
use egui::accesskit::Toggled;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
use crate::{Services, SlideApp};
/// A blank slide with three rectangles at different places; `select` picks which are selected.
fn harness(select: &[usize]) -> (Harness<'static, SlideApp>, Vec<ShapeId>) {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let ids: Vec<ShapeId> = [[100.0, 100.0], [300.0, 200.0], [500.0, 250.0]]
.iter()
.map(|[x, y]| {
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 80.0, 60.0]})).unwrap();
ShapeId(r["id"].as_u64().unwrap() as u32)
})
.collect();
let picked: Vec<u32> = select.iter().map(|&i| ids[i].0).collect();
s.execute("selection.select", &json!({"ids": picked})).unwrap();
let mut app = SlideApp::new(s, Services::default());
app.ui.tab = "Shape Format".into();
let mut h = Harness::builder().with_size(egui::vec2(1800.0, 900.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(3);
(h, ids)
}
fn pick(h: &mut Harness<'_, SlideApp>, item: &str) {
h.get_by_label("Align").click();
h.run_steps(2);
h.get_by_label(item).click();
h.run_steps(2);
}
fn marked(h: &mut Harness<'_, SlideApp>) -> (bool, bool) {
h.get_by_label("Align").click();
h.run_steps(2);
let m = (
h.get_by_label("Align to Slide").accesskit_node().toggled() == Some(Toggled::True),
h.get_by_label("Align Selected Objects").accesskit_node().toggled() == Some(Toggled::True),
);
// Esc would also clear the selection: a second click on Align closes the menu.
h.get_by_label("Align").click();
h.run_steps(2);
m
}
fn pos(h: &Harness<'_, SlideApp>, id: ShapeId) -> (f64, f64) {
let x = h.state().session.active().unwrap().shape(id).unwrap().xfrm.unwrap();
(x.x, x.y)
}
#[test]
fn align_to_slide_moves_align_left_to_the_slide_edge() {
let (mut h, ids) = harness(&[0, 1]);
pick(&mut h, "Align to Slide");
pick(&mut h, "Align Left");
assert_eq!(pos(&h, ids[0]).0, 0.0);
assert_eq!(pos(&h, ids[1]).0, 0.0);
}
#[test]
fn one_object_aligns_to_the_slide_and_two_to_each_other() {
let (mut h, ids) = harness(&[1]);
assert_eq!(marked(&mut h), (true, false));
pick(&mut h, "Align Left");
assert_eq!(pos(&h, ids[1]).0, 0.0);
let (mut h, ids) = harness(&[0, 1]);
assert_eq!(marked(&mut h), (false, true));
pick(&mut h, "Align Left");
assert_eq!(pos(&h, ids[1]).0, 100.0, "the left edge of the selection");
}
#[test]
fn the_choice_stays_until_the_selection_changes() {
let (mut h, ids) = harness(&[0, 1]);
pick(&mut h, "Align to Slide");
pick(&mut h, "Align Left");
pick(&mut h, "Align Top");
assert_eq!(pos(&h, ids[0]), (0.0, 0.0));
assert_eq!(pos(&h, ids[1]), (0.0, 0.0));
assert_eq!(marked(&mut h), (true, false));
// Another selection starts again from its own default.
h.state_mut().run("selection.select", json!({"ids": [ids[1].0, ids[2].0]})).unwrap();
h.run_steps(2);
assert_eq!(marked(&mut h), (false, true));
pick(&mut h, "Align Bottom");
assert_eq!(pos(&h, ids[1]).1, 250.0, "the bottom of the selection, not of the slide");
}
+3
View File
@@ -496,6 +496,8 @@ pub struct SlideApp {
pub(crate) thumb_drag: Option<(usize, usize)>,
/// Selection pane: folded groups, renaming, drag and drop.
pub selpane: selpane::State,
/// Arrange ▸ Align: Align to Slide (`true`) or Align Selected Objects, ticked for that selection.
pub(crate) align_to: Option<(Vec<deckcraft_model::ShapeId>, bool)>,
/// View › New Window: a second window on one presentation. `None` when it is closed.
pub doc_window: Option<doc_window::DocWindow>,
/// Slide Sorter: folded sections, inline rename, dragging a whole section.
@@ -572,6 +574,7 @@ impl SlideApp {
ui_fonts: uifonts::UiFonts::default(),
thumb_drag: None,
selpane: selpane::State::default(),
align_to: None,
doc_window: None,
sorter: sorter::State::default(),
slides_focused: false,
+28 -4
View File
@@ -1,7 +1,7 @@
//! Title bar, ribbon tabs and the ribbon card with every tab's groups.
use deckcraft_engine::Target;
use deckcraft_model::{ShapeKind, anim::AnimClass};
use deckcraft_model::{ShapeId, ShapeKind, anim::AnimClass};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
@@ -1134,6 +1134,9 @@ pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) {
}
pub fn align_menu(app: &mut SlideApp, ui: &mut Ui) {
let selection = app.session.active().map(|d| d.selection.shapes.clone()).unwrap_or_default();
let to_slide = align_to_slide(app.align_to.as_ref(), &selection);
let to = if to_slide { "slide" } else { "selection" };
for (l, e, ic) in [
("Align Left", "left", Icon::AlignObjLeft),
("Align Center", "center", Icon::AlignObjCenter),
@@ -1142,11 +1145,28 @@ pub fn align_menu(app: &mut SlideApp, ui: &mut Ui) {
("Align Middle", "middle", Icon::AlignObjMiddle),
("Align Bottom", "bottom", Icon::AlignObjBottom),
] {
menu_item(app, ui, ic, l, "arrange.align", json!({"edge": e}));
menu_item(app, ui, ic, l, "arrange.align", json!({"edge": e, "to": to}));
}
ui.separator();
menu_item(app, ui, Icon::DistributeH, "Distribute Horizontally", "arrange.distribute", json!({"dir": "horizontal"}));
menu_item(app, ui, Icon::DistributeV, "Distribute Vertically", "arrange.distribute", json!({"dir": "vertical"}));
menu_item(app, ui, Icon::DistributeH, "Distribute Horizontally", "arrange.distribute", json!({"dir": "horizontal", "to": to}));
menu_item(app, ui, Icon::DistributeV, "Distribute Vertically", "arrange.distribute", json!({"dir": "vertical", "to": to}));
ui.separator();
let en = enabled(app, "arrange.align");
for (l, slide) in [("Align to Slide", true), ("Align Selected Objects", false)] {
if widgets::check_item(ui, l, en, to_slide == slide).clicked() {
app.align_to = Some((selection.clone(), slide));
ui.close();
}
}
}
/// Align to Slide or to the selected objects, as in PowerPoint: one object aligns to the slide and
/// several to each other, unless the other choice was ticked for this same selection.
fn align_to_slide(choice: Option<&(Vec<ShapeId>, bool)>, selection: &[ShapeId]) -> bool {
match choice {
Some((ids, to_slide)) if ids.as_slice() == selection => *to_slide,
_ => selection.len() < 2,
}
}
pub fn menu_item(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) {
@@ -3454,6 +3474,10 @@ mod effects_tests;
#[path = "master_layout_tests.rs"]
mod master_layout_tests;
#[cfg(test)]
#[path = "align_tests.rs"]
mod align_tests;
#[cfg(test)]
mod title_bar_tests {
use super::*;
+19
View File
@@ -152,6 +152,25 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele
(if split { main_resp } else { resp }, arrow_resp)
}
/// A menu choice ticked in the icon column (Align to Slide…), its text lined up with the
/// [`small_button`] items around it.
pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response {
let t = Tokens::get(ui.ctx());
let font = theme::font(12.0);
let tw = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0;
let (rect, resp) = ui.allocate_exact_size(vec2(24.0 + tw, 22.0), if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled, on, label));
let painter = ui.painter_at(rect);
if let Some(c) = bg(ui, &resp, false, &t) {
painter.rect_filled(rect, CornerRadius::same(4), c);
}
if on {
icons::paint(&painter, Rect::from_center_size(pos2(rect.min.x + 12.0, rect.center().y), vec2(17.0, 17.0)), Icon::Check, t.text, !enabled);
}
painter.text(pos2(rect.min.x + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, if enabled { t.text } else { t.text_faint });
resp
}
/// A square icon toggle (B, I, U, alignment…).
pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) -> Response {
let t = Tokens::get(ui.ctx());
+2 -1
View File
@@ -186,7 +186,7 @@ Features row of the dimension table keeps ~62% until the next full re-measure).
| Masters and themes | 7 | 92% | ~70% | 8–14 | Notes/Handout master view polish, effect-scheme depth, Designer suggestions | [parity-checklist.md](parity-checklist.md) |
| Shapes | 7 | 91% | ~75% | 8–14 | Edit points depth, WordArt depth (the Transform gallery and warp adjust handles are in) | [ui-parity.md](ui-parity.md) |
| Format (fill, line, effects) | 6 | 83% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) |
| Arrange | 3 | 100% | ~90% | 1–2 | Align to slide vs selected as a menu choice, More Rotation Options | [ui-parity.md](ui-parity.md) |
| Arrange | 3 | 100% | ~90% | 1–2 | More Rotation Options | [ui-parity.md](ui-parity.md) |
| Text | 15 | 83% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), true vertical CJK, dictionaries, AutoFormat as you type, thesaurus depth, equations (PR #55 open) | [localization-parity.md](localization-parity.md) |
| Tables | 5 | 97% | ~75% | 3–6 | Draw table depth, borders and cell effects depth | [parity-checklist.md](parity-checklist.md) |
| Charts | 7 | 65% | ~45% | 12–20 | Data grid editing, chart elements, the `chartex` types (waterfall, funnel, treemap, sunburst, histogram, box) | [parity-checklist.md](parity-checklist.md) |
@@ -289,6 +289,7 @@ scored done (16 open issues).
| Date | Change | Summary |
|---|---|---|
| 2026-10-16 | trivial | Arrange shortfall: Align to Slide and Align Selected Objects are in the Align menu. Percentages unchanged |
| 2026-10-14 | minor | Community PR wave folded: one headline breadth row (84% over 191 features after `cargo xtask parity`; duplicate checklist rows removed), one row per feature area with the latest breadth and shortfalls, one Themes / Slide show / Views / Review / Automation bullet each (320 engine commands). SmartArt ready ~35% (Text Pane, 6e40335) moves the area mean to 62.8%; dimension and headline percentages unchanged |
| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed to adjust handles: the Transform gallery applies a preset |
| 2026-10-14 | minor | Media remainder: Insert › Icons places Lucide stroke icons. 3-D models and stock content remain. Headline percentages unchanged; the checklist row stays missing |