diff --git a/ROADMAP.md b/ROADMAP.md index 7dfa96f8..4778fd65 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/crates/ui-egui/src/align_tests.rs b/crates/ui-egui/src/align_tests.rs new file mode 100644 index 00000000..969ef2fe --- /dev/null +++ b/crates/ui-egui/src/align_tests.rs @@ -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) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let ids: Vec = [[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 = 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"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 88674070..7cdb921b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, bool)>, /// View › New Window: a second window on one presentation. `None` when it is closed. pub doc_window: Option, /// 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, diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 3e43a68b..3a679dd1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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, 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::*; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 6903f1bf..67549ce3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index aa1893ac..e5a42d29 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 |