diff --git a/ROADMAP.md b/ROADMAP.md index dd60485c..bc7d88d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,6 +3,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -149,6 +150,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) | | 2026-10-28 | CLI `run --cmd-file FILE` reads UTF-8 commands in order alongside `--cmd`, skips blank/comment lines, and reports file/line errors before final save/export (#1098) | | 2026-10-28 | MCP accepts repeated `--automation-read-root` and `--automation-write-root` flags for access to multiple folders (#1154) | +| 2026-10-26 | Slide reorder: dragging one of several selected slides in the thumbnail pane or the Slide Sorter moves the whole selection, as one run in deck order and one undo step; the slides stay selected and join the section they land in. `slide.move {ids, to}` does the same from the control channel and MCP; dragging an unselected slide still moves only that slide | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI ``, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-29 | Picture Format: the Picture Styles row shows as many styles as leave room for the groups after it, and its arrow opens all 24. With every style in the row, Crop and Size sat past the ribbon's edge in any window narrower than about 2500 px, so Crop could not be seen without scrolling the ribbon (#1232). In the default 1440 px window Crop, Height and Width are now in view, and from 1600 px the whole tab fits | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | @@ -313,6 +315,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged | | 2026-10-28 | trivial | Progress log: CLI command-file input (#1098). Scores and milestone scope unchanged. | | 2026-10-28 | trivial | Progress log: MCP accepts multiple automation read and write roots (#1154). Scores and milestone names unchanged | +| 2026-10-26 | trivial | Progress log: dragging several selected slides moves them all. Scores and milestone names unchanged | | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged | | 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 787c6547..e150a265 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -1,5 +1,6 @@ //! Slides: new, duplicate, delete, move, layout, reset, hide, notes, sections, navigation. +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use deckcraft_model::text::TextBody; @@ -22,7 +23,15 @@ pub fn specs() -> Vec { ), cmd!("slide.duplicate", "Duplicate Slide", ["Insert"], None, "{index?}", has_slide, duplicate), cmd!("slide.delete", "Delete Slide", ["Edit"], None, "{index?, ids?: [slide id]}", has_slide, delete), - cmd!("slide.move", "Move Slide", [], None, "{from?: index, to: index}", has_slide, move_slide), + cmd!( + "slide.move", + "Move Slide", + [], + None, + "{from?: index, ids?: [slide id], to: index (counted without the moved slides)}", + has_slide, + move_slide + ), cmd!(noundo "slide.go", "Go to Slide", [], None, "{index} | {id}", has_slide, go), cmd!(noundo "slide.next", "Next Slide", [], Some("PageDown"), "{}", has_slide, next), cmd!(noundo "slide.previous", "Previous Slide", [], Some("PageUp"), "{}", has_slide, prev), @@ -266,36 +275,75 @@ fn delete(s: &mut Session, p: &Value) -> Result { }) } +/// Moves one slide (`from`, else the current slide) or several (`ids`) to `to`, in one undo step. +/// `to` counts in the deck without the moved slides; they land there as one run, in the order +/// they had, join the section of the slide before them, and stay selected. fn move_slide(s: &mut Session, p: &Value) -> Result { - let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide); + let ids = p.get("ids").filter(|v| !v.is_null()); + let from = match ids { + None => opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide), + Some(_) => 0, + }; let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?; s.edit(|doc, sel| { - if from >= doc.slides.len() { - return Err(bad("slide.move", format!("no slide {from}"))); - } - let sl = doc.slides.remove(from); - let to = to.min(doc.slides.len()); - let id = sl.id; - doc.slides.insert(to, sl); - // Keep sections consistent with order: move the id into the section of its new neighbour. - if !doc.sections.is_empty() { - for sec in &mut doc.sections { - sec.slides.retain(|x| *x != id); + let moving: HashSet = match ids { + Some(v) => slide_ids_param(v, doc, "slide.move")?, + None => { + let id = doc.slides.get(from).map(|x| x.id).ok_or_else(|| bad("slide.move", format!("no slide {from}")))?; + HashSet::from([id]) } - let neighbour = to.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id); - let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0); - let order: Vec = doc.slides.iter().map(|x| x.id).collect(); - if let Some(sec) = doc.sections.get_mut(si) { - sec.slides.push(id); - sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX)); - } - } + }; + let (block, mut rest): (Vec<_>, Vec<_>) = std::mem::take(&mut doc.slides).into_iter().partition(|x| moving.contains(&x.id)); + let to = to.min(rest.len()); + let tail = rest.split_off(to); + let moved: Vec = block.iter().map(|x| x.id).collect(); + rest.extend(block); + rest.extend(tail); + doc.slides = rest; + reassign_sections(doc, &moved, to); sel.slide = to; - sel.slides = vec![id]; - Ok(json!({"index": to})) + sel.slides = moved; + sel.shapes.clear(); + sel.text = None; + Ok(json!({"index": to, "moved": moving.len()})) }) } +/// The slides an `ids` param names: a non-empty list of ids of slides in `doc` (repeats fold). +/// The first id that is not a slide of `doc` is an error, so the set never outgrows the deck. +fn slide_ids_param(v: &Value, doc: &Presentation, cmd: &str) -> Result> { + let a = v.as_array().ok_or_else(|| bad(cmd, "`ids` must be a list of slide ids"))?; + let known: HashSet = doc.slides.iter().map(|x| x.id).collect(); + let mut out = HashSet::new(); + for x in a { + let id = x.as_u64().and_then(|n| u32::try_from(n).ok()).map(SlideId).filter(|id| known.contains(id)); + out.insert(id.ok_or_else(|| bad(cmd, format!("no slide with id {x}")))?); + } + if out.is_empty() { + return Err(bad(cmd, "`ids` is empty")); + } + Ok(out) +} + +/// Keeps sections in step after `moved` landed as a run at `at`: the run joins the section of +/// the slide just before it (the first section when it opens the deck), in document order. +fn reassign_sections(doc: &mut Presentation, moved: &[SlideId], at: usize) { + if doc.sections.is_empty() { + return; + } + let moving: HashSet = moved.iter().copied().collect(); + for sec in &mut doc.sections { + sec.slides.retain(|x| !moving.contains(x)); + } + let neighbour = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id); + let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0); + let order: HashMap = doc.slides.iter().enumerate().map(|(i, x)| (x.id, i)).collect(); + if let Some(sec) = doc.sections.get_mut(si) { + sec.slides.extend_from_slice(moved); + sec.slides.sort_by_key(|x| order.get(x).copied().unwrap_or(usize::MAX)); + } +} + fn go_to(s: &mut Session, i: usize) -> Result { s.select(|doc, sel| { let n = doc.slides.len(); @@ -710,6 +758,111 @@ mod tests { } } + /// A deck of `n` slides and its slide ids, in order. + fn deck(n: usize) -> (Session, Vec) { + let mut s = Session::with_new(); + while s.doc().expect("doc").doc.slides.len() < n { + s.execute("slide.new", &json!({})).expect("new slide"); + } + let ids = order(&s); + (s, ids) + } + + fn order(s: &Session) -> Vec { + s.doc().expect("doc").doc.slides.iter().map(|x| x.id.0).collect() + } + + fn selected(s: &Session) -> (usize, Vec) { + let sel = &s.doc().expect("doc").selection; + (sel.slide, sel.slides.iter().map(|x| x.0).collect()) + } + + #[test] + fn move_from_to_moves_one_slide() { + let (mut s, ids) = deck(4); + s.execute("slide.move", &json!({"from": 3, "to": 0})).expect("move"); + assert_eq!(order(&s), [ids[3], ids[0], ids[1], ids[2]]); + assert_eq!(selected(&s), (0, vec![ids[3]])); + } + + #[test] + fn move_ids_lands_them_as_one_run_in_deck_order() { + let (mut s, ids) = deck(6); + // Listed backwards: they still land in the order they had. + s.execute("slide.move", &json!({"ids": [ids[4], ids[1]], "to": 0})).expect("to the front"); + assert_eq!(order(&s), [ids[1], ids[4], ids[0], ids[2], ids[3], ids[5]]); + assert_eq!(selected(&s), (0, vec![ids[1], ids[4]]), "the moved slides stay selected"); + + let (mut s, ids) = deck(6); + s.execute("slide.move", &json!({"ids": [ids[0], ids[2]], "to": 2})).expect("to the middle"); + assert_eq!(order(&s), [ids[1], ids[3], ids[0], ids[2], ids[4], ids[5]]); + assert_eq!(selected(&s), (2, vec![ids[0], ids[2]])); + + let (mut s, ids) = deck(6); + s.execute("slide.move", &json!({"ids": [ids[1], ids[3]], "to": 1000})).expect("past the end clamps"); + assert_eq!(order(&s), [ids[0], ids[2], ids[4], ids[5], ids[1], ids[3]]); + assert_eq!(selected(&s), (4, vec![ids[1], ids[3]])); + } + + #[test] + fn moving_several_slides_is_one_undo_step() { + let (mut s, ids) = deck(5); + s.execute("slide.selectSlides", &json!({"ids": [ids[0], ids[3]]})).expect("select"); + let before = selected(&s); + s.execute("slide.move", &json!({"ids": [ids[0], ids[3]], "to": 3})).expect("move"); + let after = order(&s); + assert_eq!(after, [ids[1], ids[2], ids[4], ids[0], ids[3]]); + s.execute("edit.undo", &json!({})).expect("undo"); + assert_eq!(order(&s), ids); + assert_eq!(selected(&s), before); + s.execute("edit.redo", &json!({})).expect("redo"); + assert_eq!(order(&s), after); + } + + #[test] + fn moved_slides_join_the_section_before_them() { + let (mut s, ids) = deck(5); + s.execute("section.add", &json!({"name": "A", "at": 0})).expect("A"); + s.execute("section.add", &json!({"name": "B", "at": 3})).expect("B"); + // A = 0,1,2; B = 3,4. Move 0 and 1 to the end: B gets them, A keeps only slide 2. + s.execute("slide.move", &json!({"ids": [ids[0], ids[1]], "to": 3})).expect("move"); + let sections = + |s: &Session| -> Vec> { s.doc().expect("doc").doc.sections.iter().map(|x| x.slides.iter().map(|id| id.0).collect()).collect() }; + assert_eq!(sections(&s), [vec![ids[2]], vec![ids[3], ids[4], ids[0], ids[1]]]); + // Move the whole of A away: A stays, empty; the run at the front joins the first section. + s.execute("slide.move", &json!({"ids": [ids[2]], "to": 4})).expect("empty A"); + assert_eq!(sections(&s), [vec![], vec![ids[3], ids[4], ids[0], ids[1], ids[2]]]); + s.execute("slide.move", &json!({"ids": [ids[0], ids[1]], "to": 0})).expect("to the front"); + assert_eq!(sections(&s), [vec![ids[0], ids[1]], vec![ids[3], ids[4], ids[2]]]); + let all: Vec = sections(&s).concat(); + assert_eq!(all, order(&s), "sections follow slide order"); + } + + #[test] + fn bad_ids_are_rejected_and_change_nothing() { + let (mut s, ids) = deck(3); + let lots_of_unknown: Vec = (1_000_000..1_100_000).collect(); + for p in [ + json!({"ids": [], "to": 0}), + json!({"ids": [ids[0], 999_999], "to": 0}), + json!({"ids": [-1], "to": 0}), + json!({"ids": ["1"], "to": 0}), + json!({"ids": [1.5], "to": 0}), + json!({"ids": [u64::MAX], "to": 0}), + json!({"ids": ids[0], "to": 0}), + json!({"ids": lots_of_unknown, "to": 0}), + json!({"ids": [ids[1]]}), + ] { + assert!(s.execute("slide.move", &p).is_err(), "slide.move must reject {p}"); + assert_eq!(order(&s), ids, "{p}"); + } + // Repeats fold into one. + let repeated = vec![ids[2]; 100_000]; + s.execute("slide.move", &json!({"ids": repeated, "to": 0})).expect("repeats"); + assert_eq!(order(&s), [ids[2], ids[0], ids[1]]); + assert_eq!(selected(&s), (0, vec![ids[2]])); + } + #[test] fn duplicate_keeps_section_order_in_step_with_slides() { let mut s = Session::with_new(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7ef12398..0937cac6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -310,8 +310,8 @@ pub struct SlideApp { pub(crate) notes_buf: (u64, usize, notes::Draft), /// Faces borrowed from the font database for text the interface fonts can't draw (#101). pub ui_fonts: uifonts::UiFonts, - /// Thumbnail pane drag (slide index, current drop index). - pub(crate) thumb_drag: Option<(usize, usize)>, + /// Slide drag in the thumbnail pane or the Slide Sorter: the dragged slides and the drop gap. + pub(crate) thumb_drag: Option, /// 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. diff --git a/crates/ui-egui/src/panels/thumbs.rs b/crates/ui-egui/src/panels/thumbs.rs index 25be3e8c..15bf9d37 100644 --- a/crates/ui-egui/src/panels/thumbs.rs +++ b/crates/ui-egui/src/panels/thumbs.rs @@ -1,13 +1,84 @@ //! The slide thumbnail pane (Normal view): numbered thumbnails grouped by section, selection, //! drag-to-reorder, and the slide context menu. +use std::collections::HashSet; + use deckcraft_engine::Target; -use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use deckcraft_model::SlideId; +use egui::{Align2, Color32, CornerRadius, Rect, Role, Sense, Stroke, Ui, WidgetInfo, pos2, vec2}; use serde_json::json; use crate::SlideApp; use crate::theme::{self, Tokens}; +/// A slide drag in the thumbnail pane or the Slide Sorter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SlideDrag { + /// The presentation the drag started in; it is dropped, not applied, anywhere else. + pub uid: u64, + /// The dragged slides, in deck order. + pub ids: Vec, + /// The gap under the pointer: 0 is before the first slide, the slide count after the last. + pub slot: usize, +} + +/// Starts dragging slide `i`: the whole slide selection when `i` is part of it, else `i` alone, +/// which then becomes the selection, as in PowerPoint. +pub(crate) fn start_slide_drag(app: &mut SlideApp, i: usize) { + let Some(st) = app.session.active() else { return }; + let Some(id) = st.doc.slides.get(i).map(|s| s.id) else { return }; + let selected: HashSet = st.selection.slides.iter().copied().collect(); + let ids: Vec = + if selected.contains(&id) { st.doc.slides.iter().map(|s| s.id).filter(|x| selected.contains(x)).collect() } else { vec![id] }; + let uid = st.uid; + if !selected.contains(&id) { + let _ = app.run("slide.go", json!({"index": i})); + } + app.thumb_drag = Some(SlideDrag { uid, ids, slot: i }); +} + +/// Whether a slide drag is under way in the active presentation. `button` is whether the +/// primary button is held or was released this frame. A drag begun in another presentation, or +/// one whose release this view never saw (the view changed under it), is discarded, so it never +/// moves slides on some later, unrelated release. +pub(crate) fn slide_drag_live(app: &mut SlideApp, button: bool) -> bool { + let uid = app.session.active().map(|st| st.uid); + if !button || app.thumb_drag.as_ref().is_some_and(|d| Some(d.uid) != uid) { + app.thumb_drag = None; + } + app.thumb_drag.is_some() +} + +/// Whether the primary button is held or was released this frame: a slide drag is still alive. +pub(crate) fn drag_button(ui: &Ui) -> bool { + ui.input(|i| i.pointer.primary_down() || i.pointer.any_released()) +} + +/// `slide.move`'s `to` for slides at `moving` (ascending indices) dropped at gap `slot`: the gap +/// counted without them. `None` when the drop leaves the deck as it is. +pub(crate) fn slides_move_dest(moving: &[usize], slot: usize) -> Option { + let first = *moving.first()?; + let dest = slot.saturating_sub(moving.iter().filter(|&&i| i < slot).count()); + let one_run = moving.iter().enumerate().all(|(k, &i)| first.checked_add(k) == Some(i)); + if one_run && dest == first { None } else { Some(dest) } +} + +/// Ends the slide drag: one `slide.move` (one undo step) for all the dragged slides, unless the +/// drop leaves them where they are, the drag began in another presentation, or one of them is gone. +pub(crate) fn drop_slide_drag(app: &mut SlideApp) { + let Some(drag) = app.thumb_drag.take() else { return }; + let Some(st) = app.session.active().filter(|st| st.uid == drag.uid) else { return }; + let dragged: HashSet = drag.ids.iter().copied().collect(); + let moving: Vec = st.doc.slides.iter().enumerate().filter(|(_, s)| dragged.contains(&s.id)).map(|(i, _)| i).collect(); + if moving.len() != dragged.len() { + return; + } + if let Some(to) = slides_move_dest(&moving, drag.slot) { + let ids: Vec = drag.ids.iter().map(|id| id.0).collect(); + let _ = app.run("slide.move", json!({"ids": ids, "to": to})); + } +} + /// Render the slide thumbnail pane, including section headers, selection, and scroll state. pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -108,6 +179,8 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { }); } let (row, resp) = ui.allocate_exact_size(vec2(width, th + 16.0), Sense::click_and_drag()); + let label = format!("Slide {}", i + 1); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &label)); let img = crate::textures::device_pixel_rect(Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th)), ppp); rects.push((i, row)); let p = ui.painter(); @@ -174,7 +247,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { app.start_show(i, false); } if resp.drag_started() { - app.thumb_drag = Some((i, i)); + start_slide_drag(app, i); } resp.context_menu(|ui| slide_menu(app, ui, i)); } @@ -186,7 +259,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { ui.scroll_to_rect(*row_rect, None); } // Drag reorder: insertion line. - if let Some((from, _)) = app.thumb_drag { + if slide_drag_live(app, drag_button(ui)) { if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) { let mut to = doc.slides.len(); for (i, r) in &rects { @@ -195,19 +268,14 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { break; } } - app.thumb_drag = Some((from, to)); + if let Some(d) = app.thumb_drag.as_mut() { + d.slot = to; + } let y = rects.iter().find(|(i, _)| *i == to).map(|(_, r)| r.min.y + 2.0).or_else(|| rects.last().map(|(_, r)| r.max.y)).unwrap_or(0.0); ui.painter().line_segment([pos2(26.0, y), pos2(width, y)], Stroke::new(2.0, t.accent)); } if ui.input(|i| i.pointer.any_released()) { - if let Some((from, to)) = app.thumb_drag.take() - && to != from - && to != from + 1 - { - let dest = if to > from { to - 1 } else { to }; - let _ = app.run("slide.move", json!({"from": from, "to": dest})); - } - app.thumb_drag = None; + drop_slide_drag(app); } } // Click on empty space below: new slide prompt. @@ -348,6 +416,7 @@ mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; use super::*; use crate::Services; @@ -396,4 +465,124 @@ mod tests { let held = thumbs_scroll(&h); assert!((held - jumped).abs() < 1.0, "no re-scroll for a visible slide: {jumped} -> {held}"); } + + /// A tall window, so every thumbnail of a small deck is on screen. + fn tall_harness(slides: usize) -> (Harness<'static, SlideApp>, Vec) { + let mut s = Session::with_new(); + while s.doc().unwrap().doc.slides.len() < slides { + s.execute("slide.new", &json!({})).unwrap(); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 1600.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(s, Services::default()), + ); + h.run_steps(3); + let ids = order(&h); + (h, ids) + } + + fn order(h: &Harness<'_, SlideApp>) -> Vec { + h.state().session.active().unwrap().doc.slides.iter().map(|s| s.id.0).collect() + } + + #[test] + fn slides_move_dest_counts_without_the_moved_slides() { + // One slide: the gaps on either side of it leave it where it is. + assert_eq!(slides_move_dest(&[1], 1), None); + assert_eq!(slides_move_dest(&[1], 2), None); + assert_eq!(slides_move_dest(&[1], 0), Some(0)); + assert_eq!(slides_move_dest(&[1], 4), Some(3)); + // A run: any gap inside or around it is a no-op. + for slot in 2..=5 { + assert_eq!(slides_move_dest(&[2, 3, 4], slot), None, "slot {slot}"); + } + assert_eq!(slides_move_dest(&[2, 3, 4], 0), Some(0)); + assert_eq!(slides_move_dest(&[2, 3, 4], 6), Some(3)); + // Scattered slides always gather, even at a gap between them. + assert_eq!(slides_move_dest(&[0, 2], 1), Some(0)); + assert_eq!(slides_move_dest(&[0, 2], 0), Some(0)); + assert_eq!(slides_move_dest(&[1, 3], 5), Some(3)); + assert_eq!(slides_move_dest(&[], 3), None); + assert_eq!(slides_move_dest(&[usize::MAX], usize::MAX), None); + } + + #[test] + fn dragging_selected_thumbnails_moves_them_together() { + let (mut h, ids) = tall_harness(4); + h.state_mut().run("slide.selectSlides", json!({"ids": [ids[0], ids[2]]})).unwrap(); + h.run_steps(2); + let (first, last) = (h.get_by_label("Slide 1").rect(), h.get_by_label("Slide 4").rect()); + // Below the middle of the last thumbnail: the gap after it. + crate::sorter::ui_tests::drag(&mut h, first.center(), egui::pos2(last.center().x, last.bottom() - 4.0)); + assert_eq!(order(&h), [ids[1], ids[3], ids[0], ids[2]]); + let sel: Vec = h.state().session.active().unwrap().selection.slides.iter().map(|s| s.0).collect(); + assert_eq!(sel, [ids[0], ids[2]], "the moved slides stay selected"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(order(&h), ids, "one undo puts both back"); + } + + #[test] + fn dragging_an_unselected_thumbnail_moves_only_it() { + let (mut h, ids) = tall_harness(4); + h.state_mut().run("slide.selectSlides", json!({"ids": [ids[0], ids[2]]})).unwrap(); + h.run_steps(2); + let (second, first) = (h.get_by_label("Slide 2").rect(), h.get_by_label("Slide 1").rect()); + crate::sorter::ui_tests::drag(&mut h, second.center(), egui::pos2(first.center().x, first.top() + 4.0)); + assert_eq!(order(&h), [ids[1], ids[0], ids[2], ids[3]]); + let sel: Vec = h.state().session.active().unwrap().selection.slides.iter().map(|s| s.0).collect(); + assert_eq!(sel, [ids[1]]); + } + + /// A drag whose release no view saw (a double-click in the Slide Sorter opens Normal view + /// mid-gesture) is dropped, not left to move slides on the next unrelated release. + #[test] + fn a_slide_drag_without_a_held_button_is_dropped() { + let (mut h, ids) = tall_harness(3); + let app = h.state_mut(); + app.run("slide.selectSlides", json!({"ids": [ids[0], ids[1]]})).unwrap(); + start_slide_drag(app, 0); + if let Some(d) = app.thumb_drag.as_mut() { + d.slot = 3; + } + h.run_steps(2); + assert!(h.state().thumb_drag.is_none(), "no button held: the drag is over"); + let last = h.get_by_label("Slide 3").rect(); + h.hover_at(last.center()); + h.run_steps(1); + h.event(egui::Event::PointerButton { + pos: last.center(), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(2); + assert_eq!(order(&h), ids, "a later release moves nothing"); + } + + /// A drag begun in one presentation never moves slides in another (switching mid-drag). + #[test] + fn a_slide_drag_does_not_follow_into_another_presentation() { + let (mut h, ids) = tall_harness(3); + let app = h.state_mut(); + app.run("slide.selectSlides", json!({"ids": [ids[0], ids[1]]})).unwrap(); + start_slide_drag(app, 0); + let drag = app.thumb_drag.clone().unwrap(); + assert_eq!(drag.ids.iter().map(|s| s.0).collect::>(), [ids[0], ids[1]]); + app.thumb_drag = Some(SlideDrag { slot: 3, ..drag.clone() }); + app.run("file.new", json!({})).unwrap(); + while app.session.active().unwrap().doc.slides.len() < 3 { + app.run("slide.new", json!({})).unwrap(); + } + let other = app.session.active().unwrap().doc.slides.iter().map(|s| s.id.0).collect::>(); + assert!(!slide_drag_live(app, true), "the drag belongs to the first presentation"); + app.thumb_drag = Some(SlideDrag { slot: 3, ..drag }); + drop_slide_drag(app); + assert!(app.thumb_drag.is_none()); + assert_eq!(app.session.active().unwrap().doc.slides.iter().map(|s| s.id.0).collect::>(), other); + app.session.set_active(0); + assert_eq!(order(&h), ids, "and the first presentation is untouched"); + } } diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index b3706186..6c962638 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -248,7 +248,7 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32, app.ui.view = crate::ViewMode::Normal; } if resp.drag_started() && app.sorter.drag.is_none() { - app.thumb_drag = Some((i, i)); + crate::thumbs::start_slide_drag(app, i); } resp.context_menu(|ui| crate::thumbs::slide_menu(app, ui, i)); } @@ -333,26 +333,20 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { app.sorter.drag = None; } } - if app.sorter.drag.is_none() - && let Some((from, _)) = app.thumb_drag - { + if app.sorter.drag.is_none() && crate::thumbs::slide_drag_live(app, crate::thumbs::drag_button(ui)) { if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) { let to = rects.iter().find(|(_, r)| r.contains(pp)).map(|(i, r)| if pp.x > r.center().x { i + 1 } else { *i }).unwrap_or(doc.slides.len()); - app.thumb_drag = Some((from, to)); + if let Some(d) = app.thumb_drag.as_mut() { + d.slot = to; + } if let Some((_, r)) = rects.iter().find(|(i, _)| *i == to).or(rects.last()) { let x = if to >= doc.slides.len() { r.max.x - 6.0 } else { r.min.x + 6.0 }; ui.painter().line_segment([pos2(x, r.min.y + 6.0), pos2(x, r.max.y - 20.0)], Stroke::new(3.0, t.accent)); } } if ui.input(|i| i.pointer.any_released()) { - if let Some((from, to)) = app.thumb_drag.take() - && to != from - && to != from + 1 - { - let _ = app.run("slide.move", json!({"from": from, "to": if to > from { to - 1 } else { to }})); - } - app.thumb_drag = None; + crate::thumbs::drop_slide_drag(app); } } // Ctrl/⌘ + wheel zooms the sorter. @@ -475,7 +469,7 @@ pub fn outline_pane(app: &mut SlideApp, ui: &mut Ui) { } #[cfg(test)] -mod ui_tests; +pub(crate) mod ui_tests; /// A slide's outline block height before it has been laid out: title row plus its text lines. fn outline_estimate(s: &deckcraft_model::Slide) -> f32 { diff --git a/crates/ui-egui/src/sorter/ui_tests.rs b/crates/ui-egui/src/sorter/ui_tests.rs index 3a5d17c8..96697461 100644 --- a/crates/ui-egui/src/sorter/ui_tests.rs +++ b/crates/ui-egui/src/sorter/ui_tests.rs @@ -50,7 +50,8 @@ fn double_click(h: &mut Harness<'_, SlideApp>, p: Pos2) { h.run_steps(2); } -fn drag(h: &mut Harness<'_, SlideApp>, from: Pos2, to: Pos2) { +/// Presses at `from`, moves to `to` in a few steps and releases there. +pub(crate) fn drag(h: &mut Harness<'_, SlideApp>, from: Pos2, to: Pos2) { h.hover_at(from); h.run_steps(1); h.drag_at(from); @@ -170,6 +171,55 @@ fn dragging_a_section_moves_it_and_keeps_it_collapsed() { assert!(beta.top() < alpha.top()); } +fn slide_order(h: &Harness<'_, SlideApp>) -> Vec { + h.state().session.active().unwrap().doc.slides.iter().map(|s| s.id.0).collect() +} + +fn selected_slides(h: &Harness<'_, SlideApp>) -> Vec { + h.state().session.active().unwrap().selection.slides.iter().map(|s| s.0).collect() +} + +#[test] +fn dragging_a_selected_slide_moves_the_whole_selection() { + let mut h = harness(false); + let ids = slide_order(&h); + h.state_mut().run("slide.selectSlides", json!({"ids": [ids[0], ids[2]]})).unwrap(); + h.run_steps(2); + let (first, last) = (h.get_by_label("Slide 1").rect(), h.get_by_label("Slide 4").rect()); + // The right half of the last slide: the gap after it. + drag(&mut h, first.center(), Pos2::new(last.right() - 10.0, last.center().y)); + assert_eq!(slide_order(&h), [ids[1], ids[3], ids[0], ids[2]]); + assert_eq!(selected_slides(&h), [ids[0], ids[2]], "the moved slides stay selected"); + assert!(h.state().thumb_drag.is_none()); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(slide_order(&h), ids, "one undo puts both back"); +} + +#[test] +fn dragging_an_unselected_slide_moves_only_it() { + let mut h = harness(false); + let ids = slide_order(&h); + h.state_mut().run("slide.selectSlides", json!({"ids": [ids[0], ids[2]]})).unwrap(); + h.run_steps(2); + let (second, last) = (h.get_by_label("Slide 2").rect(), h.get_by_label("Slide 4").rect()); + drag(&mut h, second.center(), Pos2::new(last.right() - 10.0, last.center().y)); + assert_eq!(slide_order(&h), [ids[0], ids[2], ids[3], ids[1]]); + assert_eq!(selected_slides(&h), [ids[1]]); +} + +#[test] +fn dropping_a_selection_where_it_is_changes_nothing() { + let mut h = harness(false); + let ids = slide_order(&h); + h.state_mut().run("slide.selectSlides", json!({"ids": [ids[1], ids[2]]})).unwrap(); + h.run_steps(2); + let steps = h.state().session.active().unwrap().history.undo.len(); + let (second, third) = (h.get_by_label("Slide 2").rect(), h.get_by_label("Slide 3").rect()); + drag(&mut h, second.center(), Pos2::new(third.right() - 10.0, third.center().y)); + assert_eq!(slide_order(&h), ids); + assert_eq!(h.state().session.active().unwrap().history.undo.len(), steps, "a drop in place adds no undo step"); +} + #[test] fn ctrl_a_selects_every_slide() { let mut h = harness(false); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 14341110..1e2ef52f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,7 @@ # UI parity with PowerPoint > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (Picture Format: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (dragging several selected slides moves them all; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -101,7 +102,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Group fill and outline | formats the members | Shape Fill and Shape Outline reach nested members in one undo step; a transparency-only fill edit retains each member's colour (#1079) | | WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. Adjust handles: see WordArt adjust handles | | Rulers with indent markers and tab stops | yes | yes: indent markers and tab stops drag on the ruler (a click adds a tab, the corner selector picks its kind); Tabs dialog from Home › Paragraph and the Paragraph dialog | -| Drag and drop of files and between thumbnails | yes | yes | +| Drag and drop of files and between thumbnails | yes | yes; several selected slides move together as one run, in one undo step, in the thumbnail pane and the Slide Sorter | | Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes | | Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), and rich text copies and pastes as HTML (bold, italic, underline). Windows image paste stays open (#1414) | | Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) | @@ -171,6 +172,7 @@ consistency is open (#73, #56). | Date | Change | Summary | |---|---|---| | 2026-10-29 | trivial | Picture Format: the Picture Styles row shows as many styles as fit beside the groups after it (at least two) and a More arrow with all 24, as the Transitions and Animations galleries do; Crop and Size are in view in the default window (#1232) | +| 2026-10-26 | trivial | Thumbnail pane and Slide Sorter: dragging one of several selected slides moves the whole selection | | 2026-10-24 | trivial | Table cells: dragging across cells selects a range of cells | | 2026-10-24 | trivial | Edit Points: Shift, Ctrl/Cmd or Alt/Option held while dragging a handle makes the point smooth, straight or corner | | 2026-10-24 | trivial | Columns dialog: the number of text columns and the spacing between them. Dialog count 41 → 42 |