diff --git a/ROADMAP.md b/ROADMAP.md index 890f36fe..4c5c6512 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -219,6 +219,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) | | 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items | | 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes | +| 2026-10-13 | Start screen: a missing Recent file is marked and its click shows the error there; entries can be removed from the list (#172) | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index dccb10ff..fa413c74 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -398,6 +398,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.label(RichText::new(bs.page.label()).font(theme::bold(22.0)).color(t.text)); ui.add_space(16.0); } + // Errors raised on a backstage page (a missing recent file, a failed open) would + // otherwise vanish: the status bar only draws once the backstage is closed (#172). + // A real label, not painted text, so screen readers see it too. + if let Some(msg) = app.fresh_status() { + ui.label(RichText::new(&msg).font(theme::font(12.0)).color(t.accent)); + ui.add_space(8.0); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } match bs.page { Page::Home => home(app, ui, &mut bs, &mut out), Page::New => new_page(app, ui, &mut bs), @@ -583,6 +591,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { } let width = ui.available_width().min(CONTENT_W); for path in list { + let missing = !app.recent_exists(&path); let (name, folder) = split_path(&path); let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); @@ -590,10 +599,40 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { if resp.hovered() { p.rect_filled(rect, CornerRadius::same(3), t.hover); } - 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 ink = if missing { t.text_dim } else { t.text }; + // The row is as wide as the page's content, which a narrow window clips: keep the mark + // and the remove button inside the visible part of the row. + let visible_right = rect.right().min(ui.clip_rect().right() - 6.0); + { + let p = ui.painter(); + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, ink, false); + p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), ink); + p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim); + // A missing file says so before it is clicked (#172). + if missing { + p.text(pos2(visible_right - 82.0, rect.center().y), Align2::RIGHT_CENTER, "(missing)", theme::font(11.0), t.text_dim); + } + } + let resp = if missing { + resp.on_hover_text(format!("{path} — this file can't be found")) + } else { + resp.on_hover_text(&path) + }; + // Remove from the list: a real labelled widget on top of the row, with a code-drawn icon + // (a text glyph would need a font that has it). + let x = ui.allocate_rect( + Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)), + Sense::click(), + ); + x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Remove from Recent")); + if x.hovered() { + ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(x.rect.center(), vec2(14.0, 14.0)), Icon::Close, t.text_dim, false); + if x.on_hover_text("Remove from Recent").clicked() { + app.forget_recent(&path); + } + if resp.clicked() && let Err(e) = app.open_path(&path) { app.notify(e); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cd69fba7..6ebaeedc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ pub mod toast; pub mod uifonts; pub mod widgets; +use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender}; use deckcraft_engine::{Mods, Session, UiRequest}; @@ -48,6 +49,9 @@ pub type DownloadFn = Box; /// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files). pub type Inbox = std::sync::Arc)>>>; +/// How long a transient status message stays visible. +const STATUS_LINGER_MS: f64 = 6000.0; + /// Platform services injected by the host (desktop or web). #[derive(Default)] pub struct Services { @@ -288,6 +292,9 @@ pub struct SlideApp { pub(crate) slides_focused: bool, /// The web picker's next picture replaces the selected one (`app.changePictureDialog`). change_picture: bool, + /// Which recent paths exist on disk, probed once per path per session (a dead network share + /// must not stall every frame). Not persisted. + recent_checked: HashMap, } impl SlideApp { @@ -335,6 +342,7 @@ impl SlideApp { sorter: sorter::State::default(), slides_focused: false, change_picture: false, + recent_checked: HashMap::new(), } } @@ -353,6 +361,56 @@ impl SlideApp { self.toast = Some(toast::Toast { text: msg.into(), at: now_ms() }); } + /// The transient status message while it is fresh; clears it once stale. Both the status bar + /// and the start screen draw it — the start screen has no status bar, and errors raised there + /// (a missing recent file, a failed sample) used to vanish (issue #172). + pub fn fresh_status(&mut self) -> Option { + let (msg, at) = self.status.clone()?; + if now_ms() - at < STATUS_LINGER_MS { + Some(msg) + } else { + self.status = None; + None + } + } + + /// Does this recent path exist on disk? Probed once per path per session and only on + /// desktop (the web build has no file system to ask): a stat on a dead network share can + /// block for seconds, so it must not repeat on every frame. A stale answer is harmless — + /// opening still tries and reports, and a successful open re-probes. + pub fn recent_exists(&mut self, path: &str) -> bool { + #[cfg(target_arch = "wasm32")] + { + let _ = path; + true + } + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(ok) = self.recent_checked.get(path) { + return *ok; + } + // A stat on a dead network location (UNC share, URL) can block for the OS timeout, + // so remote-looking paths are never probed: they stay unmarked and are judged when + // opened. A disconnected mapped drive would still stall once — accepted. + if path.starts_with("\\\\") || path.contains("://") { + return true; + } + let ok = std::path::Path::new(path).is_file(); + if self.recent_checked.len() > 64 { + let live: std::collections::HashSet<&String> = self.ui.recent.iter().collect(); + self.recent_checked.retain(|p, _| live.contains(p)); + } + self.recent_checked.insert(path.to_string(), ok); + ok + } + } + + /// Drop a path from the Recent list (its file is gone or the user asked for it). + pub fn forget_recent(&mut self, path: &str) { + self.ui.recent.retain(|p| p != path); + self.recent_checked.remove(path); + } + /// Run a command by id (UI commands here, everything else in the engine). Errors show in the /// status bar and are returned. pub fn run(&mut self, id: &str, params: Value) -> Result { @@ -709,6 +767,8 @@ 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.status = None; // an older error (a missing file just before this one) must not linger + self.recent_checked.remove(path); self.ui.recent.retain(|p| p != path); self.ui.recent.insert(0, path.to_string()); self.ui.recent.truncate(12); @@ -1332,6 +1392,7 @@ mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; + use egui_kittest::kittest::Queryable; fn title_text(app: &SlideApp) -> String { let d = app.session.active().unwrap(); @@ -1854,4 +1915,63 @@ mod tests { let st = harness.state().session.active().unwrap(); assert!(!st.selection.shapes.is_empty(), "canvas select all picks the shapes"); } + + fn harness() -> egui_kittest::Harness<'static, SlideApp> { + egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::new(), Services::default()), + ) + } + + /// #172: a recent file that no longer exists is marked on File › Home, and clicking it draws + /// the open error right there (the status bar is hidden while the backstage is open, so the + /// message used to vanish). + #[test] + fn missing_recent_is_marked_and_its_error_shows() { + let mut harness = harness(); + let missing = "/deckcraft-tests/172/missing.deckcraft"; + harness.state_mut().ui.recent = vec![missing.to_string()]; + harness.run_steps(2); + // The row is labelled with the file's name. Clicking it raises the open error, and the + // drawn message is a second node that mentions the name (the OS text after it varies). + harness.get_by_label("missing.deckcraft").click(); + harness.run_steps(2); + assert!(harness.state().status.is_some(), "the open error was raised"); + assert!( + harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, + "the open error is drawn on the page, next to the entry" + ); + } + + /// #172: a Recent entry can be removed from the list. + #[test] + fn recent_entry_can_be_removed() { + let mut harness = harness(); + harness.state_mut().ui.recent = vec!["/deckcraft-tests/172/a.deckcraft".into(), "/deckcraft-tests/172/b.deckcraft".into()]; + harness.run_steps(2); + let removes = harness.get_all_by_label("Remove from Recent").collect::>(); + assert_eq!(removes.len(), 2); + // The remove button must sit inside the visible part of the row even when the page's + // content is wider than the window. + assert!(removes[1].rect().right() < 1200.0, "reachable in the 1200-wide window: {:?}", removes[1].rect()); + removes[1].click(); + harness.run_steps(1); + assert_eq!(harness.state().ui.recent, vec!["/deckcraft-tests/172/a.deckcraft"]); + } + + /// The existence probe tells present files from missing ones and never touches remote paths. + #[test] + fn recent_exists_tells_missing_from_present() { + let mut app = SlideApp::new(Session::new(), Services::default()); + let mut path = std::env::temp_dir(); + path.push(format!("deckcraft-test-172-{}.deckcraft", std::process::id())); + std::fs::write(&path, b"").unwrap(); + assert!(app.recent_exists(&path.to_string_lossy()), "the file exists"); + assert!(!app.recent_exists("/deckcraft-tests/172/missing.deckcraft")); + assert!(app.recent_exists("\\\\server/share/deck.deckcraft"), "UNC paths are never probed"); + std::fs::remove_file(&path).ok(); + } } diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 94055d46..5a0a3456 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -22,13 +22,9 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim); p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, "English (United States)", theme::font(12.0), t.text_dim); // Transient status message. - if let Some((msg, at)) = app.status.clone() { - if crate::now_ms() - at < 6000.0 { - p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent); - ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); - } else { - app.status = None; - } + if let Some(msg) = app.fresh_status() { + p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); } let mut right = ui.new_child( egui::UiBuilder::new()