mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 03:56:50 -06:00
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:
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user