Merge pull request #1109

This commit is contained in:
Brandon Thomas
2026-10-25 13:42:06 -04:00
3 changed files with 188 additions and 9 deletions
+4 -2
View File
@@ -1645,9 +1645,11 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
app.set_status("Tick at least one slide to insert".to_string());
return false;
}
app.reuse_slides(&path, keep, &picked);
// A failed insert keeps the dialog (and the ticked slides) open; the error is in the status bar.
let source = d.params.get("source").cloned().unwrap_or_default();
return app.reuse_slides(&source, keep, &picked);
}
ok || cancel
cancel
}
"renameSection" | "renameShape" | "renameLayout" => {
let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string();
+45 -7
View File
@@ -534,6 +534,8 @@ pub struct SlideApp {
pub show: Option<show::Show>,
/// Current slide's animations playing on the editor canvas (not the full-screen show).
anim_preview: Option<AnimPreview>,
/// Where the Reuse Slides picker inserts: the presentation and slide current when it opened.
reuse_target: Option<FileTarget>,
/// Audio/video playback (editor control bar, slide show, `media.*` commands).
pub media: media::MediaHost,
pub status: Option<(String, f64)>,
@@ -806,6 +808,7 @@ impl SlideApp {
thesaurus_query: String::new(),
show: None,
anim_preview: None,
reuse_target: None,
media: media::MediaHost::new(audio_out),
status: None,
opening: None,
@@ -1609,22 +1612,55 @@ impl SlideApp {
return;
};
let Some(path) = pick("open") else { return };
// Read the file once: the list shown and the slides inserted come from the same bytes, even
// if the file changes while the dialog is open. Without a reader the path is used as is.
let mut source = json!({});
match self.services.read.as_ref().map(|r| r(&path)) {
Some(Ok(bytes)) => {
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
source["name"] = json!(name);
source["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes));
}
Some(Err(e)) => {
self.set_status(e.to_string());
return;
}
None => source["path"] = json!(path),
}
// 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})) {
match self.session.execute("slide.reuseList", &source) {
Ok(list) => {
let mut d = dialogs::Dialog::new("reuseSlides");
d.params = json!({"path": path, "keep": keep, "slides": list["slides"]});
d.params = json!({"path": path, "source": source, "keep": keep, "slides": list["slides"]});
self.reuse_target = Some(self.capture_file_target());
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()),
/// Insert the slides `picked` (indices) of `source` (the `name` + `data`, or `path`, that
/// `slide.reuseList` listed) after the slide that was current when the picker opened, in that
/// presentation, even if another one is active now; if it is closed or the slide is gone,
/// nothing is inserted. Returns whether they were inserted.
pub fn reuse_slides(&mut self, source: &Value, keep: bool, picked: &[usize]) -> bool {
let mut args = if source.is_object() { source.clone() } else { json!({}) };
args["keepFormatting"] = json!(keep);
args["slides"] = json!(picked);
let result = match self.reuse_target {
Some(target) if !self.viewing_target(&target) => self.execute_on_target(&target, "Reuse Slides", "slide.reuse", &args),
_ => self.session.execute("slide.reuse", &args).map(|_| ()).map_err(|e| e.to_string()),
};
match result {
Ok(()) => {
self.reuse_target = None;
self.set_status(format!("Inserted {} slides", picked.len()));
true
}
Err(e) => {
self.set_status(e);
false
}
}
}
@@ -3513,6 +3549,8 @@ mod last_color_tests;
#[cfg(test)]
mod replacefonts_tests;
#[cfg(test)]
mod reuse_picker_tests;
#[cfg(test)]
mod slide_size_tests;
#[cfg(test)]
mod split_button_tests;
+139
View File
@@ -0,0 +1,139 @@
//! Home › New Slide › Reuse Slides…: the dialog lists the slides of the picked file and inserts the
//! ticked ones; what is inserted is what was listed, and a failed insert keeps the dialog open.
use std::cell::RefCell;
use std::rc::Rc;
use deckcraft_engine::Session;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::{Services, SlideApp};
fn deck_bytes(titles: &[&str]) -> Vec<u8> {
let mut s = Session::with_new();
for t in titles {
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": t})).unwrap();
}
// Drop the new deck's empty first slide, so slide `i` is `titles[i]`.
s.execute("slide.delete", &json!({"index": 0})).unwrap();
deckcraft_engine::cmd::file::save_bytes(&s.doc().unwrap().doc, "deckcraft").unwrap()
}
/// An app whose open dialog picks `src.deckcraft`, read from the shared `file` bytes.
fn harness(file: &Rc<RefCell<Vec<u8>>>) -> Harness<'static, SlideApp> {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let f = Rc::clone(file);
let services = Services {
pick_open: Some(Box::new(|_| Some("/tmp/src.deckcraft".to_string()))),
read: Some(Box::new(move |_| Ok(f.borrow().clone()))),
..Services::default()
};
let app = SlideApp::new(s, services);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(3);
h
}
fn titles(h: &Harness<'_, SlideApp>) -> Vec<String> {
h.state().session.active().unwrap().doc.slides.iter().map(|s| s.title()).collect()
}
fn open_dialog(h: &mut Harness<'_, SlideApp>) {
h.state_mut().run("app.reuseSlidesDialog", json!({})).unwrap();
h.run_steps(3);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("reuseSlides"));
}
fn press_insert(h: &mut Harness<'_, SlideApp>) {
// The ribbon has an Insert tab too; the dialog's button is drawn last.
h.query_all_by_label("Insert").last().expect("the dialog's Insert button").click();
h.run_steps(3);
}
#[test]
fn inserts_the_ticked_slides_and_closes() {
let file = Rc::new(RefCell::new(deck_bytes(&["Alpha", "Beta", "Gamma"])));
let mut h = harness(&file);
open_dialog(&mut h);
// Untick the middle slide.
h.state_mut().dialog.as_mut().unwrap().fields.insert("off1".into(), "1".into());
h.run_steps(2);
press_insert(&mut h);
assert!(h.state().dialog.is_none());
let t = titles(&h);
assert!(t.iter().any(|x| x == "Alpha") && t.iter().any(|x| x == "Gamma") && !t.iter().any(|x| x == "Beta"), "{t:?}");
}
#[test]
fn inserts_what_was_listed_even_if_the_file_changed_meanwhile() {
let file = Rc::new(RefCell::new(deck_bytes(&["Alpha", "Beta"])));
let mut h = harness(&file);
open_dialog(&mut h);
// Someone replaces the file while the dialog is open: slide 0 is now a different one.
*file.borrow_mut() = deck_bytes(&["Other", "Beta"]);
h.state_mut().dialog.as_mut().unwrap().fields.insert("off1".into(), "1".into());
h.run_steps(2);
press_insert(&mut h);
let t = titles(&h);
assert!(t.iter().any(|x| x == "Alpha"), "{t:?}");
assert!(!t.iter().any(|x| x == "Other"), "{t:?}");
}
#[test]
fn inserts_into_the_presentation_the_picker_was_opened_for() {
let file = Rc::new(RefCell::new(deck_bytes(&["Alpha"])));
let mut h = harness(&file);
open_dialog(&mut h);
let first = h.state().session.active().unwrap().uid;
// Another presentation becomes active while the dialog is open.
h.state_mut().session.execute("file.new", &json!({})).unwrap();
let second = h.state().session.active().unwrap().uid;
assert_ne!(first, second);
press_insert(&mut h);
assert!(h.state().dialog.is_none());
let s = &h.state().session;
let doc = |uid: u64| s.documents().iter().find(|d| d.uid == uid).unwrap();
assert!(doc(first).doc.slides.iter().any(|x| x.title() == "Alpha"), "inserted where the picker was opened");
assert_eq!(doc(second).doc.slides.len(), 1, "the other presentation is untouched");
assert_eq!(s.active().unwrap().uid, second, "the view stays on the presentation the user moved to");
}
#[test]
fn nothing_is_inserted_once_that_presentation_is_closed() {
let file = Rc::new(RefCell::new(deck_bytes(&["Alpha"])));
let mut h = harness(&file);
open_dialog(&mut h);
h.state_mut().session.execute("file.new", &json!({})).unwrap();
h.state_mut().session.close_document(0);
press_insert(&mut h);
assert_eq!(h.state().session.documents().len(), 1);
assert!(!titles(&h).iter().any(|x| x == "Alpha"), "{:?}", titles(&h));
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("reuseSlides"), "the dialog stays open with the reason");
}
#[test]
fn a_failed_insert_keeps_the_dialog_open() {
let file = Rc::new(RefCell::new(deck_bytes(&["Alpha"])));
let mut h = harness(&file);
open_dialog(&mut h);
let before = titles(&h);
// Offer a slide the file doesn't have, so the insert is refused.
if let Some(d) = h.state_mut().dialog.as_mut()
&& let Some(a) = d.params["slides"].as_array_mut()
{
a.push(json!({"index": 9, "title": "Ghost", "hidden": false}));
}
h.run_steps(2);
press_insert(&mut h);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("reuseSlides"), "the dialog closed on a failed insert");
assert_eq!(titles(&h), before);
}