From fdfdb9e058bfc5906d24421048b30dbd4f2290ee Mon Sep 17 00:00:00 2001 From: Faabyy <68284158+Faabyy@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:04:19 +0000 Subject: [PATCH] Reuse Slides: choose which slides of the other file to insert (#1103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse Slides… used to insert every slide of the picked presentation. It now opens a list of that presentation's slides (number and title, all ticked), with Select All / Select None and a Keep source formatting option, and inserts only the ticked ones after the current slide. New query slide.reuseList {path | data} gives the list; slide.reuse already took `slides`. Co-Authored-By: Claude --- crates/engine/src/cmd/reuse.rs | 67 +++++++++++++++++++++++++++++----- crates/ui-egui/src/dialogs.rs | 61 +++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 15 +++++++- 3 files changed, 133 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index 1e6c09ac..05052ebd 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -17,15 +17,34 @@ use super::*; use crate::{Result, Session, media_refs}; pub fn specs() -> Vec { - vec![cmd!( - "slide.reuse", - "Reuse Slides…", - ["Home", "Slides", "New Slide"], - None, - "{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}", - has_doc, - reuse - )] + vec![ + cmd!( + query "slide.reuseList", + "List Slides to Reuse", + [], + None, + "{path | data: base64, name?} → {name, slides: [{index, title, hidden}]}: the slides of another presentation, for choosing which ones `slide.reuse` brings in", + has_doc, + reuse_list + ), + cmd!( + "slide.reuse", + "Reuse Slides…", + ["Home", "Slides", "New Slide"], + None, + "{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}", + has_doc, + reuse + ), + ] +} + +fn reuse_list(_s: &mut Session, p: &Value) -> Result { + let (name, bytes) = super::insert::media_bytes(p, "slide.reuseList")?; + let src = super::file::open_presentation(&name, &bytes)?; + let slides: Vec = + src.slides.iter().enumerate().map(|(index, sl)| json!({"index": index, "title": sl.title(), "hidden": sl.hidden})).collect(); + Ok(json!({"name": name, "slides": slides})) } /// Point every media id under `x` at its copy in the destination (`map`: source → destination). @@ -397,4 +416,34 @@ mod tests { assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link"); assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too"); } + + /// The list that lets a dialog offer the slides of the other file; picking from it brings in + /// exactly those. + #[test] + fn reuse_list_names_the_slides_and_the_picked_ones_come_in() { + let mut src = Session::with_new(); + for t in ["Alpha", "Beta", "Gamma"] { + src.execute("slide.new", &json!({"layout": "titleAndContent", "title": t})).expect("new"); + } + let last_slide = src.doc().expect("doc").doc.slides.len() - 1; + src.execute("slide.hide", &json!({"index": last_slide})).expect("hide"); + let bytes = crate::cmd::file::save_bytes(&src.doc().expect("doc").doc, "deckcraft").expect("save"); + let data = super::super::base64_encode(&bytes); + + let mut s = Session::with_new(); + let listed = s.execute("slide.reuseList", &json!({"data": data, "name": "src.deckcraft"})).expect("list"); + let slides = listed["slides"].as_array().expect("slides"); + let title = |i: usize| slides.iter().find(|x| x["index"] == i).and_then(|x| x["title"].as_str()).unwrap_or_default().to_string(); + assert!(slides.len() >= 3); + let (a, b, g) = (title(slides.len() - 3), title(slides.len() - 2), title(slides.len() - 1)); + assert_eq!((a.as_str(), b.as_str(), g.as_str()), ("Alpha", "Beta", "Gamma")); + assert_eq!(slides.last().map(|x| x["hidden"].clone()), Some(json!(true))); + assert_eq!(s.doc().expect("doc").doc.slides.len(), 1, "listing changes nothing"); + + let last = slides.len() - 1; + s.execute("slide.reuse", &json!({"data": data, "name": "src.deckcraft", "slides": [last - 2, last]})).expect("reuse"); + let titles: Vec = s.doc().expect("doc").doc.slides.iter().map(|x| x.title()).collect(); + assert!(titles.iter().any(|t| t == "Alpha") && titles.iter().any(|t| t == "Gamma")); + assert!(!titles.iter().any(|t| t == "Beta"), "a slide that was not picked stays out"); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2393bd61..d4f184f2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -42,6 +42,7 @@ fn title(id: &str) -> &'static str { "altText" => "Alt Text", "outline" => "Slides from Outline", "renameSection" => "Rename Section", + "reuseSlides" => "Reuse Slides", "renameShape" => "Rename", "renameLayout" => "Rename Layout", "paragraph" => "Paragraph", @@ -1522,6 +1523,66 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "reuseSlides" => { + let path = d.params.get("path").and_then(Value::as_str).unwrap_or("").to_string(); + let slides: Vec<(usize, String)> = d + .params + .get("slides") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|x| { + Some((usize::try_from(x.get("index")?.as_u64()?).ok()?, x.get("title").and_then(Value::as_str).unwrap_or("").to_string())) + }) + .collect() + }) + .unwrap_or_default(); + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); + ui.label(format!("Insert slides from {name} after the current slide:")); + // Unticked slides are remembered by index; everything starts ticked. + let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1"); + ui.horizontal(|ui| { + if ui.small_button("Select All").clicked() { + for (i, _) in &slides { + d.fields.remove(&format!("off{i}")); + } + } + if ui.small_button("Select None").clicked() { + for (i, _) in &slides { + d.fields.insert(format!("off{i}"), "1".into()); + } + } + }); + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (i, title) in &slides { + let mut on = !off(d, *i); + let label = if title.trim().is_empty() { format!("{} (no title)", i + 1) } else { format!("{} {}", i + 1, title.trim()) }; + if ui.checkbox(&mut on, label).changed() { + if on { + d.fields.remove(&format!("off{i}")); + } else { + d.fields.insert(format!("off{i}"), "1".into()); + } + } + } + }); + let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false); + if ui.checkbox(&mut keep, "Keep source formatting").changed() + && let Some(o) = d.params.as_object_mut() + { + o.insert("keep".into(), json!(keep)); + } + let picked: Vec = slides.iter().map(|(i, _)| *i).filter(|i| !off(d, *i)).collect(); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + if picked.is_empty() { + app.set_status("Tick at least one slide to insert".to_string()); + return false; + } + app.reuse_slides(&path, keep, &picked); + } + ok || cancel + } "renameSection" | "renameShape" | "renameLayout" => { let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string(); field(ui, d, "Name:", "name", &cur); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eef9b1b3..2c4f9ee7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1233,7 +1233,20 @@ impl SlideApp { return; }; let Some(path) = pick("open") else { return }; - match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep})) { + // Offer the file's slides to choose from; all of them are ticked to begin with. + match self.session.execute("slide.reuseList", &json!({"path": path})) { + Ok(list) => { + let mut d = dialogs::Dialog::new("reuseSlides"); + d.params = json!({"path": path, "keep": keep, "slides": list["slides"]}); + self.dialog = Some(d); + } + Err(e) => self.set_status(e.to_string()), + } + } + + /// Insert the slides `picked` (indices) of the presentation at `path` after the current slide. + pub fn reuse_slides(&mut self, path: &str, keep: bool, picked: &[usize]) { + match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep, "slides": picked})) { Ok(r) => self.set_status(format!("Inserted {} slides from {path}", r["slides"])), Err(e) => self.set_status(e.to_string()), }