Merge pull request #1107

This commit is contained in:
Brandon Thomas
2026-10-25 13:42:06 -04:00
6 changed files with 190 additions and 9 deletions
+2
View File
@@ -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 |
+145
View File
@@ -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),
@@ -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}))
@@ -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}))
}
@@ -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
View File
@@ -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,
}
+4 -2
View File
@@ -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}));
}
+30
View File
@@ -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]);
}
+4 -7
View File
@@ -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<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}));
}