diff --git a/ROADMAP.md b/ROADMAP.md index 7c39a1ba..7efd344a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -150,7 +150,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux and content-sized dialogs and menus done; accessibility, Windows/Linux packaging pending | 18–28 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 | | | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~335–520** | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index c230b009..fa58a619 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "file.new", "New…", ["File"], - Some("Cmd+N"), + None, "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 86ea9d13..4b9dde39 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -127,7 +127,11 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let full = ui.available_rect_before_wrap(); - if app.session.active().is_none() { + // A document that opened, closed or became active since Home was chosen replaces it. + if app.ui.home.is_some_and(|k| k != crate::menus::home_key(app)) { + app.ui.home = None; + } + if app.session.active().is_none() || app.ui.home.is_some() { home(app, ui, full); return; } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 42fceecb..5126897f 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -27,8 +27,9 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); crate::brand::paint_mark(ui, r); ui.add_space(4.0); - if widgets::icon_button(ui, "house", "Home", false, 24.0).clicked() { - crate::dialogs::open_new_document(app); + let on_home = app.ui.home.is_some() || app.session.active().is_none(); + if widgets::icon_button(ui, "house", "Home", on_home, 24.0).clicked() { + app.run("app.home", json!({})).ok(); } ui.add_space(2.0); let menus_end = if app.native_menu { @@ -237,7 +238,8 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { for (i, d) in app.session.documents().iter().enumerate() { let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0); let title = tab_title(d, zoom, app.ui.view.outline); - let is_active = Some(i) == active; + // On the Home screen no tab is the current one. + let is_active = Some(i) == active && app.ui.home.is_none(); let galley = ui.painter().layout_no_wrap(title, theme::semibold(12.5), if is_active { t.text_strong } else { t.text_dim }); let w = galley.size().x + 50.0; let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(w, strip.height() - 1.0)); @@ -265,6 +267,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { app.status(e); } } else if let Some(i) = activate { + app.ui.home = None; app.session.set_active(i); } // Isolation mode breadcrumb bar. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1befbd13..70a26f24 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -46,6 +46,8 @@ mod tests_clipboard; #[cfg(test)] mod tests_docsetup; #[cfg(test)] +mod tests_home; +#[cfg(test)] mod tests_labels; #[cfg(test)] mod tests_nativeoptions; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e7fcc540..81341c9e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -13,6 +13,7 @@ use crate::VectorcraftApp; use crate::io; use crate::state::{DockTab, ICON_PANELS, next_zoom}; use crate::theme::{self, Brightness, Tokens}; +use crate::widgets; #[derive(Clone, Debug)] pub enum Item { @@ -110,6 +111,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), + ("app.home", "Home", "", "{} shows the Home screen (new file presets, Open) over the open documents; choosing a document tab returns to it"), ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), ("edit.keyboardShortcuts", "Keyboard Shortcuts…", "Cmd+Alt+Shift+K", "{}"), ("shortcuts.set", "Set Keyboard Shortcut", "", "{id: command id or tool:, shortcut: \"Cmd+Shift+K\" | \"\" (none) | null (default), force?}"), @@ -548,6 +550,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { + app.ui.home = Some(home_key(app)); + Ok(Value::Null) + } "file.open" => match s("path") { Some(path) => io::open_path(app, &path).map(|_| Value::Null), None => io::open_dialog(app).map(|_| Value::Null), @@ -1860,10 +1866,13 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { end } -/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt. +/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt; +/// it scrolls when it is taller than the window. fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) { - ui.set_min_width(230.0); - render_items(app, ui, items, clicked); + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(230.0); + render_items(app, ui, items, clicked); + }); } fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) { @@ -1878,8 +1887,10 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked } Item::Sub(label, children) => { ui.menu_button(*label, |ui| { - ui.set_min_width(200.0); - render_items(app, ui, children, clicked); + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(200.0); + render_items(app, ui, children, clicked); + }); }); } Item::Todo(label, sc) => { @@ -1913,6 +1924,12 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked } } +/// What the Home screen remembers when it opens (`app.home`): the active document and the +/// document count. When either changes, the Home screen gives way to the document. +pub(crate) fn home_key(app: &VectorcraftApp) -> (Option, usize) { + (app.session.active().map(|d| d.uid), app.session.documents().len()) +} + fn label_of(it: &Item) -> &'static str { match it { Item::Cmd(l, _, _) => l, @@ -2428,4 +2445,36 @@ mod tests { let w = ctx.memory(|m| m.area_rect(id)).unwrap().width(); assert!((230.0..340.0).contains(&w), "File menu is {w} pt wide"); } + + #[test] + fn long_menus_scroll_inside_a_short_window() { + // The Window menu is taller than a 600 pt window: it stops above the bottom edge and + // scrolls instead of running off screen. + let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let tree = menu_tree(); + let (_, window) = tree.iter().find(|(t, _)| *t == "Window").unwrap(); + let id = egui::Id::new("test-long-menu"); + let rect = |height: f32| { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, height)); + for _ in 0..3 { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + egui::Area::new(id).fixed_pos(egui::pos2(300.0, 30.0)).show(ui.ctx(), |ui| { + egui::containers::menu::menu_style(ui.style_mut()); + ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| menu_body(&app, ui, window, &mut None)); + }); + }); + out.textures_delta.clear(); + } + ctx.memory(|m| m.area_rect(id)).unwrap() + }; + let short = rect(600.0); + assert!(short.bottom() <= 600.0, "Window menu ends at {} in a 600 pt window", short.bottom()); + // With room to spare it shows whole, taller than the short window allowed. + let tall = rect(3000.0); + assert!(tall.height() > short.height() + 100.0, "{} vs {}", tall.height(), short.height()); + assert!(tall.bottom() < 3000.0); + } } diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 8c5b6423..bab72d90 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -870,12 +870,14 @@ pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { } for (g, items) in groups { ui.menu_button(g, |ui| { - for (id, label) in items { - if ui.button(format!("{label}…")).clicked() { - app.run("effect.dialog", json!({"effect": id})).ok(); - ui.close(); + crate::widgets::menu_scroll(ui, |ui| { + for (id, label) in items { + if ui.button(format!("{label}…")).clicked() { + app.run("effect.dialog", json!({"effect": id})).ok(); + ui.close(); + } } - } + }); }); } } diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index f57e97b3..2affee13 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -374,9 +374,11 @@ pub(crate) fn library_items(ui: &mut Ui, libs: &[LibraryRef], current: Option<&s } for s in subs { ui.menu_button(s, |ui| { - for l in libs.iter().filter(|l| l.submenu == Some(s)) { - item(ui, l); - } + crate::widgets::menu_scroll(ui, |ui| { + for l in libs.iter().filter(|l| l.submenu == Some(s)) { + item(ui, l); + } + }); }); } chosen diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 521db16b..055732d8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -158,8 +158,10 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) { icons::paint(ui, "menu", rect.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text_dim }); let resp = resp.on_hover_text("Panel menu"); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(220.0); - panel_menu_items(app, ui, id); + crate::widgets::menu_scroll(ui, |ui| { + ui.set_min_width(220.0); + panel_menu_items(app, ui, id); + }); }); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 7a7d1303..4c7c3153 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -298,6 +298,11 @@ pub struct UiState { /// never used). #[serde(default)] pub dxf_import: Value, + /// The Home screen is shown over the open documents (`app.home`): the active document's uid + /// and the document count when it opened. Choosing a tab, or a document opening, closing or + /// becoming active, leaves it. + #[serde(skip)] + pub home: Option<(Option, usize)>, } impl UiState { @@ -359,6 +364,7 @@ impl Default for UiState { dxf_options: Value::Null, eps_options: Value::Null, dxf_import: Value::Null, + home: None, } } } diff --git a/crates/ui-egui/src/tests_home.rs b/crates/ui-egui/src/tests_home.rs new file mode 100644 index 00000000..0fd8f0ca --- /dev/null +++ b/crates/ui-egui/src/tests_home.rs @@ -0,0 +1,49 @@ +//! The Home icon shows the Home screen over open documents; Cmd+N opens the New Document dialog. + +use serde_json::json; + +use crate::{VectorcraftApp, canvas}; + +fn app_with_doc() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app +} + +/// Draw one canvas frame; whether it drew the document (else the Home screen). +fn shows_document(app: &mut VectorcraftApp) -> bool { + app.canvas_rect = None; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + app.canvas_rect.is_some() +} + +#[test] +fn home_shows_over_open_documents_until_a_document_is_chosen() { + let mut app = app_with_doc(); + assert!(shows_document(&mut app)); + app.run("app.home", json!({})).unwrap(); + assert!(!shows_document(&mut app), "Home replaces the canvas"); + assert_eq!(app.session.documents().len(), 1, "the document stays open"); + assert!(app.ui.dialog.is_none(), "Home is not the New Document dialog"); + // Choosing the document's tab returns to it. + app.ui.home = None; + assert!(shows_document(&mut app)); + // A new document (from Home's presets, New… or Open) replaces Home too. + app.run("app.home", json!({})).unwrap(); + app.run("file.new", json!({})).unwrap(); + assert!(shows_document(&mut app)); + assert!(app.ui.home.is_none()); +} + +#[test] +fn cmd_n_opens_the_new_document_dialog() { + assert_eq!(crate::shortcut_editor::command_for_key("Cmd+N"), Some("file.newDialog")); + let mut app = app_with_doc(); + app.run("file.newDialog", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("newDocument")); + assert_eq!(app.session.documents().len(), 1, "no document is made until the dialog's OK"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3faaa7e3..4d8bcc12 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -426,6 +426,19 @@ fn combo( .inner } +/// The body of a menu or popup list: as tall as its items up to the bottom of the window, and +/// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on +/// small windows. +pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + // Room left below the popup's first item, less the popup frame and a small gap. + const BOTTOM_GAP: f32 = 12.0; + const MIN_HEIGHT: f32 = 120.0; + let room = (ui.ctx().content_rect().bottom() - ui.next_widget_position().y - BOTTOM_GAP).max(MIN_HEIGHT); + // The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to + // its items when they need less. + egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).show(ui, add_contents).inner +} + /// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a /// [`BlendMode::group`] starts. pub fn blend_separator_before(i: usize) -> bool {