mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 19:46:58 -06:00
Merge pull request #1107
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user