Home: the title bar's Home button opens the start screen (#17) (#49)

It ran file.new, so each click silently added another blank presentation
and looked like it did nothing. Home is now a UI command (app.home) that
shows the start screen over the open presentations, with a "Back to …"
button; Escape or Home again goes back. Creating or opening a presentation
from it leaves it. While it's open, keys don't reach the hidden
presentation (only app shortcuts and Escape). ui.inspect reports `home`.

generated with Claude Code
This commit is contained in:
alexanderalber
2026-10-10 06:19:38 -04:00
committed by GitHub
parent a8c983ba6b
commit e8c4c60884
5 changed files with 116 additions and 5 deletions
+1
View File
@@ -73,6 +73,7 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value {
"slideRect": app.slide_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
"canvasScale": app.canvas_scale,
"dialog": app.dialog.as_ref().map(|d| d.id.clone()),
"home": app.home_open(),
"showing": app.show.as_ref().map(|s| json!({"slide": s.state.slide, "step": s.state.step, "ended": s.ended})),
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::<Vec<_>>(),
"status": app.status.as_ref().map(|s| s.0.clone()),
+10
View File
@@ -847,6 +847,16 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
paint_logo(ui.painter(), r);
ui.label(egui::RichText::new("DeckCraft").font(theme::bold(26.0)));
});
if app.home_open() {
let title = app.session.active().map(|d| d.title()).unwrap_or_default();
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.button(format!("← Back to {title}")).on_hover_text("Esc").clicked() {
let _ = app.run("app.home", json!({"on": false}));
}
});
}
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
+102 -2
View File
@@ -150,6 +150,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("show.start", "Slide Show", Some("F5"), "{from?: index}"),
("show.presenter", "Presenter View", None, "{on?: bool}"),
("show.end", "End Show", None, "{}"),
("app.home", "Home", None, "{on?}"),
("app.about", "About DeckCraft", None, "{}"),
("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"),
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
@@ -190,6 +191,9 @@ pub struct SlideApp {
pub palette: Option<(String, usize)>,
/// Integrated title bar (macOS traffic lights over our chrome).
pub integrated_titlebar: bool,
/// The start screen is shown over the open presentations (Home), with the document count
/// and active index when it was opened: opening or creating a presentation leaves it.
pub home: Option<(usize, Option<usize>)>,
pending_urls: Vec<String>,
shot_token: u64,
queued_shots: Vec<(u64, f64, u32)>,
@@ -227,6 +231,7 @@ impl SlideApp {
slide_rect: None,
palette: None,
integrated_titlebar: false,
home: None,
pending_urls: vec![],
shot_token: 0,
queued_shots: vec![],
@@ -266,6 +271,15 @@ impl SlideApp {
r
}
fn docs_key(&self) -> (usize, Option<usize>) {
(self.session.documents().len(), self.session.active_index())
}
/// Home is open over a presentation (not just the start screen with nothing open).
pub fn home_open(&self) -> bool {
self.home.is_some() && self.session.active().is_some()
}
fn flag(&mut self, params: &Value, cur: bool) -> bool {
params.get("on").and_then(Value::as_bool).unwrap_or(!cur)
}
@@ -325,6 +339,10 @@ impl SlideApp {
self.show = None;
self.media.stop_owned(media::Owner::Show);
}
"app.home" => {
let on = self.flag(p, self.home.is_some());
self.home = (on && self.session.active().is_some()).then(|| self.docs_key());
}
"app.about" => self.dialog = Some(dialogs::Dialog::new("about")),
"app.palette" => self.palette = Some((String::new(), 0)),
"app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")),
@@ -593,6 +611,22 @@ impl SlideApp {
return;
}
let events = ctx.input(|i| i.events.clone());
if self.home_open() {
// Only app shortcuts (Open, Command Palette…) and Escape, which goes back to the
// presentation: typing, pasting or deleting would edit the hidden presentation.
for e in events {
if let egui::Event::Key { key, pressed: true, modifiers, .. } = e {
let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl };
if key == egui::Key::Escape {
self.home = None;
} else if mods.cmd || mods.alt {
menus::ui_shortcut(self, key, mods);
}
}
}
self.drain_requests();
return;
}
for e in events {
match e {
egui::Event::Text(t) => {
@@ -661,14 +695,17 @@ impl SlideApp {
self.perf.frame_ms = now_ms() - t0;
return;
}
if self.home.is_some_and(|k| k != self.docs_key()) {
self.home = None;
}
self.keyboard(&ctx);
ribbon::title_bar(self, ui);
if self.session.active().is_some() {
if self.session.active().is_some() && !self.home_open() {
ribbon::show(self, ui);
status::status_bar(self, ui);
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
if self.session.active().is_none() {
if self.session.active().is_none() || self.home_open() {
dialogs::start_screen(self, ui);
return;
}
@@ -839,3 +876,66 @@ pub fn now_ms() -> f64 {
fn js_now() -> f64 {
js_sys::Date::now()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn title_text(app: &SlideApp) -> String {
let d = app.session.active().unwrap();
let sh = &d.doc.slides[0].shapes[0];
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
}
/// #17: Home created a new blank presentation instead of going to the start screen.
#[test]
fn home_shows_the_start_screen_over_the_open_presentation() {
let mut harness = 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::with_new(), Services::default()),
);
// The app keeps requesting repaints, so step frames instead of run().
harness.run_steps(2);
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
harness.state_mut().run("app.home", json!({})).unwrap();
harness.run_steps(2);
assert!(harness.state().home_open());
assert_eq!(harness.state().session.documents().len(), 1, "Home doesn't create a presentation");
// Typing on the start screen doesn't reach the presentation behind it; Escape goes back.
harness.event(egui::Event::Text("a".into()));
harness.key_press(egui::Key::Backspace);
harness.run_steps(2);
assert_eq!(title_text(harness.state()), "");
assert!(!harness.state().session.active().unwrap().is_dirty());
harness.key_press(egui::Key::Escape);
harness.run_steps(2);
assert!(!harness.state().home_open());
// Home again toggles; picking a theme on the start screen (a new presentation) leaves it.
harness.state_mut().run("app.home", json!({})).unwrap();
harness.run_steps(2);
assert!(harness.state().home_open());
harness.state_mut().run("app.home", json!({})).unwrap();
assert!(!harness.state().home_open());
harness.state_mut().run("app.home", json!({"on": true})).unwrap();
harness.state_mut().run("file.new", json!({})).unwrap();
harness.run_steps(2);
assert!(!harness.state().home_open(), "a new presentation leaves Home");
assert_eq!(harness.state().session.documents().len(), 2);
}
#[test]
fn home_needs_an_open_presentation() {
let mut app = SlideApp::new(Session::new(), Services::default());
app.run("app.home", json!({})).unwrap();
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
}
}
+2 -2
View File
@@ -47,8 +47,8 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) {
child.spacing_mut().item_spacing.x = 2.0;
let has_doc = app.session.active().is_some();
let dirty = app.session.active().is_some_and(|d| d.is_dirty());
if icon_toggle(&mut child, Icon::Home, "Home", true, false).clicked() {
let _ = app.run("file.new", json!({}));
if icon_toggle(&mut child, Icon::Home, "Home", true, app.home_open()).clicked() {
let _ = app.run("app.home", json!({}));
}
if icon_toggle(&mut child, Icon::Save, "Save (⌘S)", has_doc, false).clicked() {
app.save();
+1 -1
View File
@@ -15,7 +15,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) |
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason |
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, slide show state, documents, perf (fps, render ms) |
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, Home (start screen over the open presentations), slide show state, documents, perf (fps, render ms) |
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |