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