Merge pull request #143 from storytold/pr/scroll-long-menus

M14.1, M14.3, M14.7: scrolling menus, Home button, Cmd+N opens New Document
This commit is contained in:
BFlat authored and GitHub committed 2026-10-05 22:44:21 +07:00
commit 63bf2c4598
12 files changed
+153 -21

No files matched your search

+1 -1
View File
@@ -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** |
+1 -1
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
+5 -1
View File
@@ -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;
}
+6 -3
View File
@@ -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.
+2
View File
@@ -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;
+54 -5
View File
@@ -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:<id>, 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<R
crate::dialogs::open_new_document(app);
Ok(Value::Null)
}
"app.home" => {
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<u64>, 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);
}
}
+7 -5
View File
@@ -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();
}
}
}
});
});
}
}
+5 -3
View File
@@ -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
+4 -2
View File
@@ -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);
});
});
}
+6
View File
@@ -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<u64>, 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,
}
}
}
+49
View File
@@ -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");
}
+13
View File
@@ -426,6 +426,19 @@ fn combo<R>(
.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<R>(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 {