Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down
145 changes: 145 additions & 0 deletions crates/engine/src/cmd/slide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
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),
Expand Down Expand Up @@ -303,6 +304,7 @@ fn go_to(s: &mut Session, i: usize) -> Result<Value> {
sel.shapes.clear();
sel.text = None;
sel.cells = None;
sel.range_anchor = None;
sel.target = Target::Slides;
})?;
Ok(json!({"index": s.doc()?.selection.slide}))
Expand Down Expand Up @@ -368,6 +370,47 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
}
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<Value> {
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<SlideId> = 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}))
}
Expand Down Expand Up @@ -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::<Vec<_>>());
}

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<usize> {
let st = s.doc().expect("doc");
let mut v: Vec<usize> = 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);
}
}
5 changes: 5 additions & 0 deletions crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ pub struct Selection {
pub text: Option<TextSel>,
/// 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,
}

Expand Down
6 changes: 4 additions & 2 deletions crates/ui-egui/src/sorter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}));
}
Expand Down
30 changes: 30 additions & 0 deletions crates/ui-egui/src/sorter_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
let st = h.state().session.active().unwrap();
let mut v: Vec<usize> = 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]);
}
11 changes: 4 additions & 7 deletions crates/ui-egui/src/thumbs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32> = 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}));
}
Expand Down
Loading