mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
12 files changed
+153
-21
No files matched your search
+1
-1
@@ -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** |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user