Merge PR #291 from albayazid: Start screen: a missing Recent file is marked, its error shows, and entries can be removed (#172)

# Conflicts:
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:27:28 -04:00
4 changed files with 167 additions and 11 deletions
+1
View File
@@ -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 |
+43 -4
View File
@@ -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);
+120
View File
@@ -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<dyn FnMut(&str, &[u8])>;
/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files).
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
/// 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<String, bool>,
}
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<String> {
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<Value, String> {
@@ -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::<Vec<_>>();
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();
}
}
+3 -7
View File
@@ -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()