From e8c4c60884797cc3eb68cd891dc55706e5e24561 Mon Sep 17 00:00:00 2001 From: alexanderalber <90604107+alexanderalber@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:19:38 +0200 Subject: [PATCH] Home: the title bar's Home button opens the start screen (#17) (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/dialogs.rs | 10 ++++ crates/ui-egui/src/lib.rs | 104 +++++++++++++++++++++++++++++++++- crates/ui-egui/src/ribbon.rs | 4 +- docs/control-protocol.md | 2 +- 5 files changed, 116 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 745dcffd..067610b2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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::>(), "status": app.status.as_ref().map(|s| s.0.clone()), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c651e791..73ce180b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 35d9caa0..bef64a4f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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)>, pending_urls: Vec, 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) { + (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::>().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"); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 897fa757..32e09e88 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2e287128..449b7fc6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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) |