From 78a99ad1cfc662295c88b4a618a72f751da7a5b0 Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Tue, 13 Oct 2026 02:12:23 +0600 Subject: [PATCH] Start screen: a missing Recent file is marked, its error shows, and entries can be removed (#172) The start screen reported errors through set_status, but the status bar is only drawn while a presentation is open: clicking a Recent entry whose file had moved or been deleted did nothing visible, and the same went for every other start-screen error, like a failed sample open. The start screen now draws the transient status message itself: a real label, so screen readers see it too, wrapped so a long OS error does not clip at the panel edge. Recent entries whose file no longer exists are dimmed with a "(missing)" mark before anything is clicked, each entry has a remove button, and a successful open clears a stale error. Existence is probed once per path per session; a stat on a dead network share can block for the OS timeout, so UNC paths and URLs are never probed and are judged when opened instead. The remove button is a plain multiplication sign: U+2715 has no glyph in the UI font stack and egui would draw the replacement character. Tests: the missing mark and the drawn error (kittest, clicked through the real UI), removal from the list, and the negative case - an existing file is not marked. --- ROADMAP.md | 1 + crates/ui-egui/src/backstage.rs | 47 +++++++++++-- crates/ui-egui/src/lib.rs | 120 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/status.rs | 10 +-- 4 files changed, 167 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..aff356bb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | +| 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 f1d0fde3..6a1980aa 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -391,6 +391,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), @@ -558,6 +566,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)); @@ -565,10 +574,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.set_status(e); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..8975ab87 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -28,6 +28,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}; @@ -44,6 +45,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 { @@ -227,6 +231,9 @@ pub struct SlideApp { pub(crate) thumb_drag: Option<(usize, usize)>, /// Selection pane: folded groups, renaming, drag and drop. pub selpane: selpane::State, + /// 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 { @@ -267,6 +274,7 @@ impl SlideApp { ui_fonts: uifonts::UiFonts::default(), thumb_drag: None, selpane: selpane::State::default(), + recent_checked: HashMap::new(), } } @@ -279,6 +287,56 @@ impl SlideApp { self.status = Some((msg.into(), 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 { @@ -573,6 +631,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); @@ -1135,6 +1195,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(); @@ -1408,4 +1469,63 @@ mod tests { harness.run_steps(3); assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); } + + 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 8e915b71..026f6526 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()