From 29bd07e1b8348ac91b7649d6554f791bb56af428 Mon Sep 17 00:00:00 2001 From: Faabyy <68284158+Faabyy@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:54:13 +0000 Subject: [PATCH 1/4] Slides: Ctrl+Shift+click selects the range, also in the slide sorter (#1105) New slide.selectRange {to, from?, add?} selects every slide between the current one (or `from`) and `to`. Shift+click in the slide list and the slide sorter selects that range; Ctrl+Shift+click adds it to the slides already selected. The sorter had no range selection before, and in the list Ctrl+Shift+click used to add only the clicked slide. Co-Authored-By: Claude --- crates/engine/src/cmd/slide.rs | 80 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/sorter.rs | 6 ++- crates/ui-egui/src/thumbs.rs | 11 ++--- 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 8002aac9..e54f9249 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), @@ -372,6 +373,37 @@ fn select_slides(s: &mut Session, p: &Value) -> Result { Ok(json!({"selected": s.doc()?.selection.slides})) } +/// Shift-click in the slide list: select every slide from `from` (default the current one) to `to`. +/// 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 add = bool_or(p, "add", false); + let (from, count) = { + let st = s.doc()?; + (usize_param(p, "from").unwrap_or(st.selection.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.shapes.clear(); + sel.text = None; + })?; + Ok(json!({"selected": s.doc()?.selection.slides})) +} + fn hide(s: &mut Session, p: &Value) -> Result { let targets: Vec = match opt_usize_param(p, "index", "slide.hide")? { Some(i) => vec![i], @@ -697,4 +729,52 @@ 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()); + } } diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 3c840860..5a0e30ee 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -229,8 +229,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/thumbs.rs b/crates/ui-egui/src/thumbs.rs index 4e3f4f0b..d1841224 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -158,14 +158,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})); } From a378c78c253818af9a3fbfceb541f5c973a2791f Mon Sep 17 00:00:00 2001 From: Faabyy <68284158+Faabyy@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:14:31 +0000 Subject: [PATCH 2/4] Roadmap: record sel-range-1105 Co-Authored-By: Claude --- ROADMAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index be172bc3..c75e4920 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -154,6 +154,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) | @@ -292,6 +293,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 | From abdf5f337bdbda742939fb460809cf9c8cb062ae Mon Sep 17 00:00:00 2001 From: Faabyy <68284158+Faabyy@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:38:40 +0000 Subject: [PATCH 3/4] Slides: a second Shift-click extends from the same anchor; the range clears cells and returns to the slides; a bad `from` is an error (#1105) Review on #1107: read `from` with opt_usize_param, clear `sel.cells` and set `target = Slides` as `slide.go` did, keep the anchor of the first Shift-click. A kittest drives the Shift and Ctrl+Shift clicks in the slide sorter. Co-Authored-By: Claude --- crates/engine/src/cmd/slide.rs | 55 ++++++++++++++++++++++++++++-- crates/engine/src/lib.rs | 4 +++ crates/ui-egui/src/sorter_tests.rs | 30 ++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index e54f9249..8773b5b5 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -304,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})) @@ -369,18 +370,24 @@ 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` (default the current one) to `to`. +/// 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()?; - (usize_param(p, "from").unwrap_or(st.selection.slide), st.doc.slides.len()) + let sel = &st.selection; + let anchor = sel.range_anchor.filter(|(_, end)| *end == sel.slide).map(|(a, _)| 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")); @@ -398,8 +405,11 @@ fn select_range(s: &mut Session, p: &Value) -> Result { sel.slides = range; } sel.slide = to; + sel.range_anchor = Some((from, to)); sel.shapes.clear(); sel.text = None; + sel.cells = None; + sel.target = Target::Slides; })?; Ok(json!({"selected": s.doc()?.selection.slides})) } @@ -777,4 +787,45 @@ mod tests { 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 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 bf6d11de..5455f0c3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -155,6 +155,10 @@ 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. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range_anchor: Option<(usize, usize)>, pub target: Target, } 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]); +} From 121441a079eae4502c87dea7a483aff75f0aba08 Mon Sep 17 00:00:00 2001 From: Faabyy <68284158+Faabyy@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:47:18 +0000 Subject: [PATCH 4/4] Slides: the range anchor is held by slide id, so moving, inserting or deleting slides can't shift it (#1105) Co-Authored-By: Claude --- crates/engine/src/cmd/slide.rs | 18 ++++++++++++++++-- crates/engine/src/lib.rs | 5 +++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 8773b5b5..65f4296e 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -386,7 +386,8 @@ fn select_range(s: &mut Session, p: &Value) -> Result { let (from, count) = { let st = s.doc()?; let sel = &st.selection; - let anchor = sel.range_anchor.filter(|(_, end)| *end == sel.slide).map(|(a, _)| a); + 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 { @@ -405,7 +406,7 @@ fn select_range(s: &mut Session, p: &Value) -> Result { sel.slides = range; } sel.slide = to; - sel.range_anchor = Some((from, 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; @@ -815,6 +816,19 @@ mod tests { 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); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5455f0c3..f6e67205 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -156,9 +156,10 @@ pub struct Selection { /// 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. + /// 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<(usize, usize)>, + pub range_anchor: Option<(SlideId, SlideId)>, pub target: Target, }