diff --git a/ROADMAP.md b/ROADMAP.md index 7c39a1ba0..25a231335 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, 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/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e7fcc540a..103fe999a 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 { @@ -1860,10 +1861,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 +1882,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) => { @@ -2428,4 +2434,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 8c5b6423a..bab72d90b 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 f57e97b36..2affee132 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 521db16b4..055732d8e 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/widgets.rs b/crates/ui-egui/src/widgets.rs index 3faaa7e3e..4d8bcc125 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 {