diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d7356b23..aa0b4719 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -162,17 +162,42 @@ pub fn add_opened(s: &mut Session, name: &str, path: Option, p: Presenta s.add_document(st) } +/// A presentation read from disk, not yet added to a session: [`read_presentation_file`] needs no +/// session, so a host can run it on a background thread and add the result with +/// [`add_presentation_file`]. +#[derive(Debug)] +pub struct LoadedFile { + pub path: String, + pub doc: Presentation, + /// A native or PPTX file (saving goes back to it); an outline import has no file to save to. + pub native: bool, + /// Pictures and media a `.deckcraft` file had lost or damaged. + pub damaged: Vec, +} + +/// Read and parse the presentation at `path`. #[cfg(not(target_arch = "wasm32"))] -fn open(s: &mut Session, p: &Value) -> Result { - let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; +pub fn read_presentation_file(path: &str) -> Result { let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let (doc, damaged) = open_checked(path, &bytes)?; let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes); - // A damaged file opens as an untitled repaired copy, so Save can't overwrite the original - // with the empty media. + Ok(LoadedFile { path: path.to_string(), doc, native, damaged }) +} + +/// Add a presentation read by [`read_presentation_file`] as the active document. A damaged file +/// opens as an untitled repaired copy, so Save can't overwrite the original with the empty media. +pub fn add_presentation_file(s: &mut Session, f: LoadedFile) -> Result<(usize, Vec)> { + let LoadedFile { path, doc, native, damaged } = f; let keep_path = native && damaged.is_empty(); - let i = add_opened(s, path, keep_path.then(|| path.to_string()), doc); + let i = add_opened(s, &path, keep_path.then(|| path.clone()), doc); report_damage(s, &damaged)?; + Ok((i, damaged)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn open(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; + let (i, damaged) = add_presentation_file(s, read_presentation_file(path)?)?; let st = s.doc()?; Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts, "damagedMedia": damaged})) } diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index dccb10ff..59f06aa6 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -593,10 +593,8 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, t.text, false); p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), t.text); p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim); - if resp.on_hover_text(&path).clicked() - && let Err(e) = app.open_path(&path) - { - app.notify(e); + if resp.on_hover_text(&path).clicked() { + app.open_path_in_background(&path); } } } diff --git a/crates/ui-egui/src/backstage_tests.rs b/crates/ui-egui/src/backstage_tests.rs index 015262f8..e0f4e6d2 100644 --- a/crates/ui-egui/src/backstage_tests.rs +++ b/crates/ui-egui/src/backstage_tests.rs @@ -232,6 +232,15 @@ fn open_lists_recent_files_and_opens_one() { h.run_steps(2); assert!(h.query_by_label("Other.pptx").is_none(), "the search box filters by name"); h.get_by_label("Quarterly review.pptx").click(); + h.run_steps(1); + // The file is read in the background (#350); its progress window shows meanwhile. + for _ in 0..300 { + if h.state().opening.is_none() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + h.run_steps(1); + } h.run_steps(3); assert_eq!(h.state().session.documents().len(), 2); assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(path.as_str())); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2d83074c..d1caa59e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,6 +18,7 @@ pub mod fillui; pub mod icons; pub mod media; pub mod menus; +pub mod opening; pub mod panes; mod preview; pub mod qat; @@ -441,6 +442,8 @@ pub struct SlideApp { /// Audio/video playback (editor control bar, slide show, `media.*` commands). pub media: media::MediaHost, pub status: Option<(String, f64)>, + /// A presentation being read in the background, with its progress window ([`opening`]). + pub opening: Option, /// A brief notice over the bottom of the window (missing fonts on opening a file). pub toast: Option, /// Documents (by uid) whose missing fonts have been mentioned. @@ -527,6 +530,7 @@ impl SlideApp { anim_preview: None, media: media::MediaHost::new(audio_out), status: None, + opening: None, toast: None, fonts_noted: Default::default(), perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 }, @@ -1091,10 +1095,8 @@ impl SlideApp { fn pick_and_open(&mut self) { if let Some(pick) = self.services.pick_open.as_mut() { - if let Some(path) = pick("open") - && let Err(e) = self.open_path(&path) - { - self.notify(e); + if let Some(path) = pick("open") { + self.open_path_in_background(&path); } } else if self.services.open_async.is_some() { let id = self.begin_external_file("open"); @@ -1106,11 +1108,16 @@ impl SlideApp { pub fn open_path(&mut self, path: &str) -> Result<(), String> { self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; + self.note_recent(path); + Ok(()) + } + + /// A presentation was opened from `path`: first in the recent list, shown in Normal view. + pub(crate) fn note_recent(&mut self, path: &str) { self.ui.recent.retain(|p| p != path); self.ui.recent.insert(0, path.to_string()); self.ui.recent.truncate(RECENT_MAX); self.ui.view = ViewMode::Normal; - Ok(()) } /// Transitions › Sound › Other Sound…: pick an audio file for the selected slides. @@ -1449,6 +1456,7 @@ impl SlideApp { } self.last_time = now; self.drain_control(ctx); + self.poll_opening(); if self.fonts_ready { self.drain_inbox(); } @@ -1665,6 +1673,7 @@ impl SlideApp { let t = theme::Tokens::get(&ctx); self.tick_media(&ctx); eyedropper::tick(self, &ctx); + self.opening_ui(&ctx); if self.show.is_some() { self.anim_preview = None; show::ui(self, ui); diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs new file mode 100644 index 00000000..d48734ec --- /dev/null +++ b/crates/ui-egui/src/opening.rs @@ -0,0 +1,195 @@ +//! Opening a presentation in the background (#350): reading and parsing a large PPTX no longer +//! freezes the window. A modal shows the file being opened and the time so far, and Cancel (or +//! Escape) gives the window back at once; the abandoned read finishes on its thread and is dropped. + +use crate::SlideApp; +use deckcraft_engine::cmd::file::LoadedFile; +use std::sync::mpsc::{Receiver, TryRecvError}; + +/// A presentation being read on a background thread. +pub struct Opening { + pub path: String, + pub started_ms: f64, + rx: Receiver>, +} + +impl Opening { + /// The file name shown in the window. + pub fn name(&self) -> String { + std::path::Path::new(&self.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| self.path.clone()) + } +} + +impl SlideApp { + /// Open `path` without blocking the UI: the file is read on a thread and the progress window + /// shows until it arrives. Without threads (the web build) it opens at once. + pub fn open_path_in_background(&mut self, path: &str) { + if let Some(o) = &self.opening { + let name = o.name(); + self.set_status(format!("Still opening {name}")); + return; + } + #[cfg(target_arch = "wasm32")] + { + if let Err(e) = self.open_path(path) { + self.set_status(e); + } + } + #[cfg(not(target_arch = "wasm32"))] + { + let (tx, rx) = std::sync::mpsc::channel(); + let p = path.to_string(); + let spawned = std::thread::Builder::new().name("deckcraft-open".into()).spawn(move || { + let r = deckcraft_engine::cmd::file::read_presentation_file(&p).map_err(|e| e.to_string()); + let _ = tx.send(r); + }); + match spawned { + Ok(_) => self.opening = Some(Opening { path: path.to_string(), started_ms: crate::now_ms(), rx }), + Err(_) => { + if let Err(e) = self.open_path(path) { + self.set_status(e); + } + } + } + } + } + + /// Stop waiting for the file being opened; the presentation stays as it is. + pub fn cancel_opening(&mut self) { + if let Some(o) = self.opening.take() { + self.set_status(format!("Stopped opening {}", o.name())); + } + } + + /// Take the background read's result when it has arrived. + pub(crate) fn poll_opening(&mut self) { + let Some(o) = &self.opening else { return }; + let r = match o.rx.try_recv() { + Ok(r) => r, + Err(TryRecvError::Empty) => return, + Err(TryRecvError::Disconnected) => Err(format!("{}: reading the file stopped unexpectedly", o.path)), + }; + let Some(o) = self.opening.take() else { return }; + match r { + Ok(f) => match deckcraft_engine::cmd::file::add_presentation_file(&mut self.session, f) { + Ok(_) => self.note_recent(&o.path), + Err(e) => self.set_status(e.to_string()), + }, + Err(e) => self.set_status(e), + } + } + + /// The progress window over everything while a file opens. + pub(crate) fn opening_ui(&mut self, ctx: &egui::Context) { + let Some(o) = &self.opening else { return }; + let name = o.name(); + let secs = ((crate::now_ms() - o.started_ms) / 1000.0).max(0.0); + let mut cancel = false; + egui::Modal::new(egui::Id::new("deckcraft-opening")).show(ctx, |ui| { + ui.set_width(320.0); + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(18.0)); + ui.label(format!("Opening {name}…")); + }); + ui.add_space(6.0); + ui.weak(format!("Reading the file: {secs:.0} s")); + ui.add_space(6.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + cancel = ui.button("Cancel").clicked(); + }); + }); + if cancel || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + self.cancel_opening(); + } + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + use deckcraft_engine::Session; + + fn saved_deck(name: &str, slides: usize) -> String { + let mut s = Session::with_new(); + for _ in 1..slides { + s.execute("slide.new", &serde_json::json!({})).unwrap(); + } + let path = std::env::temp_dir().join(format!("deckcraft-opening-{}-{name}.deckcraft", std::process::id())); + let path = path.to_string_lossy().to_string(); + s.execute("file.saveAs", &serde_json::json!({"path": path})).unwrap(); + path + } + + fn wait(app: &mut SlideApp) { + for _ in 0..500 { + app.poll_opening(); + if app.opening.is_none() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("opening did not finish"); + } + + #[test] + fn opens_in_the_background_and_adds_the_document() { + let path = saved_deck("ok", 3); + let mut app = SlideApp::new(Session::new(), crate::Services::default()); + app.open_path_in_background(&path); + assert!(app.opening.is_some(), "the read runs on a thread"); + wait(&mut app); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.session.active().unwrap().doc.slides.len(), 3); + assert_eq!(app.ui.recent.first(), Some(&path)); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn cancel_drops_the_result() { + let path = saved_deck("cancel", 2); + let mut app = SlideApp::new(Session::with_new(), crate::Services::default()); + app.open_path_in_background(&path); + app.cancel_opening(); + assert!(app.opening.is_none()); + std::thread::sleep(std::time::Duration::from_millis(200)); + app.poll_opening(); + assert_eq!(app.session.documents().len(), 1, "nothing is added after Cancel"); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn errors_show_in_the_status_bar_and_a_second_open_waits() { + let mut app = SlideApp::new(Session::new(), crate::Services::default()); + app.open_path_in_background("/nonexistent/deckcraft/missing.pptx"); + app.open_path_in_background("/nonexistent/deckcraft/other.pptx"); + assert!(app.status.as_ref().is_some_and(|(m, _)| m.starts_with("Still opening missing.pptx"))); + wait(&mut app); + assert!(app.session.documents().is_empty()); + assert!(app.status.as_ref().is_some_and(|(m, _)| m.contains("missing.pptx")), "{:?}", app.status); + } + + #[test] + fn the_progress_window_shows_and_cancel_closes_it() { + use egui_kittest::kittest::Queryable; + let mut app = SlideApp::new(Session::with_new(), crate::Services::default()); + // A read that never answers: the window stays until Cancel. + let (_tx, rx) = std::sync::mpsc::channel(); + app.opening = Some(Opening { path: "/decks/Big deck.pptx".into(), started_ms: crate::now_ms(), rx }); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + harness.get_by_label("Opening Big deck.pptx…"); + harness.get_by_label("Cancel").click(); + harness.run_steps(2); + assert!(harness.state().opening.is_none()); + assert_eq!(harness.state().session.documents().len(), 1); + } +}