Merge PR #488 from NikitkaRa: UI: open presentations in the background with a progress window (#350)

# Conflicts:
#	crates/engine/src/cmd/file.rs
#	crates/ui-egui/src/backstage.rs
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-14 15:43:43 -04:00
5 changed files with 250 additions and 14 deletions
+30 -5
View File
@@ -162,17 +162,42 @@ pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, 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<String>,
}
/// Read and parse the presentation at `path`.
#[cfg(not(target_arch = "wasm32"))]
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
pub fn read_presentation_file(path: &str) -> Result<LoadedFile> {
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<String>)> {
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<Value> {
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}))
}
+2 -4
View File
@@ -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);
}
}
}
+9
View File
@@ -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()));
+14 -5
View File
@@ -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<opening::Opening>,
/// A brief notice over the bottom of the window (missing fonts on opening a file).
pub toast: Option<toast::Toast>,
/// 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);
+195
View File
@@ -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<Result<LoadedFile, String>>,
}
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);
}
}