diff --git a/ROADMAP.md b/ROADMAP.md index b9c65f80..bee9d11d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -158,6 +158,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour | | 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names | | 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) | +| 2026-10-21 | Slide list and slide sorter: Shift+click selects a range of slides and Ctrl+Shift+click adds it to the selection (`slide.selectRange`, #1105) | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) | | 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) | @@ -297,6 +298,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-24 | trivial | Progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option. Scores and milestone names unchanged | | 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged | | 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged | +| 2026-10-21 | trivial | Progress log: slide range selection (#1105). Scores and milestone names unchanged | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) | | 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged | diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 6d5eb4bc..787c6547 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -28,6 +28,7 @@ pub fn specs() -> Vec { cmd!(noundo "slide.previous", "Previous Slide", [], Some("PageUp"), "{}", has_slide, prev), cmd!(noundo "slide.first", "First Slide", [], Some("Home"), "{}", has_slide, first), cmd!(noundo "slide.last", "Last Slide", [], Some("End"), "{}", has_slide, last), + cmd!(noundo "slide.selectRange", "Select Slide Range", [], None, "{to: index, from?: index (default the current slide), add?: bool (keep the slides already selected)}", has_slide, select_range), cmd!(noundo "slide.selectSlides", "Select Slides", [], None, "{ids?: [slide id], indices?: [index], add?: bool}", has_slide, select_slides), cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide), cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout), @@ -303,6 +304,7 @@ fn go_to(s: &mut Session, i: usize) -> Result { sel.shapes.clear(); sel.text = None; sel.cells = None; + sel.range_anchor = None; sel.target = Target::Slides; })?; Ok(json!({"index": s.doc()?.selection.slide})) @@ -368,6 +370,47 @@ fn select_slides(s: &mut Session, p: &Value) -> Result { } sel.shapes.clear(); sel.text = None; + sel.range_anchor = None; + })?; + Ok(json!({"selected": s.doc()?.selection.slides})) +} + +/// Shift-click in the slide list: select every slide from `from` to `to`. Without `from` the range +/// starts at the anchor of the previous Shift-click (so a second Shift-click extends from the same +/// slide, as in PowerPoint), else at the current slide. +/// With `add` the slides already selected stay selected (Ctrl+Shift-click). +fn select_range(s: &mut Session, p: &Value) -> Result { + let to = usize_param(p, "to").ok_or_else(|| bad("slide.selectRange", "missing `to`"))?; + let given = opt_usize_param(p, "from", "slide.selectRange")?; + let add = bool_or(p, "add", false); + let (from, count) = { + let st = s.doc()?; + let sel = &st.selection; + let current = st.doc.slides.get(sel.slide).map(|x| x.id); + let anchor = sel.range_anchor.filter(|(_, end)| Some(*end) == current).and_then(|(a, _)| st.doc.slide_index(a)); + (given.or(anchor).unwrap_or(sel.slide), st.doc.slides.len()) + }; + if to >= count || from >= count { + return Err(bad("slide.selectRange", "no such slide")); + } + let (a, b) = (from.min(to), from.max(to)); + s.select(|doc, sel| { + let range: Vec = doc.slides.iter().skip(a).take(b - a + 1).map(|x| x.id).collect(); + if add { + for id in range { + if !sel.slides.contains(&id) { + sel.slides.push(id); + } + } + } else { + sel.slides = range; + } + sel.slide = to; + sel.range_anchor = doc.slides.get(from).zip(doc.slides.get(to)).map(|(a, b)| (a.id, b.id)); + sel.shapes.clear(); + sel.text = None; + sel.cells = None; + sel.target = Target::Slides; })?; Ok(json!({"selected": s.doc()?.selection.slides})) } @@ -697,4 +740,106 @@ mod tests { let want = [(0, "Level one"), (1, "Level two"), (2, "Level three"), (0, "Level one again")]; assert_eq!(got, want.iter().map(|(l, t)| (*l, t.to_string())).collect::>()); } + + fn deck_of(n: usize) -> Session { + let mut s = Session::with_new(); + while s.doc().expect("doc").doc.slides.len() < n { + s.execute("slide.new", &json!({})).expect("new slide"); + } + s + } + + fn selected_indices(s: &Session) -> Vec { + let st = s.doc().expect("doc"); + let mut v: Vec = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect(); + v.sort_unstable(); + v + } + + #[test] + fn select_range_covers_every_slide_between() { + let mut s = deck_of(6); + s.execute("slide.go", &json!({"index": 1})).expect("go"); + s.execute("slide.selectRange", &json!({"to": 4})).expect("range"); + assert_eq!(selected_indices(&s), vec![1, 2, 3, 4]); + assert_eq!(s.doc().expect("doc").selection.slide, 4); + // Backwards works too, and replaces the earlier selection. + s.execute("slide.selectRange", &json!({"from": 5, "to": 3})).expect("range"); + assert_eq!(selected_indices(&s), vec![3, 4, 5]); + } + + #[test] + fn select_range_add_keeps_the_existing_selection() { + let mut s = deck_of(8); + s.execute("slide.selectSlides", &json!({"indices": [0, 1]})).expect("select"); + s.execute("slide.go", &json!({"index": 4})).expect("go"); + s.execute("slide.selectSlides", &json!({"indices": [4]})).expect("select"); + s.execute("slide.selectRange", &json!({"to": 6, "add": true})).expect("range"); + assert_eq!(selected_indices(&s), vec![4, 5, 6]); + s.execute("slide.selectSlides", &json!({"indices": [0]})).expect("select"); + s.execute("slide.selectRange", &json!({"from": 3, "to": 5, "add": true})).expect("range"); + assert_eq!(selected_indices(&s), vec![0, 3, 4, 5]); + } + + #[test] + fn select_range_rejects_out_of_range_slides() { + let mut s = deck_of(3); + assert!(s.execute("slide.selectRange", &json!({"to": 3})).is_err()); + assert!(s.execute("slide.selectRange", &json!({"from": 9, "to": 1})).is_err()); + assert!(s.execute("slide.selectRange", &json!({})).is_err()); + } + + #[test] + fn select_range_rejects_an_invalid_from_instead_of_using_the_current_slide() { + let mut s = deck_of(4); + s.execute("slide.go", &json!({"index": 2})).expect("go"); + assert!(s.execute("slide.selectRange", &json!({"from": -1, "to": 3})).is_err()); + assert!(s.execute("slide.selectRange", &json!({"from": "x", "to": 3})).is_err()); + assert_eq!(selected_indices(&s), vec![2]); + } + + #[test] + fn a_second_range_extends_from_the_original_anchor() { + let mut s = deck_of(8); + s.execute("slide.go", &json!({"index": 2})).expect("go"); + s.execute("slide.selectRange", &json!({"to": 5})).expect("range"); + assert_eq!(selected_indices(&s), vec![2, 3, 4, 5]); + // Shift-click further on: still anchored at 2, not at 5. + s.execute("slide.selectRange", &json!({"to": 7})).expect("range"); + assert_eq!(selected_indices(&s), vec![2, 3, 4, 5, 6, 7]); + // Shift-click back across the anchor. + s.execute("slide.selectRange", &json!({"to": 0})).expect("range"); + assert_eq!(selected_indices(&s), vec![0, 1, 2]); + // A plain click on another slide starts a new anchor. + s.execute("slide.go", &json!({"index": 4})).expect("go"); + s.execute("slide.selectRange", &json!({"to": 6})).expect("range"); + assert_eq!(selected_indices(&s), vec![4, 5, 6]); + } + + #[test] + fn moving_a_slide_drops_the_range_anchor() { + let mut s = deck_of(6); + s.execute("slide.go", &json!({"index": 0})).expect("go"); + s.execute("slide.selectRange", &json!({"to": 2})).expect("range"); + // Slide 0 moves to index 2 and stays current; index 0 now holds another slide, so the + // next Shift-click starts at the moved slide instead of at index 0. + s.execute("slide.move", &json!({"from": 0, "to": 2})).expect("move"); + assert_eq!(s.doc().expect("doc").selection.slide, 2); + s.execute("slide.selectRange", &json!({"to": 4})).expect("range"); + assert_eq!(selected_indices(&s), vec![2, 3, 4]); + } + + #[test] + fn select_range_clears_a_table_cell_selection_and_returns_to_the_slides() { + let mut s = deck_of(3); + s.select(|_, sel| { + sel.cells = Some((deckcraft_model::ShapeId(1), (0, 0), (1, 1))); + sel.target = crate::Target::Master { master: 0 }; + }) + .expect("cells"); + s.execute("slide.selectRange", &json!({"to": 2})).expect("range"); + let sel = &s.doc().expect("doc").selection; + assert!(sel.cells.is_none()); + assert_eq!(sel.target, crate::Target::Slides); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0eabc5fc..382cbbf9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -155,6 +155,11 @@ pub struct Selection { pub text: Option, /// Selected table cells: (table, (r0, c0), (r1, c1)). pub cells: Option<(ShapeId, (usize, usize), (usize, usize))>, + /// Slide-list range last made with Shift-click: `(anchor, end)`. A further Shift-click extends from + /// the anchor while the current slide is still `end`; moving to another slide drops it. Held as + /// slide ids, so moving, inserting or deleting slides can't make it name other slides. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range_anchor: Option<(SlideId, SlideId)>, pub target: Target, } diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 4d39d62c..210a802c 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -232,8 +232,10 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32, p.text(pos2(img.max.x, img.max.y + 12.0), Align2::RIGHT_CENTER, format!("★ {}", tr.kind), theme::font(10.5), t.text_faint); } if resp.clicked() { - let add = ui.input(|inp| inp.modifiers.command); - if add { + let (shift, add) = ui.input(|inp| (inp.modifiers.shift, inp.modifiers.command)); + if shift { + let _ = app.run("slide.selectRange", json!({"to": i, "add": add})); + } else if add { if let Some(id) = id { let _ = app.run("slide.selectSlides", json!({"ids": [id.0], "add": true})); } diff --git a/crates/ui-egui/src/sorter_tests.rs b/crates/ui-egui/src/sorter_tests.rs index c4c4b9af..3a5d17c8 100644 --- a/crates/ui-egui/src/sorter_tests.rs +++ b/crates/ui-egui/src/sorter_tests.rs @@ -179,3 +179,33 @@ fn ctrl_a_selects_every_slide() { let all = st.doc.slides.len(); assert_eq!(st.selection.slides.len(), all, "Ctrl+A in the sorter selects every slide"); } + +fn selected(h: &Harness<'_, SlideApp>) -> Vec { + let st = h.state().session.active().unwrap(); + let mut v: Vec = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect(); + v.sort_unstable(); + v +} + +#[test] +fn shift_and_ctrl_shift_clicks_select_ranges() { + use egui::Modifiers; + let mut h = harness(false); + let click = |h: &mut Harness<'_, SlideApp>, n: usize, m: Modifiers| { + h.get_by_label(&format!("Slide {n}")).click_modifiers(m); + h.run_steps(2); + }; + // Click slide 2, Shift+click slide 4: slides 2–4, slide 4 current. + click(&mut h, 2, Modifiers::NONE); + click(&mut h, 4, Modifiers::SHIFT); + assert_eq!(selected(&h), vec![1, 2, 3]); + assert_eq!(h.state().session.active().unwrap().selection.slide, 3); + // A second Shift+click extends from slide 2 again, not from slide 4. + click(&mut h, 1, Modifiers::SHIFT); + assert_eq!(selected(&h), vec![0, 1]); + // Click 1, Ctrl+click 2, Ctrl+Shift+click 4: the range joins what is selected. + click(&mut h, 1, Modifiers::NONE); + click(&mut h, 3, Modifiers::COMMAND); + click(&mut h, 4, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(selected(&h), vec![0, 2, 3]); +} diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index 0fe82166..25be3e8c 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -160,14 +160,11 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { if resp.clicked() { let (shift, cmd) = ui.input(|i| (i.modifiers.shift, i.modifiers.command)); if let Some(id) = id { - if cmd { + if shift { + // Shift: the slides between the current one and this one. Ctrl+Shift keeps what is selected. + let _ = app.run("slide.selectRange", json!({"to": i, "add": cmd})); + } else if cmd { let _ = app.run("slide.selectSlides", json!({"ids": [id.0], "add": true})); - } else if shift { - let (a, b) = (cur.min(i), cur.max(i)); - let ids: Vec = doc.slides.iter().skip(a).take(b - a + 1).map(|s| s.id.0).collect(); - let _ = app.run("slide.selectSlides", json!({"ids": ids})); - let _ = app.run("slide.go", json!({"index": i})); - let _ = app.run("slide.selectSlides", json!({"ids": ids})); } else { let _ = app.run("slide.go", json!({"index": i})); }