mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Arrange: Align to Slide and Align Selected Objects in the Align menu, as in PowerPoint
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user