From ab65474044ff9f1caf04e74f3550c642c97298c0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 20:24:41 +0700 Subject: [PATCH] macOS: the menus live in the system menu bar, settings in the VectorCraft menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS the menus are in the system menu bar, laid out as a Mac app's (Illustrator on the Mac as the reference), and the app settings sit in the VectorCraft menu: About, Settings ▸ (one item per Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own quit, so unsaved documents are asked about); they leave Edit and Help. Window gets Minimize ⌃⌘M, Zoom and Bring All to Front; Help is the system's Help menu. Windows, Linux and the web keep the in-window menu bar unchanged; --in-window-menus or VECTORCRAFT_IN_WINDOW_MENUS keeps it on macOS too, and so does a menu bar AppKit refuses to build. One menu model: the in-window menus and the native bar draw the same rows (menus::entry), and the platform-free native_menu module lays them out, decides when to update (a cheap state hash; labels, enabled and checked in place, a rebuild only when the structure changes) and hands key equivalents back to egui as the key presses they were, so shortcuts, user overrides and focused text fields behave as in the window and nothing fires twice. The muda backend (pinned =0.21.1, macOS only, no unsafe) is apps/vectorcraft/src/mac_menu.rs, mirroring PhotoCraft's. ui.inspect reports nativeMenuBar. Translations for the new labels in ja, es, zh-hans, zh-hant, cs and pt-br. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 7 +- ROADMAP.md | 5 +- apps/vectorcraft/Cargo.toml | 6 +- apps/vectorcraft/src/mac_menu.rs | 367 ++++++++++++ apps/vectorcraft/src/main.rs | 39 +- apps/vectorcraft/src/native_menu.rs | 183 ------ crates/ui-egui/src/chrome.rs | 3 +- crates/ui-egui/src/control.rs | 2 + .../src/dialogs/perspective_presets.rs | 5 - .../ui-egui/src/dialogs/tests_perspective.rs | 6 +- crates/ui-egui/src/i18n/cs.tsv | 21 + crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/it.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/tests.rs | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + crates/ui-egui/src/lib.rs | 25 +- crates/ui-egui/src/menus.rs | 250 +++++---- crates/ui-egui/src/native_menu.rs | 528 ++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 28 +- crates/ui-egui/src/tests_nativemenu.rs | 422 ++++++++++++++ crates/ui-egui/src/tests_save.rs | 2 +- docs/control-protocol.md | 2 +- docs/development.md | 10 + 26 files changed, 1608 insertions(+), 349 deletions(-) create mode 100644 apps/vectorcraft/src/mac_menu.rs delete mode 100644 apps/vectorcraft/src/native_menu.rs create mode 100644 crates/ui-egui/src/native_menu.rs create mode 100644 crates/ui-egui/src/tests_nativemenu.rs diff --git a/Cargo.lock b/Cargo.lock index 427c3dcc..f7a94e40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2283,9 +2283,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e" dependencies = [ "crossbeam-channel", "dpi", @@ -2294,7 +2294,6 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "once_cell", "png 0.18.1", "thiserror 2.0.21", "windows-sys 0.61.2", @@ -2777,7 +2776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 1e477316..5cc39bf4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -42,7 +42,7 @@ since 2026-10-01. | **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | -| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) | +| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) | | **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel | | **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) | @@ -103,6 +103,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish and Italian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. + - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index b48e5c2b..0ec3881e 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -37,9 +37,11 @@ winit = { version = "0.30", default-features = false } clipboard-win = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] -muda = { version = "0.21", default-features = false } +# The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK +# with default features off). Pinned exactly: review the diff before bumping. +muda = { version = "=0.21.1", default-features = false } objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSPasteboard", "NSResponder", "NSTextInputContext"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext"] } # Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd. objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] } block2 = "0.6" diff --git a/apps/vectorcraft/src/mac_menu.rs b/apps/vectorcraft/src/mac_menu.rs new file mode 100644 index 00000000..0c8325dc --- /dev/null +++ b/apps/vectorcraft/src/mac_menu.rs @@ -0,0 +1,367 @@ +//! The macOS menu bar, through muda (Tauri's menu crate: AppKit menus behind a safe API, no GTK +//! with default features off). The menus, their Mac layout and how their keys reach VectorCraft +//! are in `vectorcraft_ui_egui::native_menu`; this file only builds and updates the `NSMenu`s. +//! +//! - Item ids are the items' places (`vc12`, depth first): one command can run from several items +//! (each language of VectorCraft › Language is `app.language` with its own code). +//! - A structure change (a recent file added, the language or a shortcut changed) rebuilds the +//! menu; labels, enabled and checked are updated in place. +//! - A chosen item is a click or a key equivalent: AppKit's current event says which. A key +//! equivalent goes back to egui as the key press that was made (the event's modifiers, not the +//! item's: AppKit may match ⌘R to a ⇧⌘R item), so VectorCraft's key rules still apply. +//! - Hide and Hide Others are items of ours calling `NSApplication`: AppKit's own always take ⌘H +//! (View › Hide Edges) and ⌥⌘H. + +use std::sync::mpsc::{Receiver, channel}; + +use egui::{Key, KeyboardShortcut}; +use muda::accelerator::{Accelerator, Code, Modifiers}; +use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use objc2::MainThreadMarker; +use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType}; +use vectorcraft_ui_egui::VectorcraftApp; +use vectorcraft_ui_egui::native_menu::{self, Backend, Event, MenuBar, MenuRole, Node, Standard}; + +enum Handle { + Plain(MenuItem), + Check(CheckMenuItem), +} + +/// One native item, in the order of [`MenuBar::items`]. +struct Entry { + handle: Handle, + item: native_menu::Item, + /// Its shortcut, when it is a key equivalent. + chord: Option, +} + +/// How an item was chosen, read from AppKit's current event while it calls the handler. +struct Chosen { + id: String, + /// A key press (not a mouse click), and the modifiers held: ⌘, ⌃, ⌥, ⇧. + key: bool, + mods: [bool; 4], +} + +pub struct MacMenu { + menu: Option, + entries: Vec, + rx: Receiver, + structure: u64, +} + +impl MacMenu { + fn new(ctx: &egui::Context) -> MacMenu { + let (tx, rx) = channel(); + let repaint = ctx.clone(); + MenuEvent::set_event_handler(Some(move |e: MenuEvent| { + // AppKit calls this on the main thread, from the event that chose the item: a key + // equivalent is a key-down, a click a mouse-up (or ↩ inside the open menu). + let event = MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent()); + let key = event.as_ref().is_some_and(|ev| ev.r#type() == NSEventType::KeyDown); + let flags = event.as_ref().map_or(NSEventModifierFlags::empty(), |ev| ev.modifierFlags()); + let mods = [NSEventModifierFlags::Command, NSEventModifierFlags::Control, NSEventModifierFlags::Option, NSEventModifierFlags::Shift] + .map(|m| flags.contains(m)); + // The receiver only goes away with the app. + let _ = tx.send(Chosen { id: e.id.0, key, mods }); + // Wake egui, so the item runs now rather than on the next mouse move. + repaint.request_repaint(); + })); + MacMenu { menu: None, entries: Vec::new(), rx, structure: 0 } + } + + /// Build the menus of `bar` and make them the app's, in place of the ones before. + fn build(&mut self, bar: &MenuBar) -> muda::Result<()> { + let mut entries = Vec::new(); + let menu = Menu::new(); + let mut special = Vec::new(); + for m in &bar.menus { + let sub = Submenu::new(escape(&m.title), true); + append(&sub, &m.children, &mut entries)?; + menu.append(&sub)?; + if matches!(m.role, MenuRole::Window | MenuRole::Help) { + special.push((m.role, sub)); + } + } + if let Some(old) = self.menu.take() { + old.remove_for_nsapp(); + } + menu.init_for_nsapp(); + // Only once the menu is the app's: muda looks the submenus up in the installed main menu. + for (role, sub) in special { + match role { + MenuRole::Window => sub.set_as_windows_menu_for_nsapp(), + _ => sub.set_as_help_menu_for_nsapp(), + } + } + self.menu = Some(menu); + self.entries = entries; + self.structure = native_menu::structure_key(bar); + Ok(()) + } +} + +fn append(sub: &Submenu, nodes: &[Node], entries: &mut Vec) -> muda::Result<()> { + for n in nodes { + match n { + Node::Separator => sub.append(&PredefinedMenuItem::separator())?, + Node::Header(label) => sub.append(&MenuItem::new(escape(label), false, None))?, + Node::Submenu { label, children } => { + let s = Submenu::new(escape(label), !children.is_empty()); + append(&s, children, entries)?; + sub.append(&s)?; + } + Node::Standard(item) => { + let text = Some(vectorcraft_ui_egui::i18n::t(item.label())); + let p = match item { + Standard::Services => PredefinedMenuItem::services(text), + Standard::ShowAll => PredefinedMenuItem::show_all(text), + Standard::Zoom => PredefinedMenuItem::maximize(text), + Standard::BringAllToFront => PredefinedMenuItem::bring_all_to_front(text), + }; + sub.append(&p)?; + } + Node::Item(it) => { + let id = format!("vc{}", entries.len()); + let chord = it.shortcut.and_then(vectorcraft_ui_egui::shortcuts::parse).filter(native_menu::native_ok); + let accel = chord.as_ref().and_then(accelerator); + let handle = match it.checked { + Some(c) => { + let h = CheckMenuItem::with_id(id, escape(&it.label), it.enabled, c, accel); + sub.append(&h)?; + Handle::Check(h) + } + None => { + let h = MenuItem::with_id(id, escape(&it.label), it.enabled, accel); + sub.append(&h)?; + Handle::Plain(h) + } + }; + entries.push(Entry { handle, item: it.clone(), chord }); + } + } + } + Ok(()) +} + +impl Backend for MacMenu { + fn sync(&mut self, bar: &MenuBar) { + let items = bar.items(); + if self.menu.is_none() || native_menu::structure_key(bar) != self.structure || items.len() != self.entries.len() { + if let Err(e) = self.build(bar) { + // The menus as they were stay up (a failed build changed nothing). + log::warn!("couldn't rebuild the macOS menu bar: {e}"); + } + return; + } + for (e, it) in self.entries.iter_mut().zip(items) { + if e.item.label != it.label { + let text = escape(&it.label); + match &e.handle { + Handle::Plain(h) => h.set_text(text), + Handle::Check(h) => h.set_text(text), + } + } + if e.item.enabled != it.enabled { + match &e.handle { + Handle::Plain(h) => h.set_enabled(it.enabled), + Handle::Check(h) => h.set_enabled(it.enabled), + } + } + // Compare with AppKit's own state: muda toggles a check item when it's clicked. + if let (Handle::Check(h), Some(c)) = (&e.handle, it.checked) + && h.is_checked() != c + { + h.set_checked(c); + } + if e.item != *it { + e.item = it.clone(); + } + } + } + + fn drain(&mut self) -> Vec { + let mut out = Vec::new(); + while let Ok(chosen) = self.rx.try_recv() { + // Ids that aren't ours are AppKit's own items (Services, Zoom…), already handled. + let Some(e) = chosen.id.strip_prefix("vc").and_then(|n| n.parse::().ok()).and_then(|i| self.entries.get(i)) else { continue }; + if let Some(command @ (native_menu::HIDE | native_menu::HIDE_OTHERS)) = e.item.command { + if let Some(mtm) = MainThreadMarker::new() { + let app = NSApplication::sharedApplication(mtm); + if command == native_menu::HIDE { app.hide(None) } else { app.hideOtherApplications(None) } + } + continue; + } + // A key-down chose it: its key equivalent, unless it was ↩ or Space inside the open + // menu (no ⌘ or ⌃ while the item's shortcut has one). The key is the item's (AppKit + // matched on it); the modifiers are the ones held. + let [cmd, ctrl, alt, shift] = chosen.mods; + let key = e + .chord + .filter(|c| { + chosen.key && e.item.command != Some(native_menu::MINIMIZE) && (cmd || ctrl || !(c.modifiers.command || c.modifiers.ctrl)) + }) + .map(|c| KeyboardShortcut::new(egui::Modifiers { alt, ctrl, shift, mac_cmd: cmd, command: cmd }, c.logical_key)); + out.push(match key { + Some(k) => Event::Key(k), + None => Event::Click(e.item.clone()), + }); + } + out + } +} + +/// Install the menu bar (in eframe's creator closure, so winit's default menu doesn't stay up). +/// `None` keeps the menus in the window: AppKit refused them (logged), never a crash. +pub fn install(ctx: &egui::Context, app: &VectorcraftApp) -> Option { + vectorcraft_ui_egui::i18n::set_current(app.ui_language()); + let bar = native_menu::layout(app).bar; + let built = vectorcraft_engine::guard::catch_panic(|| { + let mut menu = MacMenu::new(ctx); + menu.build(&bar).map(|()| menu) + }); + match built { + Ok(Ok(menu)) => Some(native_menu::NativeMenu::new(Box::new(menu))), + Ok(Err(e)) => { + log::warn!("couldn't install the macOS menu bar ({e}); the menus stay in the window"); + None + } + Err(e) => { + log::warn!("couldn't install the macOS menu bar ({e}); the menus stay in the window"); + None + } + } +} + +/// `&` marks a mnemonic in muda labels: "Fill & Stroke" keeps its ampersand. +fn escape(s: &str) -> String { + s.replace('&', "&&") +} + +/// A key equivalent for AppKit. `Cmd` is ⌘ (`META`) and `Ctrl` is ⌃: never folded together. +fn accelerator(c: &KeyboardShortcut) -> Option { + let m = c.modifiers; + let mut mods = Modifiers::empty(); + for (on, flag) in [(m.command || m.mac_cmd, Modifiers::META), (m.ctrl, Modifiers::CONTROL), (m.alt, Modifiers::ALT), (m.shift, Modifiers::SHIFT)] + { + if on { + mods |= flag; + } + } + Some(Accelerator::new(mods, code(c.logical_key)?)) +} + +/// The key code AppKit matches for an egui key. +fn code(key: Key) -> Option { + Some(match key { + Key::A => Code::KeyA, + Key::B => Code::KeyB, + Key::C => Code::KeyC, + Key::D => Code::KeyD, + Key::E => Code::KeyE, + Key::F => Code::KeyF, + Key::G => Code::KeyG, + Key::H => Code::KeyH, + Key::I => Code::KeyI, + Key::J => Code::KeyJ, + Key::K => Code::KeyK, + Key::L => Code::KeyL, + Key::M => Code::KeyM, + Key::N => Code::KeyN, + Key::O => Code::KeyO, + Key::P => Code::KeyP, + Key::Q => Code::KeyQ, + Key::R => Code::KeyR, + Key::S => Code::KeyS, + Key::T => Code::KeyT, + Key::U => Code::KeyU, + Key::V => Code::KeyV, + Key::W => Code::KeyW, + Key::X => Code::KeyX, + Key::Y => Code::KeyY, + Key::Z => Code::KeyZ, + Key::Num0 => Code::Digit0, + Key::Num1 => Code::Digit1, + Key::Num2 => Code::Digit2, + Key::Num3 => Code::Digit3, + Key::Num4 => Code::Digit4, + Key::Num5 => Code::Digit5, + Key::Num6 => Code::Digit6, + Key::Num7 => Code::Digit7, + Key::Num8 => Code::Digit8, + Key::Num9 => Code::Digit9, + Key::F1 => Code::F1, + Key::F2 => Code::F2, + Key::F3 => Code::F3, + Key::F4 => Code::F4, + Key::F5 => Code::F5, + Key::F6 => Code::F6, + Key::F7 => Code::F7, + Key::F8 => Code::F8, + Key::F9 => Code::F9, + Key::F10 => Code::F10, + Key::F11 => Code::F11, + Key::F12 => Code::F12, + Key::F13 => Code::F13, + Key::F14 => Code::F14, + Key::F15 => Code::F15, + Key::F16 => Code::F16, + Key::F17 => Code::F17, + Key::F18 => Code::F18, + Key::F19 => Code::F19, + Key::F20 => Code::F20, + Key::F21 => Code::F21, + Key::F22 => Code::F22, + Key::F23 => Code::F23, + Key::F24 => Code::F24, + Key::OpenBracket => Code::BracketLeft, + Key::CloseBracket => Code::BracketRight, + Key::Backslash => Code::Backslash, + Key::Slash => Code::Slash, + // `+` is Shift+`=` on the keyboards AppKit matches by key: Zoom In shows ⌘=. + Key::Equals | Key::Plus => Code::Equal, + Key::Minus => Code::Minus, + Key::Quote => Code::Quote, + Key::Semicolon => Code::Semicolon, + Key::Comma => Code::Comma, + Key::Period => Code::Period, + Key::Backtick => Code::Backquote, + Key::Backspace => Code::Backspace, + Key::Delete => Code::Delete, + Key::Enter => Code::Enter, + Key::Tab => Code::Tab, + Key::Space => Code::Space, + Key::Escape => Code::Escape, + Key::ArrowLeft => Code::ArrowLeft, + Key::ArrowRight => Code::ArrowRight, + Key::ArrowUp => Code::ArrowUp, + Key::ArrowDown => Code::ArrowDown, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every shortcut the Mac menu shows as a key equivalent has one AppKit can match. + #[test] + fn every_menu_shortcut_maps_to_a_key_equivalent() { + let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let bar = native_menu::layout(&app).bar; + let mut native = 0; + for it in bar.items() { + let Some(c) = it.shortcut.and_then(vectorcraft_ui_egui::shortcuts::parse).filter(native_menu::native_ok) else { continue }; + assert!(accelerator(&c).is_some(), "{:?} ({:?}) has no key equivalent", it.shortcut, it.command); + native += 1; + } + assert!(native > 70, "{native} key equivalents"); + } + + #[test] + fn control_and_command_stay_distinct() { + let parse = |s| vectorcraft_ui_egui::shortcuts::parse(s).and_then(|c| accelerator(&c)); + assert_ne!(parse("Cmd+M"), parse("Ctrl+Cmd+M")); + assert!(parse("Cmd+M").is_some()); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 64fc2049..8a5537a7 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -1,6 +1,9 @@ //! VectorCraft desktop app. //! -//! Usage: `vectorcraft [--control ] [files…]` +//! Usage: `vectorcraft [--control ] [--in-window-menus] [files…]` +//! +//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on +//! macOS instead of the macOS menu bar (`mac_menu`). //! //! `--control ` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. @@ -20,7 +23,7 @@ mod control_server; mod gpu; mod logging; #[cfg(target_os = "macos")] -mod native_menu; +mod mac_menu; #[cfg(target_os = "macos")] mod open_documents; mod printing; @@ -37,8 +40,6 @@ struct App { graphics_loss: GraphicsLoss, /// The graphics device was lost and the unsaved changes are kept for Data Recovery. graphics_lost: bool, - #[cfg(target_os = "macos")] - menu: Option, } impl eframe::App for App { @@ -59,15 +60,7 @@ impl eframe::App for App { return; } #[cfg(target_os = "macos")] - { - if self.menu.is_none() && std::env::var_os("VECTORCRAFT_NO_NATIVE_MENU").is_none() { - self.menu = Some(native_menu::NativeMenu::install(&mut self.app)); - } - if let Some(m) = &mut self.menu { - m.poll(&mut self.app, ctx); - } - open_files(&mut self.app, open_documents::take()); - } + open_files(&mut self.app, open_documents::take()); self.app.logic(ctx); window::track(ctx, &mut self.app.ui.window); if self.app.ui.status == "quit" { @@ -319,10 +312,12 @@ fn main() -> std::process::ExitCode { vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); let mut control_port: Option = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); + let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--in-window-menus" => in_window_menus = true, "--version" => { println!("vectorcraft {}", env!("CARGO_PKG_VERSION")); return std::process::ExitCode::SUCCESS; @@ -434,15 +429,17 @@ fn main() -> std::process::ExitCode { app = app.with_control(rx); } #[cfg(target_os = "macos")] - open_documents::set_ui(&cc.egui_ctx); + { + open_documents::set_ui(&cc.egui_ctx); + // The macOS menu bar, installed now so winit's default menu doesn't stay up. + if !in_window_menus { + app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app); + } + } + #[cfg(not(target_os = "macos"))] + let _ = in_window_menus; open_files(&mut app, files); - Ok(Box::new(App { - app, - graphics_loss, - graphics_lost: false, - #[cfg(target_os = "macos")] - menu: None, - })) + Ok(Box::new(App { app, graphics_loss, graphics_lost: false })) }), ) }); diff --git a/apps/vectorcraft/src/native_menu.rs b/apps/vectorcraft/src/native_menu.rs deleted file mode 100644 index ba8ca16c..00000000 --- a/apps/vectorcraft/src/native_menu.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! macOS: VectorCraft's menu tree as the native system menu bar (like Illustrator on the Mac). -//! Items dispatch through the same command path as the in-window menus; enablement, check marks -//! and dynamic labels ("Undo Move") are refreshed a few times per second, and the menu is rebuilt -//! when saved views or recent files come and go. - -use std::collections::HashMap; -use std::str::FromStr; - -use muda::accelerator::Accelerator; -use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; -use serde_json::Value; -use vectorcraft_ui_egui::VectorcraftApp; -use vectorcraft_ui_egui::i18n::{self, Lang, t}; -use vectorcraft_ui_egui::menus::{self, Item}; - -enum Handle { - Plain(MenuItem), - Check(CheckMenuItem), -} - -pub struct NativeMenu { - _menu: Menu, - items: HashMap, - last_refresh: f64, - /// Shortcut-override / workspace-list generation the menu was built for. - generation: u64, - /// Saved views and recent files listed (empty slots are left out, so a change rebuilds). - slots: usize, - /// Plug-in registry revision the Plug-ins submenus were built for. - plugins: u64, - /// The language the labels were built in (a change rebuilds the menu). - language: Lang, -} - -/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they -/// don't steal keys from text fields; a focused field still gets Cmd+A/X/C/V, see [`NativeMenu::poll`]). -fn accel(sc: &str) -> Option { - if !(sc.contains("Cmd") || sc.contains("Ctrl") || sc.contains("Alt") || sc.starts_with('F')) { - return None; - } - Accelerator::from_str(&sc.replace("Cmd", "CMD").replace("Alt", "ALT").replace("Shift", "SHIFT").replace("Ctrl", "CTRL")).ok() -} - -impl NativeMenu { - pub fn install(app: &mut VectorcraftApp) -> Self { - // Accelerators come from `shortcut_of`, which honours the user's overrides. - vectorcraft_ui_egui::shortcut_editor::sync(&app.ui); - let generation = vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed); - let menu = Menu::new(); - let mut items = HashMap::new(); - let mut counter = 0usize; - for (title, entries) in menus::menu_tree() { - let sub = Submenu::new(t(title), true); - build(app, &sub, &entries, &mut items, &mut counter); - let _ = menu.append(&sub); - } - menu.init_for_nsapp(); - app.native_menu = true; - app.native_shortcuts = items - .values() - .filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some()) - .map(|(_, _, _, c)| c.clone()) - .collect(); - Self { - _menu: menu, - items, - last_refresh: 0.0, - generation, - slots: menus::listed_slots(app), - plugins: menus::plugin_revision(), - language: i18n::current(), - } - } - - /// Dispatch chosen items (a focused text field takes Select All and the clipboard) and refresh - /// state. - pub fn poll(&mut self, app: &mut VectorcraftApp, ctx: &egui::Context) { - while let Ok(ev) = MenuEvent::receiver().try_recv() { - if let Some((cmd, params, _, _)) = self.items.get(ev.id.as_ref()) { - let p = if params.is_null() { serde_json::json!({}) } else { params.clone() }; - menus::invoke_from_system_menu(app, ctx, cmd, p); - } - } - let now = vectorcraft_ui_egui::now_ms(); - if now - self.last_refresh < 250.0 { - return; - } - self.last_refresh = now; - // Shortcuts edited, workspaces added, saved views, recent files, plug-ins or the UI language - // changed: rebuild so accelerators, lists and labels are current. - if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation - || menus::listed_slots(app) != self.slots - || menus::plugin_revision() != self.plugins - || i18n::current() != self.language - { - *self = NativeMenu::install(app); - return; - } - for (run, rp, h, cmd) in self.items.values() { - let null = Value::Null; - let params = if run == cmd { rp } else { &null }; - let en = menus::enabled(app, cmd); - match h { - Handle::Plain(i) => { - i.set_enabled(en); - if matches!( - cmd.as_str(), - "edit.undo" - | "edit.redo" - | "view.outline" - | "view.rulers" - | "view.guides" - | "view.grid" - | "view.edges" - | "view.artboards" - | "view.boundingBox" - | "view.transparencyGrid" - | "window.workspace.reset" - ) || cmd.starts_with("file.openRecent") - || cmd.starts_with("type.recentFont") - || cmd.starts_with("view.goto") - || matches!( - cmd.as_str(), - "view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters" - ) - { - i.set_text(menus::display_label(app, cmd, "")); - } - } - Handle::Check(c) => { - c.set_enabled(en); - c.set_checked(menus::checked(app, cmd, params).unwrap_or(false)); - } - } - } - } -} - -fn build( - app: &VectorcraftApp, - parent: &Submenu, - entries: &[Item], - items: &mut HashMap, - counter: &mut usize, -) { - for e in entries { - match e { - Item::Sep => { - let _ = parent.append(&PredefinedMenuItem::separator()); - } - Item::Header(h) => { - let _ = parent.append(&MenuItem::new(t(h), false, None)); - } - Item::Todo(label, sc) => { - let _ = parent.append(&MenuItem::new(t(label), false, accel(sc))); - } - Item::Sub(label, children) => { - let sub = Submenu::new(t(label), true); - build(app, &sub, children, items, counter); - let _ = parent.append(&sub); - } - // Unused saved-view and recent-file slots are left out, as in the in-window menus. - Item::Cmd(_, cmd, _) if menus::hidden_slot(app, cmd) => {} - Item::Cmd(label, cmd, params) => { - *counter += 1; - let id = format!("dc{counter}"); - let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None }; - let handle = if menus::checked(app, cmd, params).is_some() { - let c = CheckMenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, false, sc); - let _ = parent.append(&c); - Handle::Check(c) - } else { - let i = MenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, sc); - let _ = parent.append(&i); - Handle::Plain(i) - }; - let (run, run_params) = menus::click_target(label, cmd, params); - let _ = &run_params; - items.insert(id, (run, run_params, handle, cmd.to_string())); - } - } - } -} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 14a96d82..3418ba32 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -34,7 +34,8 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("app.home", json!({})).ok(); } ui.add_space(2.0); - let menus_end = if app.native_menu { + // With the macOS menu bar the menus are at the top of the screen instead. + let menus_end = if app.services.native_menu.is_some() { let full = ui.max_rect(); ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "VectorCraft", egui::FontId::proportional(13.5), t.text); ui.cursor().min.x diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 534da568..43977320 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -91,6 +91,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "activeDocument": app.session.active_index(), "perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps}, "graphicsAdapter": app.graphics_adapter, + // The menus are in the macOS menu bar rather than the window. + "nativeMenuBar": app.services.native_menu.is_some(), // Saves and exports still being written in the background (Background Save / Export). "background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::>(), }) diff --git a/crates/ui-egui/src/dialogs/perspective_presets.rs b/crates/ui-egui/src/dialogs/perspective_presets.rs index c720a036..be4d2109 100644 --- a/crates/ui-egui/src/dialogs/perspective_presets.rs +++ b/crates/ui-egui/src/dialogs/perspective_presets.rs @@ -209,11 +209,6 @@ pub fn slot_preset(app: &VectorcraftApp, id: &str) -> Option { app.session.prefs.perspective_presets.iter().filter(|p| p.kind == kind).nth(n.checked_sub(1)?).map(|p| p.name.clone()) } -/// How many saved presets the slots list (the native menu is rebuilt when this changes). -pub fn listed_slots(app: &VectorcraftApp) -> usize { - (1..=3u8).map(|k| app.session.prefs.perspective_presets.iter().filter(|p| p.kind == k).count().min(SLOTS)).sum() -} - /// View → Perspective Grid → One/Two/Three Point Perspective (`kind` 1–3): the built-in views of /// the type, then the saved presets' slots (`slots`, the type's ids). pub fn menu(kind: u8, slots: [&'static str; SLOTS]) -> Vec { diff --git a/crates/ui-egui/src/dialogs/tests_perspective.rs b/crates/ui-egui/src/dialogs/tests_perspective.rs index ee933229..c88f7961 100644 --- a/crates/ui-egui/src/dialogs/tests_perspective.rs +++ b/crates/ui-egui/src/dialogs/tests_perspective.rs @@ -141,7 +141,11 @@ fn the_presets_manager_creates_edits_and_deletes_presets() { assert_eq!(app.session.prefs.perspective_presets[0].opacity, 90.0); // Its type's menu lists it in a slot, which applies it. assert_eq!(crate::menus::dynamic_label(&app, "ui.perspectiveUserPreset3.1", ""), "Gorge"); - assert!(crate::menus::hidden_slot(&app, "ui.perspectiveUserPreset3.2") && crate::menus::hidden_slot(&app, "ui.perspectiveUserPreset2.1")); + assert!( + ["ui.perspectiveUserPreset3.2", "ui.perspectiveUserPreset2.1"] + .iter() + .all(|id| crate::menus::shown_state(&app, id, &serde_json::Value::Null).is_none()) + ); app.run("ui.perspectiveUserPreset3.1", json!({})).unwrap(); assert_eq!(grid(&app).name, "Gorge"); // Delete selects the one above it. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b8b572f8..c5004e61 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -614,3 +614,24 @@ Float Panel Plovoucí panel Dock Panel Ukotvit panel Float Tool Group Plovoucí skupina nástrojů + +# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. + Services Služby + Hide VectorCraft Skrýt VectorCraft + Hide Others Skrýt ostatní + Minimize Minimalizovat + Zoom Zvětšit + Bring All to Front Převést vše do popředí + Settings Nastavení + General Obecné + Selection & Anchor Display Výběr a zobrazení kotevních bodů + Units Jednotky + Guides & Grid Vodítka a mřížka + Hyphenation Dělení slov + Performance & Storage Výkon a úložiště + User Interface Uživatelské rozhraní + Performance Výkon + File Handling Práce se soubory + Clipboard Handling Práce se schránkou + Appearance of Black Vzhled černé + Devices Zařízení diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6de71837..cce9d938 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3582,3 +3582,10 @@ axis Both Ambos @msg {tool} isn't in a toolbar group of several tools {tool} no está en un grupo de la barra de herramientas con varias herramientas @msg onto must name a panel floating in another group onto debe nombrar un panel que flote en otro grupo @msg x and y must both be numbers x e y deben ser números + +# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. + Services Servicios + Hide VectorCraft Ocultar VectorCraft + Zoom Zoom + Bring All to Front Traer todo al frente + Settings Ajustes diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 65ab696f..707cc338 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3582,3 +3582,10 @@ axis Both Entrambi @msg {tool} isn't in a toolbar group of several tools {tool} non fa parte di un gruppo di più strumenti nella barra degli strumenti @msg onto must name a panel floating in another group onto deve indicare un pannello mobile in un altro gruppo @msg x and y must both be numbers x e y devono essere entrambi numeri + +# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. + Services Servizi + Hide VectorCraft Nascondi VectorCraft + Zoom Zoom + Bring All to Front Porta tutto in primo piano + Settings Impostazioni diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c3ecb5fd..9926f829 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3048,3 +3048,11 @@ axis Both 両方 Drag to float the toolbar ドラッグしてツールバーをフローティング Drag to the window's left edge to dock ウィンドウの左端までドラッグしてドッキング Float Tool Group ツールグループをフローティング + +# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. + Services サービス + Hide VectorCraft VectorCraft を隠す + Zoom 拡大/縮小 + Bring All to Front すべてを手前に移動 + Settings 環境設定 +theme Appearance 外観 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0e47c699..c3d4779e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2636,3 +2636,11 @@ Float Panel Flutuar Painel Dock Panel Encaixar Painel Float Tool Group Flutuar Grupo de Ferramentas + +# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. + Services Serviços + Hide VectorCraft Ocultar o VectorCraft + Hide Others Ocultar Outros + Zoom Zoom + Bring All to Front Trazer Todas para a Frente + Settings Configurações diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 13964493..a6008cef 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -503,6 +503,9 @@ fn menu_catalogs_translate_every_menu_label() { let mut all: Vec = labels.iter().filter(|l| interface(l)).map(|(l, ..)| l.to_string()).collect(); all.extend(toggled_labels()); all.extend(crate::menus::CONTEXT_LABELS.iter().map(|l| l.to_string())); + // The macOS menu bar's own labels, and its Settings submenu's Preferences pages. + all.extend(crate::native_menu::MAC_LABELS.iter().map(|l| l.to_string())); + all.extend(vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES.iter().map(|l| l.to_string())); all.sort(); all.dedup(); for code in KEEPS_MENU_NAMES { diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8c01b809..8850423f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3305,3 +3305,9 @@ axis Both 两者 Drag to float the toolbar 拖动以浮动工具栏 Drag to the window's left edge to dock 拖到窗口左边缘以停放 Float Tool Group 浮动工具组 + +# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. + Services 服务 + Hide VectorCraft 隐藏 VectorCraft + Bring All to Front 前置全部窗口 + Settings 设置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 05467689..77f49c7d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3079,3 +3079,10 @@ axis Both 兩者 Drag to float the toolbar 拖曳以浮動工具列 Drag to the window's left edge to dock 拖曳到視窗左側邊緣以停駐 Float Tool Group 浮動工具組 + +# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. + Services 服務 + Hide VectorCraft 隱藏 VectorCraft + Zoom 縮放 + Bring All to Front 將全部移至最前 + Settings 設定 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8cf17d93..e2b8c0cd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod icon_data; pub mod icons; pub mod io; pub mod menus; +pub mod native_menu; pub mod palette; pub mod panels; pub mod place; @@ -78,6 +79,8 @@ mod tests_home; #[cfg(test)] mod tests_labels; #[cfg(test)] +mod tests_nativemenu; +#[cfg(test)] mod tests_nativeoptions; #[cfg(test)] mod tests_numfields; @@ -228,6 +231,8 @@ pub struct Services { /// File → Print: the system's printers and print queue (desktop), the browser's print dialog /// (web). Without it Print saves the job as a PDF. pub print: Option>, + /// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then. + pub native_menu: Option, } /// Cached canvas raster. @@ -288,12 +293,11 @@ pub struct VectorcraftApp { pub perf: Perf, /// macOS: draw our own title strip under the traffic lights. pub integrated_titlebar: bool, - /// The host installed a native menu bar (macOS): don't draw in-window menus. - pub native_menu: bool, /// Last applied effect (Effect → Apply Last Effect). pub last_effect: Option<(String, serde_json::Value)>, - /// Commands whose shortcuts the native menu handles (skip them in egui to avoid double firing). - pub native_shortcuts: std::collections::HashSet, + /// Commands run through [`Self::run`] so far: the native menu bar reads its rows again when it + /// moves ([`native_menu::sync`]). + run_count: u64, control_rx: Option>, /// (token, path, reply, deadline ms): a window that isn't presented never delivers its frame. pending_screenshots: Vec<(u64, Option, Sender, f64)>, @@ -389,9 +393,8 @@ impl VectorcraftApp { }, perf: Perf::default(), integrated_titlebar: false, - native_menu: false, last_effect: None, - native_shortcuts: Default::default(), + run_count: 0, clipboard_out: None, clipboard_published: None, clipboard_in: None, @@ -477,6 +480,7 @@ impl VectorcraftApp { /// Run a UI or engine command by id. The single entry point for every frontend path. pub fn run(&mut self, id: &str, params: Value) -> Result { + self.run_count = self.run_count.wrapping_add(1); if let Some(r) = menus::run_ui_command(self, id, ¶ms) { return r; } @@ -793,6 +797,7 @@ impl VectorcraftApp { ctx.copy_text(t); } self.drain_inbox(); + native_menu::run(self, ctx); if self.fonts_ready { shortcuts::handle(self, ctx); } @@ -838,6 +843,13 @@ impl VectorcraftApp { /// modifiers egui holds (`i.modifiers`), so a synthetic key, button or wheel turn holds its own /// for the frames it spans (a drag's moves included); the keyboard's come back after. pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + // The native menu's key equivalents become the input their keys make, ahead of what came + // after them (see `native_menu`). + let keys = self.services.native_menu.as_mut().map(native_menu::NativeMenu::take_keys).unwrap_or_default(); + if !keys.is_empty() { + let events: Vec = keys.into_iter().flat_map(|k| native_menu::key_events(k, || self.system_clipboard_text())).collect(); + raw.events.splice(0..0, events); + } for e in &raw.events { match e { egui::Event::ModifiersChanged(m) => self.host_modifiers = *m, @@ -936,6 +948,7 @@ impl VectorcraftApp { } self.ui_fonts.frame(&ctx); scrub::end_frame(self, &ctx); + native_menu::sync(self, &ctx); self.perf.frame_ms = now_ms() - t0; let _ = json!(null); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 683e72d1..eb35c581 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1353,14 +1353,20 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { }); } let v = &app.ui.view; + // An item that can be on or off is `Some` whatever the document and selection (off with none): + // the macOS menu bar can't change an item's kind without rebuilding. + let selected_text = |onpath: bool| { + let st = app.session.active()?; + st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) { + Some(vectorcraft_engine::doc::NodeKind::Text(t)) if !onpath || matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. }) => { + Some(t) + } + _ => None, + }) + }; Some(match id { "type.orientation.vertical" | "type.orientation.horizontal" => { - let st = app.session.active()?; - let t = st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) { - Some(vectorcraft_engine::doc::NodeKind::Text(t)) => Some(t), - _ => None, - })?; - t.vertical == (id == "type.orientation.vertical") + selected_text(false).is_some_and(|t| t.vertical == (id == "type.orientation.vertical")) } "view.outline" => v.outline, "view.pixelPreview" => v.pixel_preview, @@ -1368,12 +1374,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "view.snapToPixel" => v.snap_to_pixel, // Type on a Path effect of the selected path type. "type.pathOptions" if p.get("effect").is_some() => { - let st = app.session.active()?; - let t = st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) { - Some(vectorcraft_engine::doc::NodeKind::Text(t)) if matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. }) => Some(t), - _ => None, - })?; - p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id()) + selected_text(true).is_some_and(|t| p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id())) } "view.smartGuides" => v.smart_guides, "view.grid" => v.grid, @@ -1407,8 +1408,8 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "view.proofColors" => vectorcraft_render::proof::view().proof_colors, "view.overprintPreview" => vectorcraft_render::proof::view().overprint, "view.proofSetup" => p.get("target").and_then(Value::as_str) == Some(vectorcraft_render::proof::view().setup.target.id().as_str()), - "perspective.grid.snap" => perspective_grid(app)?.snap, - "perspective.grid.lockStation" => perspective_grid(app)?.lock_station, + "perspective.grid.snap" => perspective_grid(app).is_some_and(|g| g.snap), + "perspective.grid.lockStation" => perspective_grid(app).is_some_and(|g| g.lock_station), "file.documentColorMode" | "object.convertDocumentColorMode" => { let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == vectorcraft_engine::doc::ColorMode::Cmyk); p.get("mode").and_then(Value::as_str) == Some(if cmyk { "cmyk" } else { "rgb" }) @@ -1611,28 +1612,81 @@ fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a String> { io::recent_files(app).get(n.checked_sub(1)?) } -/// A menu slot hidden while it is empty ([`hidden_when_disabled`]) rather than shown disabled (the -/// native menu leaves it out). -pub fn hidden_slot(app: &VectorcraftApp, id: &str) -> bool { - hidden_when_disabled(id) && !enabled(app, id) +/// How the menus show item `id` (params `p`) now: (enabled, checked: `Some` for an item that is on +/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]) or an item +/// another stands in for ([`swapped_out`]). +pub fn shown_state(app: &VectorcraftApp, id: &str, p: &Value) -> Option<(bool, Option)> { + let en = enabled(app, id); + if (!en && hidden_when_disabled(id)) || swapped_out(app, id) { + return None; + } + Some((en, checked(app, id, p))) } -/// How many saved-view, recent-file and User Defined library slots are listed: a menu that can't -/// hide items (the native one) is rebuilt when this changes. -pub fn listed_slots(app: &VectorcraftApp) -> usize { - use vectorcraft_engine::cmd::{stylelib, swatchlib}; - let user = |libs: Vec| libs.iter().filter(|l| l.category == "user").count().min(10); - let views = app.session.active().map_or(0, |d| d.doc.views.len().min(10)); - views - + app.session.active().map_or(0, |d| d.doc.saved_selections.len().min(SAVED_SELECTION_IDS.len())) - + io::recent_files(app).len().min(RECENT_IDS.len()) - + user(swatchlib::libraries(&app.session)) - + user(stylelib::libraries(&app.session)) - + crate::dialogs::perspective_presets::listed_slots(app) +/// One row of a menu as it shows now. The in-window menus draw these and the macOS menu bar +/// ([`crate::native_menu`]) is built from them, so the two can't drift. +pub enum Entry<'a> { + Sep, + /// A section header (a disabled label), in the UI language. + Header(&'static str), + /// A submenu: its label in the UI language, and its items. + Sub(&'static str, &'a [Item]), + Item(Row<'a>), +} + +/// A menu item as it shows now ([`entry`]). +pub struct Row<'a> { + /// The command and its params; `None` for an item not implemented yet (always disabled). + pub command: Option<(&'static str, &'a Value)>, + /// The tree's own English label, which [`click_target`] reads. + pub source: &'static str, + /// The label in the UI language, as it reads now (Undo *Move*, Show/Hide…, a recent file). + pub label: std::borrow::Cow<'static, str>, + /// The shortcut as the registry writes it (`Cmd+Shift+Z`), the user's overrides applied. + pub shortcut: Option<&'static str>, + pub enabled: bool, + /// `Some` for an item that is on or off. + pub checked: Option, +} + +impl Row<'_> { + /// What choosing it runs ([`click_target`]); `None` for an item not implemented yet. + pub fn target(&self) -> Option<(String, Value)> { + self.command.map(|(id, p)| click_target(self.source, id, p)) + } +} + +/// Item `it` as the menus show it now, or `None` while they leave it out ([`shown_state`]). +pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option> { + use crate::i18n::t; + Some(match it { + Item::Sep => Entry::Sep, + Item::Header(h) => Entry::Header(t(h)), + Item::Sub(label, children) => Entry::Sub(t(label), children), + Item::Todo(label, sc) => Entry::Item(Row { + command: None, + source: label, + label: t(label).into(), + shortcut: Some(*sc).filter(|s| !s.is_empty()), + enabled: false, + checked: None, + }), + Item::Cmd(label, id, p) => { + let (enabled, checked) = shown_state(app, id, p)?; + Entry::Item(Row { + command: Some((id, p)), + source: label, + label: display_label(app, id, label).into(), + shortcut: item_shortcut(id, p), + enabled, + checked, + }) + } + }) } /// Changes whenever a plug-in is installed or removed: Object › Plug-ins and Effect › Plug-ins -/// list them, so a menu built once (the native one) is rebuilt. +/// list them (the command palette's cache follows it). pub fn plugin_revision() -> u64 { vectorcraft_plugins::registry::revision() } @@ -2657,66 +2711,64 @@ fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: & /// room for one). fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], checks: bool, clicked: &mut Option<(String, Value)>) { let t = Tokens::get(ui.ctx()); - for it in items { - match it { - Item::Sep => { + for e in items.iter().filter_map(|it| entry(app, it)) { + match e { + Entry::Sep => { ui.separator(); } - Item::Header(h) => { - ui.label(egui::RichText::new(tl!(h)).size(11.0).color(t.text_dim)); + Entry::Header(h) => { + ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim)); } - Item::Sub(label, children) => { - ui.menu_button(tl!(label), |ui| { + Entry::Sub(label, children) => { + ui.menu_button(label, |ui| { widgets::menu_scroll(ui, |ui| { ui.set_min_width(200.0); render_items(app, ui, children, checks, clicked); }); }); } - Item::Todo(label, sc) => { - ui.add_enabled_ui(false, |ui| { - ui.add(egui::Button::new(tl!(label)).shortcut_text(pretty_shortcut(sc))); - }) - .response - .on_disabled_hover_text(tl!("Coming soon — tracked in the parity plan")); - } - Item::Cmd(label, id, p) => { - let en = enabled(app, id); - // Unused saved-view and recent-file slots are hidden (only the real ones are listed). - if (!en && hidden_when_disabled(id)) || swapped_out(app, id) { - continue; - } - let label = display_label(app, id, label); - let sc = item_shortcut(id, p).map(pretty_shortcut).unwrap_or_default(); - let chk = checks.then(|| checked(app, id, p)).flatten(); - let text = match chk { - Some(true) => format!("✓ {label}"), - Some(false) => format!(" {label}"), - None => label, - }; - // Type → Font: each family's sample after its name (Enable in-menu font previews). - let sampled = p.get("font").and_then(Value::as_str).filter(|_| *id == "text.setStyle" && app.session.prefs.font_preview); - let r = match sampled { - Some(family) => { - let slot = ui.id().with(("font-sample", family)); - let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE)); - let out = ui.add_enabled_ui(en, |ui| button.atom_ui(ui)).inner; - if let Some(rect) = out.rect(slot) { - crate::font_menu::menu_item_sample(ui, rect, family); - } - out.response - } - None => ui.add_enabled(en, egui::Button::new(text).shortcut_text(sc)), - }; - if r.clicked() { - *clicked = Some(click_target(label_of(it), id, p)); - ui.close(); - } - } + Entry::Item(row) => render_row(app, ui, &row, checks, clicked), } } } +/// One menu item: its label (with a check mark, or the room for one, under `checks`), its shortcut, +/// and Type › Font's samples. +fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool, clicked: &mut Option<(String, Value)>) { + let sc = row.shortcut.map(pretty_shortcut).unwrap_or_default(); + let Some((id, p)) = row.command else { + ui.add_enabled_ui(false, |ui| { + ui.add(egui::Button::new(row.label.as_ref()).shortcut_text(sc)); + }) + .response + .on_disabled_hover_text(tl!("Coming soon — tracked in the parity plan")); + return; + }; + let text = match row.checked.filter(|_| checks) { + Some(true) => format!("✓ {}", row.label), + Some(false) => format!(" {}", row.label), + None => row.label.to_string(), + }; + // Type → Font: each family's sample after its name (Enable in-menu font previews). + let sampled = p.get("font").and_then(Value::as_str).filter(|_| id == "text.setStyle" && app.session.prefs.font_preview); + let r = match sampled { + Some(family) => { + let slot = ui.id().with(("font-sample", family)); + let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE)); + let out = ui.add_enabled_ui(row.enabled, |ui| button.atom_ui(ui)).inner; + if let Some(rect) = out.rect(slot) { + crate::font_menu::menu_item_sample(ui, rect, family); + } + out.response + } + None => ui.add_enabled(row.enabled, egui::Button::new(text).shortcut_text(sc)), + }; + if r.clicked() { + *clicked = row.target(); + ui.close(); + } +} + /// 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) { @@ -2730,13 +2782,6 @@ pub(crate) fn home_showing(app: &VectorcraftApp) -> bool { app.ui.home.is_some() || (app.session.active().is_none() && app.session.prefs.show_home_screen) } -fn label_of(it: &Item) -> &'static str { - match it { - Item::Cmd(l, _, _) => l, - _ => "", - } -} - /// What a menu click runs: items whose label ends with "…" and that carry default params open a /// generic parameter dialog (fields = the defaults) instead of running immediately. pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) { @@ -2790,10 +2835,10 @@ fn field_event(app: &mut VectorcraftApp, id: &str) -> Option { }) } -/// Invoke an item of the system menu bar (macOS), chosen by a click or by its key equivalent: the -/// system takes those keys before the window sees them. While a text field has the keyboard, -/// Select All, Cut, Copy and Paste act on the field's text, as their keys do in the window; -/// everything else (and those commands with no field focused) goes to [`invoke`]. +/// Invoke an item clicked in the system menu bar (macOS; its key equivalents come back to egui as +/// key presses, see [`crate::native_menu`]). While a text field has the keyboard, Select All, Cut, +/// Copy and Paste act on the field's text, as their keys do; everything else (and those commands +/// with no field focused) goes to [`invoke`]. pub fn invoke_from_system_menu(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str, p: Value) { if ctx.text_edit_focused() && let Some(e) = field_event(app, id) @@ -3000,15 +3045,17 @@ pub fn context_entries(app: &VectorcraftApp) -> Vec { fn flatten(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Vec) { for it in items { match it { - Item::Cmd(_, id, _) if (hidden_when_disabled(id) && !enabled(app, id)) || swapped_out(app, id) => {} - Item::Cmd(l, id, p) => out.push(MenuEntry { - path: path.clone(), - label: dynamic_label(app, id, l), - command: Some(id.to_string()), - params: p.clone(), - enabled: enabled(app, id), - shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(), - }), + Item::Cmd(l, id, p) => { + let Some((enabled, _)) = shown_state(app, id, p) else { continue }; + out.push(MenuEntry { + path: path.clone(), + label: dynamic_label(app, id, l), + command: Some(id.to_string()), + params: p.clone(), + enabled, + shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(), + }) + } Item::Todo(l, sc) => out.push(MenuEntry { path: path.clone(), label: l.to_string(), @@ -3285,6 +3332,7 @@ pub fn menu_strings() -> std::collections::BTreeSet { out.insert(title.to_string()); walk(&items, title, &mut out); } + out.extend(crate::native_menu::MAC_LABELS.iter().map(|s| s.to_string())); out.extend(DYNAMIC_LABELS.iter().map(|s| s.to_string())); out.extend(CONTEXT_LABELS.iter().map(|s| s.to_string())); out.extend(UI_COMMANDS.iter().map(|c| c.1.to_string())); @@ -3549,11 +3597,11 @@ mod tests { let r = app.run("select.recall2", json!({})).unwrap(); assert!(r["count"].as_u64().unwrap() >= 1); assert_eq!(app.session.active().unwrap().selection.len(), 2); - // The native menu is rebuilt when the number of slots changes. - let listed = listed_slots(&app); + // A deleted selection's slot leaves the menus. app.run("select.editSaved", json!({"name": "Ellipse", "delete": true})).unwrap(); - assert_eq!(listed_slots(&app), listed - 1); assert!(!enabled(&app, "select.recall2")); + assert!(shown_state(&app, "select.recall2", &Value::Null).is_none()); + assert!(shown_state(&app, "select.recall1", &Value::Null).is_some()); } #[test] diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs new file mode 100644 index 00000000..2701dc02 --- /dev/null +++ b/crates/ui-egui/src/native_menu.rs @@ -0,0 +1,528 @@ +//! The macOS menu bar: the menus of the in-window bar ([`crate::menus::menu_tree`], each item as +//! [`crate::menus::entry`] shows it), laid out the way a Mac app's are and handed to a platform +//! [`Backend`] (the desktop app's, through muda). +//! +//! Pure and platform-free, so it is tested everywhere and builds for the web; only the backend +//! touches AppKit. What the layout does ([`mac_layout`], Illustrator on the Mac as the reference): +//! - an **app menu** named VectorCraft: About; Settings, a submenu with one item per Preferences +//! page (General… ⌘K first); Language; Appearance (the UI brightness); Services; Hide VectorCraft +//! ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All; and Quit ⌘Q, still the app's own +//! `app.quit`, so documents with unsaved changes are asked about. These leave the other menus +//! (Edit › Preferences…, Help › About), and Join Our Discord stays under Help only; +//! - **Window** gets Minimize ⌃⌘M, Zoom and Bring All to Front, and **Help** the system's search +//! field; +//! - a system key already bound to a command keeps the command's: the system item goes without. +//! +//! **Keys stay with egui.** AppKit runs a menu's key equivalents before the window sees the key, +//! which would skip VectorCraft's key rules (a focused text field takes ⌘A, ⌘C, ⌘V and ⌘Z; dialogs +//! and the Type tool's input method have the keyboard). So the backend reports a key equivalent as +//! [`Event::Key`], and [`key_events`] feeds it to egui as the key press it was (the clipboard +//! events egui's window integration makes of ⌘X, ⌘C and ⌘V): the shortcut runs through +//! [`crate::shortcuts::handle`] exactly as without a native menu, user overrides included. A click +//! is [`Event::Click`] and runs like an in-window menu click. +//! +//! **Updates are cheap.** The rows are read again only when something they show may have changed +//! ([`sync`]); labels, enabled and checked are then updated in place, and the menus are rebuilt +//! only when their structure changes ([`structure_key`]: a recent file added, the language or a +//! shortcut changed). + +use std::hash::{DefaultHasher, Hash, Hasher}; + +use egui::{Key, KeyboardShortcut}; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::i18n::{Lang, tr, tr_ctx}; +use crate::menus::{self, Entry}; + +pub const APP_NAME: &str = "VectorCraft"; +/// Hide VectorCraft: an item of ours (AppKit's always takes ⌘H, which is View › Hide Edges). +pub const HIDE: &str = "app.hide"; +/// Hide Others: an item of ours, so a command bound to ⌥⌘H keeps it. +pub const HIDE_OTHERS: &str = "app.hideOthers"; +/// Minimize: an item of ours with ⌃⌘M (AppKit's always takes ⌘M). +pub const MINIMIZE: &str = "window.minimize"; + +/// The labels the Mac layout adds to the menus (translated like every menu string: +/// [`crate::menus::menu_strings`]). +pub const MAC_LABELS: &[&str] = + &["Settings", "Appearance", "Services", "Hide VectorCraft", "Hide Others", "Show All", "Minimize", "Zoom", "Bring All to Front"]; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MenuBar { + pub menus: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Menu { + /// In the UI language (the app menu is the app's name). + pub title: String, + pub role: MenuRole, + pub children: Vec, +} + +/// What a top-level menu is for: macOS treats the app, Window and Help menus specially. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum MenuRole { + #[default] + Normal, + App, + Window, + Help, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Node { + Item(Item), + Separator, + /// A section header (a disabled label). + Header(String), + Submenu { + label: String, + children: Vec, + }, + /// An item AppKit provides and runs itself. + Standard(Standard), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Item { + /// The command id, one of [`HIDE`], [`HIDE_OTHERS`], [`MINIMIZE`], or `None` for an item not + /// implemented yet. + pub command: Option<&'static str>, + pub params: Value, + /// The menu tree's English label ([`crate::menus::click_target`] reads it). + pub source: &'static str, + /// In the UI language, as it reads now. + pub label: String, + /// As the registry writes it (`Cmd+Shift+Z`): `Cmd` is ⌘ and `Ctrl` the Control key. + pub shortcut: Option<&'static str>, + pub enabled: bool, + /// `Some` for an item that is on or off. + pub checked: Option, +} + +/// Items AppKit provides and runs: none has a key equivalent, so none can take a command's. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Standard { + Services, + ShowAll, + Zoom, + BringAllToFront, +} + +impl Standard { + /// The English label ([`MAC_LABELS`]). + pub fn label(self) -> &'static str { + match self { + Standard::Services => "Services", + Standard::ShowAll => "Show All", + Standard::Zoom => "Zoom", + Standard::BringAllToFront => "Bring All to Front", + } + } +} + +impl Item { + /// An item of the layout's own (Hide, Minimize…): always enabled, `shortcut` unless a command + /// has it. + fn system(command: &'static str, source: &'static str, shortcut: Option<&'static str>, lang: Lang) -> Item { + Item { command: Some(command), params: Value::Null, source, label: tr(lang, source).into(), shortcut, enabled: true, checked: None } + } +} + +impl MenuBar { + /// Every item, depth first. + pub fn items(&self) -> Vec<&Item> { + fn walk<'a>(nodes: &'a [Node], out: &mut Vec<&'a Item>) { + for n in nodes { + match n { + Node::Item(it) => out.push(it), + Node::Submenu { children, .. } => walk(children, out), + Node::Separator | Node::Header(_) | Node::Standard(_) => {} + } + } + } + let mut out = Vec::new(); + for m in &self.menus { + walk(&m.children, &mut out); + } + out + } + + pub fn menu(&self, role: MenuRole) -> Option<&Menu> { + self.menus.iter().find(|m| m.role == role) + } +} + +/// The menus as the in-window bar shows them now, as a tree: `tree`'s items through +/// [`crate::menus::entry`] (labels in the UI language, live state, empty slots left out). +pub fn from_tree(app: &VectorcraftApp, tree: &[(&'static str, Vec)]) -> MenuBar { + fn level(app: &VectorcraftApp, items: &[menus::Item]) -> Vec { + let nodes = items + .iter() + .filter_map(|it| menus::entry(app, it)) + .map(|e| match e { + Entry::Sep => Node::Separator, + Entry::Header(h) => Node::Header(h.to_string()), + Entry::Sub(label, children) => Node::Submenu { label: label.to_string(), children: level(app, children) }, + Entry::Item(row) => Node::Item(Item { + command: row.command.map(|(id, _)| id), + params: row.command.map_or(Value::Null, |(_, p)| p.clone()), + source: row.source, + label: row.label.into_owned(), + shortcut: row.shortcut, + enabled: row.enabled, + checked: row.checked, + }), + }) + .collect(); + tidy(nodes) + } + let menus = tree + .iter() + .enumerate() + .map(|(i, (title, items))| { + // The first menu is the in-window bar's application menu. + let role = match *title { + _ if i == 0 => MenuRole::App, + "Window" => MenuRole::Window, + "Help" => MenuRole::Help, + _ => MenuRole::Normal, + }; + Menu { title: crate::i18n::t(title).to_string(), role, children: level(app, items) } + }) + .collect(); + MenuBar { menus } +} + +/// Drop separators at the start or end of a level and collapse runs of them, at every depth. +fn tidy(nodes: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(nodes.len()); + for n in nodes { + let n = match n { + Node::Submenu { label, children } => Node::Submenu { label, children: tidy(children) }, + other => other, + }; + if matches!(n, Node::Separator) && matches!(out.last(), None | Some(Node::Separator)) { + continue; + } + out.push(n); + } + while matches!(out.last(), Some(Node::Separator)) { + out.pop(); + } + out +} + +/// A hash of everything a native menu can only change by rebuilding: menus, items (command, +/// params, kind: plain or check, shortcut), submenu and header labels. Item labels, enabled and +/// checked are updated in place. +pub fn structure_key(bar: &MenuBar) -> u64 { + fn walk(nodes: &[Node], h: &mut DefaultHasher) { + for n in nodes { + match n { + Node::Item(it) => ('i', it.command, it.params.to_string(), it.checked.is_some(), it.shortcut).hash(h), + Node::Separator => '-'.hash(h), + Node::Header(label) => ('h', label).hash(h), + Node::Submenu { label, children } => { + ('[', label).hash(h); + walk(children, h); + ']'.hash(h); + } + Node::Standard(s) => ('s', s).hash(h), + } + } + } + let mut h = DefaultHasher::new(); + for m in &bar.menus { + (&m.title, m.role).hash(&mut h); + walk(&m.children, &mut h); + } + h.finish() +} + +// ----------------------------------------------------------------------------- shortcuts + +/// Does a shortcut make a key equivalent in the native menu? Only with ⌘ or ⌃, or a function key: +/// bare keys and ⌥/⇧ chords are typing (⌥ types accented letters), which AppKit would otherwise +/// take from a text field. +pub fn native_ok(sc: &KeyboardShortcut) -> bool { + sc.modifiers.command || sc.modifiers.mac_cmd || sc.modifiers.ctrl || is_function_key(sc.logical_key) +} + +pub fn is_function_key(key: Key) -> bool { + key.name().strip_prefix('F').and_then(|n| n.parse::().ok()).is_some_and(|n| (1..=24).contains(&n)) +} + +/// The input egui's window integration would have made of a press of `sc` (key and modifiers +/// held): the key press and its release, but for ⌘X, ⌘C and ⌘V the Cut, Copy and Paste events +/// (Paste with the clipboard's text, from `paste_text`). A paste's release is left to the window's +/// own key-up: one without a press or a Paste event is how a paste of a picture or a PDF is told +/// apart ([`crate::shortcuts`]). +pub fn key_events(sc: KeyboardShortcut, paste_text: impl FnOnce() -> Option) -> Vec { + let key = |pressed| egui::Event::Key { key: sc.logical_key, physical_key: None, pressed, repeat: false, modifiers: sc.modifiers }; + if sc.modifiers.command { + match sc.logical_key { + Key::X => return vec![egui::Event::Cut, key(false)], + Key::C => return vec![egui::Event::Copy, key(false)], + Key::V => return paste_text().map(|t| t.replace("\r\n", "\n")).filter(|t| !t.is_empty()).map(egui::Event::Paste).into_iter().collect(), + _ => {} + } + } + vec![key(true), key(false)] +} + +// ----------------------------------------------------------------------------- macOS layout + +/// A system key equivalent VectorCraft already uses for a command. +#[derive(Clone, Debug, PartialEq)] +pub struct Clash { + pub shortcut: &'static str, + /// What macOS uses it for. + pub system: &'static str, + /// The command bound to it. + pub command: &'static str, +} + +pub struct Layout { + pub bar: MenuBar, + /// System keys left to commands (logged once). + pub clashes: Vec, +} + +/// The Mac menu bar of `bar` (the in-window menus, [`from_tree`]): see the module docs. +pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout { + let mut bar = bar.clone(); + let mut clashes = Vec::new(); + let bound = crate::shortcuts::all_shortcuts(); + // A system item's key, unless a command has it. + let mut key = |sc: &'static str, system: &'static str| { + let chord = crate::shortcuts::parse(sc)?; + match bound.iter().find(|(b, ..)| *b == chord) { + Some((_, command, _)) => { + clashes.push(Clash { shortcut: sc, system, command }); + None + } + None => Some(sc), + } + }; + let hide = Item::system(HIDE, "Hide VectorCraft", key("Ctrl+Cmd+H", "Hide"), lang); + let hide_others = Item::system(HIDE_OTHERS, "Hide Others", key("Cmd+Alt+H", "Hide Others"), lang); + let minimize = Item::system(MINIMIZE, "Minimize", key("Ctrl+Cmd+M", "Minimize"), lang); + + // What the application menu takes from the others (every copy: About is in Help too, Settings + // in Edit as Preferences…). + let about = take_items(&mut bar, "help.about").into_iter().next(); + let settings = take_items(&mut bar, "edit.preferences").into_iter().next(); + let quit = take_items(&mut bar, "app.quit").into_iter().next(); + let language = take_submenu(&mut bar, "app.language"); + let appearance = take_submenu(&mut bar, "window.brightness"); + // Its other items (Join Our Discord) stay where the in-window bar also has them: Help. + bar.menus.retain(|m| m.role != MenuRole::App); + + let mut app = Vec::new(); + app.extend(about.map(Node::Item)); + app.push(Node::Separator); + app.extend(settings.map(|s| Node::Submenu { label: tr(lang, "Settings").into(), children: settings_pages(&s, lang) })); + app.extend(language); + app.extend(appearance.map(|n| match n { + // The theme, as Mac apps call it. + Node::Submenu { children, .. } => Node::Submenu { label: tr_ctx(lang, "theme", "Appearance").into(), children }, + other => other, + })); + app.extend([Node::Separator, Node::Standard(Standard::Services), Node::Separator]); + app.extend([Node::Item(hide), Node::Item(hide_others), Node::Standard(Standard::ShowAll), Node::Separator]); + app.extend(quit.map(Node::Item)); + bar.menus.insert(0, Menu { title: APP_NAME.into(), role: MenuRole::App, children: app }); + + if let Some(window) = bar.menus.iter_mut().find(|m| m.role == MenuRole::Window) { + let mut children = vec![Node::Item(minimize), Node::Standard(Standard::Zoom), Node::Separator]; + children.append(&mut window.children); + children.extend([Node::Separator, Node::Standard(Standard::BringAllToFront)]); + window.children = children; + } + for m in &mut bar.menus { + m.children = tidy(std::mem::take(&mut m.children)); + } + // A menu the moves emptied goes away (Help stays: it has the search field). + bar.menus.retain(|m| !m.children.is_empty() || m.role == MenuRole::Help); + Layout { bar, clashes } +} + +/// Settings ▸: one item per page of the Preferences dialog (its own list), each opening it there; +/// the first (General…) has Preferences' shortcut (⌘K), as in Illustrator. +fn settings_pages(preferences: &Item, lang: Lang) -> Vec { + vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES + .iter() + .enumerate() + .map(|(i, page)| { + Node::Item(Item { + command: preferences.command, + params: json!({ "category": page }), + // The page's name, not a "…" label: it runs, rather than asking for its params. + source: page, + label: format!("{}…", tr(lang, page)), + shortcut: preferences.shortcut.filter(|_| i == 0), + enabled: preferences.enabled, + checked: None, + }) + }) + .collect() +} + +/// Remove every item running `command`, at any depth; returns them in order. +fn take_items(bar: &mut MenuBar, command: &str) -> Vec { + fn walk(nodes: &mut Vec, command: &str, out: &mut Vec) { + let mut kept = Vec::with_capacity(nodes.len()); + for n in std::mem::take(nodes) { + match n { + Node::Item(it) if it.command == Some(command) => out.push(it), + Node::Submenu { label, mut children } => { + walk(&mut children, command, out); + kept.push(Node::Submenu { label, children }); + } + other => kept.push(other), + } + } + *nodes = kept; + } + let mut out = Vec::new(); + for m in &mut bar.menus { + walk(&mut m.children, command, &mut out); + } + out +} + +/// Remove the first submenu that lists `command`, and any other item running it. +fn take_submenu(bar: &mut MenuBar, command: &str) -> Option { + fn find(nodes: &mut Vec, command: &str) -> Option { + let lists = |n: &Node| matches!(n, Node::Submenu { children, .. } if children.iter().any(|c| matches!(c, Node::Item(it) if it.command == Some(command)))); + if let Some(i) = nodes.iter().position(lists) { + return Some(nodes.remove(i)); + } + nodes.iter_mut().find_map(|n| match n { + Node::Submenu { children, .. } => find(children, command), + _ => None, + }) + } + let sub = bar.menus.iter_mut().find_map(|m| find(&mut m.children, command)); + take_items(bar, command); + sub +} + +/// VectorCraft's menus as a Mac menu bar, as they show now. +pub fn layout(app: &VectorcraftApp) -> Layout { + mac_layout(&from_tree(app, &menus::menu_tree()), crate::i18n::current()) +} + +// ----------------------------------------------------------------------------- the app's side + +/// What the native menu reports. +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + /// An item chosen with the mouse (or the keyboard inside an open menu). + Click(Item), + /// A key equivalent pressed: the item's key with the modifiers held, to be handled as the key + /// press it was. + Key(KeyboardShortcut), +} + +/// A platform's native menu bar (the desktop app implements it for macOS with muda). +pub trait Backend { + /// Bring the menu in step with `bar`: update labels, enabled and checked in place, or rebuild + /// when its structure changed. + fn sync(&mut self, bar: &MenuBar); + /// What was chosen since the last call. Items the platform runs itself (Services, Zoom, Hide…) + /// are handled there and not reported. + fn drain(&mut self) -> Vec; +} + +/// The native menu bar, while VectorCraft uses one (the in-window menus are hidden then). +pub struct NativeMenu { + backend: Box, + /// Hash of the state the menus were last read in ([`state_hash`]). + state: Option, + clicks: Vec, + /// Something was chosen (AppKit may have toggled a check mark itself): read the menus again. + dirty: bool, + logged: bool, +} + +impl NativeMenu { + pub fn new(backend: Box) -> NativeMenu { + NativeMenu { backend, state: None, clicks: Vec::new(), dirty: true, logged: false } + } + + /// The key equivalents pressed since the last frame, for [`key_events`]; clicks wait for + /// [`run`]. + pub fn take_keys(&mut self) -> Vec { + let mut keys = Vec::new(); + for e in self.backend.drain() { + match e { + Event::Key(k) => keys.push(k), + Event::Click(it) => self.clicks.push(it), + } + self.dirty = true; + } + keys + } +} + +/// Run the items clicked in the native menu since the last frame, like in-window menu clicks. +pub fn run(app: &mut VectorcraftApp, ctx: &egui::Context) { + let Some(menu) = app.services.native_menu.as_mut() else { return }; + for it in std::mem::take(&mut menu.clicks) { + match it.command { + Some(MINIMIZE) => ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)), + Some(command) => { + let (id, p) = menus::click_target(it.source, command, &it.params); + menus::invoke_from_system_menu(app, ctx, &id, p); + } + None => {} + } + } +} + +/// Bring the native menu up to date when anything it shows may have changed: a command ran, the +/// document, its history or selection changed, the user clicked or pressed a key. Reading the rows +/// asks every command whether it is enabled, so frames that only animate or scroll skip it. +pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) { + let Some(menu) = app.services.native_menu.as_ref() else { return }; + let input = ctx + .input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. } | egui::Event::PointerButton { pressed: false, .. }))); + let state = state_hash(app, input.then_some(app.frame)); + if menu.state == Some(state) && !menu.dirty { + return; + } + let layout = layout(app); + let Some(menu) = app.services.native_menu.as_mut() else { return }; + if !menu.logged { + for c in &layout.clashes { + log::info!("menu: {} is {} on macOS and {} in VectorCraft, which keeps it", c.shortcut, c.system, c.command); + } + menu.logged = true; + } + menu.backend.sync(&layout.bar); + menu.state = Some(state); + menu.dirty = false; +} + +/// What the menus' rows depend on, cheaply (no allocation): commands run, the active document, +/// its revision (edits, selection and view changes), history and clipboard, the language, +/// shortcuts and plug-ins. `input` forces a change on frames with a click or a key press, which +/// covers what the hash doesn't list (panels shown, a tool picked). +fn state_hash(app: &VectorcraftApp, input: Option) -> u64 { + let mut h = DefaultHasher::new(); + input.hash(&mut h); + app.run_count.hash(&mut h); + let s = &app.session; + (s.journal.len(), s.active_index(), s.clipboard.is_empty(), s.tool_composing()).hash(&mut h); + if let Some(d) = s.active() { + (d.uid, d.revision, d.history.undo.len(), d.history.redo.len(), d.transparency_grid, d.print_tiling).hash(&mut h); + } + (app.system_paste, app.last_effect.is_some(), app.ui.recent_files.len(), app.ui.recent_files.first(), app.recent_fonts().len()).hash(&mut h); + (crate::i18n::current().code(), menus::plugin_revision()).hash(&mut h); + crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed).hash(&mut h); + h.finish() +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 9b59f454..a409e226 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -82,10 +82,9 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json /// Consume a press of `sc`. A `=` chord also takes [`Key::Plus`], which is how `+` arrives from /// the numpad and from layouts where it has its own key (Shift+`=` arrives as either; extra Shift -/// is ignored). `native`: the system menu already handles the chord as written, so only that -/// alias is left to match here. -pub(crate) fn consume(i: &mut egui::InputState, sc: &KeyboardShortcut, native: bool) -> bool { - (!native && i.consume_shortcut(sc)) +/// is ignored). +pub(crate) fn consume(i: &mut egui::InputState, sc: &KeyboardShortcut) -> bool { + i.consume_shortcut(sc) || (sc.logical_key == Key::Equals && i.consume_key(sc.modifiers, Key::Plus)) || (sc.modifiers.shift && shifted(sc.logical_key).is_some_and(|k| i.consume_key(sc.modifiers, k))) } @@ -281,14 +280,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // Editing keys with modifiers and the clipboard (Cmd+A is Select All's, below: the text). crate::panels::character::route_type_input(app, ctx); // Enter was already delivered above as ToolKey::Enter (newline). - let fire = all_shortcuts().into_iter().filter(|(sc, ..)| sc.modifiers.command).find(|(sc, ..)| ctx.input_mut(|i| consume(i, sc, false))); + let fire = all_shortcuts().into_iter().filter(|(sc, ..)| sc.modifiers.command).find(|(sc, ..)| ctx.input_mut(|i| consume(i, sc))); if let Some((_, id, p)) = fire { crate::menus::invoke(app, id, p); } return; } - // Clipboard keys arrive as events, not key presses (except where the native menu has them); - // the chord held says which paste. + // Clipboard keys arrive as events, not key presses; the chord held says which paste. let mut clip = vec![]; ctx.input_mut(|i| { let held = i.modifiers; @@ -299,17 +297,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { egui::Event::Paste(t) => (paste_command(held), Some(t.clone())), _ => return true, }; - if app.native_shortcuts.contains(id) { - return true; - } clip.push((id, text)); false }) }); - // Only the system clipboard service reads what isn't text (the native menu has its own Paste). + // Only the system clipboard service reads what isn't text. if let Some(id) = textless_paste.map(paste_command) && app.services.system_clipboard.is_some() - && !app.native_shortcuts.contains(id) { clip.push((id, None)); } @@ -389,8 +383,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { if plain && (sc.logical_key.name().len() == 1 || matches!(sc.logical_key, Key::Slash | Key::Comma | Key::Period)) { continue; } - let native = app.native_shortcuts.contains(id); - if ctx.input_mut(|i| consume(i, &sc, native)) { + if ctx.input_mut(|i| consume(i, &sc)) { fire = Some((id, p)); break; } @@ -669,13 +662,6 @@ mod tests { let before = zoom(&mut app); frame(&mut app, vec![press(Key::Minus, Modifiers::COMMAND)]); assert!(zoom(&mut app) < before, "Cmd+- zooms out"); - // The system menu handles `Cmd+=` itself; the `+` key is still matched here. - app.native_shortcuts.insert("view.zoomIn".into()); - let before = zoom(&mut app); - frame(&mut app, vec![press(Key::Equals, Modifiers::COMMAND)]); - assert_eq!(zoom(&mut app), before, "left to the system menu"); - frame(&mut app, vec![press(Key::Plus, Modifiers::COMMAND)]); - assert!(zoom(&mut app) > before); } #[test] diff --git a/crates/ui-egui/src/tests_nativemenu.rs b/crates/ui-egui/src/tests_nativemenu.rs new file mode 100644 index 00000000..9ffe2814 --- /dev/null +++ b/crates/ui-egui/src/tests_nativemenu.rs @@ -0,0 +1,422 @@ +//! The macOS menu bar's model ([`crate::native_menu`]), tested on every platform with a fake +//! backend: the Mac layout of the in-window menus, the keys it hands back to egui, the clicks it +//! runs and when it reads the menus again. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use egui::{Event, Key, KeyboardShortcut, Modifiers, PointerButton, Pos2, Rect, vec2}; +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES; + +use crate::i18n::Lang; +use crate::native_menu::{self, Backend, MenuBar, MenuRole, NativeMenu, Node, Standard}; +use crate::{VectorcraftApp, menus}; + +fn app(doc: bool) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + if doc { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 37, "height": 40})).unwrap(); + } + app +} + +fn bar(app: &VectorcraftApp) -> MenuBar { + native_menu::mac_layout(&native_menu::from_tree(app, &menus::menu_tree()), Lang::EN).bar +} + +/// A level's nodes as short names: an item's command (`---` a separator, `[label]` a submenu, +/// `` an AppKit item). +fn ids(nodes: &[Node]) -> Vec { + nodes + .iter() + .map(|n| match n { + Node::Item(it) => it.command.unwrap_or("").to_string(), + Node::Separator => "---".into(), + Node::Header(h) => format!("#{h}"), + Node::Submenu { label, .. } => format!("[{label}]"), + Node::Standard(s) => format!("<{s:?}>"), + }) + .collect() +} + +fn menu<'a>(bar: &'a MenuBar, title: &str) -> &'a [Node] { + &bar.menus.iter().find(|m| m.title == title).unwrap_or_else(|| panic!("no {title} menu")).children +} + +fn submenu<'a>(nodes: &'a [Node], name: &str) -> &'a [Node] { + nodes + .iter() + .find_map(|n| match n { + Node::Submenu { label, children } if label == name => Some(children.as_slice()), + _ => None, + }) + .unwrap_or_else(|| panic!("no {name} submenu in {:?}", ids(nodes))) +} + +fn items(nodes: &[Node]) -> Vec<&native_menu::Item> { + nodes.iter().filter_map(|n| if let Node::Item(it) = n { Some(it) } else { None }).collect() +} + +fn find<'a>(bar: &'a MenuBar, command: &str) -> &'a native_menu::Item { + bar.items().into_iter().find(|it| it.command == Some(command)).unwrap_or_else(|| panic!("no {command} item")) +} + +#[test] +fn vectorcraft_gets_a_mac_app_menu() { + let bar = bar(&app(true)); + let titles: Vec<&str> = bar.menus.iter().map(|m| m.title.as_str()).collect(); + assert_eq!(titles, ["VectorCraft", "File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"]); + let app_menu = &bar.menus[0]; + assert_eq!(app_menu.role, MenuRole::App); + assert_eq!( + ids(&app_menu.children), + [ + "help.about", + "---", + "[Settings]", + "[Language]", + "[Appearance]", + "---", + "", + "---", + native_menu::HIDE, + native_menu::HIDE_OTHERS, + "", + "---", + "app.quit" + ] + ); + let shown = |command: &str| { + let it = find(&bar, command); + (it.label.as_str(), it.shortcut) + }; + assert_eq!(shown("help.about"), ("About VectorCraft", None)); + assert_eq!(shown("app.quit"), ("Quit VectorCraft", Some("Cmd+Q")), "Quit is the app's own, so unsaved documents are asked about"); + // ⌘H stays View › Hide Edges (Illustrator's); Hide takes ⌃⌘H. + assert_eq!(shown(native_menu::HIDE), ("Hide VectorCraft", Some("Ctrl+Cmd+H"))); + assert_eq!(shown(native_menu::HIDE_OTHERS), ("Hide Others", Some("Cmd+Alt+H"))); + assert_eq!(find(&bar, "view.edges").shortcut, Some("Cmd+H")); + assert_eq!(bar.menu(MenuRole::Help).map(|m| m.title.as_str()), Some("Help")); +} + +/// Settings ▸ lists the Preferences dialog's own pages, General… with ⌘K, and each opens the +/// dialog on its page. +#[test] +fn settings_lists_every_preferences_page() { + let mut app = app(true); + let bar = bar(&app); + let pages = items(submenu(&bar.menus[0].children, "Settings")); + assert_eq!(pages.len(), PREF_CATEGORIES.len()); + assert_eq!((pages[0].label.as_str(), pages[0].shortcut), ("General…", Some("Cmd+K"))); + assert!(pages[1..].iter().all(|it| it.shortcut.is_none())); + for (it, page) in pages.iter().zip(PREF_CATEGORIES) { + assert_eq!((it.command, &it.params, it.label.clone()), (Some("edit.preferences"), &json!({"category": page}), format!("{page}…"))); + } + let units = pages.iter().find(|it| it.source == "Units").unwrap(); + let (id, p) = menus::click_target(units.source, units.command.unwrap(), &units.params); + assert_eq!(id, "edit.preferences", "a page opens the dialog, not a parameter form"); + menus::invoke(&mut app, &id, p); + let dialog = app.ui.dialog.as_ref().expect("Preferences open"); + assert_eq!((dialog.kind.as_str(), dialog.fields.get("__category")), ("preferences", Some(&json!("Units")))); +} + +/// Language ▸: Automatic, then each language in its own name (in any UI language), the +/// preference checked; Appearance ▸: the UI brightness choices, the current one checked. +#[test] +fn language_and_appearance_are_in_the_app_menu() { + let mut app = app(false); + app.session.prefs.interface_language = "ja".into(); + let bar = bar(&app); + let app_menu = &bar.menus[0].children; + assert_eq!(ids(submenu(app_menu, "Language"))[..2], ["app.language", "---"], "Automatic, then the languages"); + let langs: Vec<(String, Option)> = items(submenu(app_menu, "Language")).iter().map(|it| (it.label.clone(), it.checked)).collect(); + let mut want = vec![("Automatic".to_string(), Some(false))]; + want.extend(Lang::all().map(|l| (l.name().to_string(), Some(l.code() == "ja")))); + assert_eq!(langs, want); + // In every UI language the languages keep their own names. + for ui in Lang::all() { + for l in Lang::all() { + assert_eq!(crate::i18n::tr_id(ui, "app.language", l.name()), l.name(), "{} in {}", l.name(), ui.code()); + } + } + let appearance: Vec<(&'static str, Option)> = items(submenu(app_menu, "Appearance")).iter().map(|it| (it.source, it.checked)).collect(); + assert_eq!(appearance.len(), crate::theme::Brightness::ALL.len()); + assert_eq!(appearance.iter().filter(|(_, c)| *c == Some(true)).count(), 1, "{appearance:?}"); + assert!(appearance.iter().all(|(_, c)| c.is_some())); + assert_eq!(crate::i18n::tr_ctx(Lang::from_code("ja").unwrap(), "theme", "Appearance"), "外観", "the theme, not the Appearance panel"); +} + +/// Everything the in-window bar lists is in the Mac menu bar, once: what moved to the app menu +/// (About, Settings, Quit, Language, UI Brightness) leaves its other places, and Join Our Discord +/// stays under Help. +#[test] +fn every_in_window_item_is_in_the_mac_menu_bar_once() { + let app = app(true); + let count = |bar: &MenuBar| { + let mut n: HashMap<(String, String), usize> = HashMap::new(); + for it in bar.items() { + if let Some(c) = it.command { + // Settings ▸ opens Preferences on each page: one item per page instead of one. + let params = if c == "edit.preferences" { String::new() } else { it.params.to_string() }; + *n.entry((c.to_string(), params)).or_default() += 1; + } + } + n + }; + let in_window = native_menu::from_tree(&app, &menus::menu_tree()); + let mac = bar(&app); + let (a, b) = (count(&in_window), count(&mac)); + // The in-window application menu's commands, its submenus' included. + let moved: Vec<&str> = MenuBar { menus: vec![in_window.menus[0].clone()] }.items().iter().filter_map(|it| it.command).collect(); + for (key, n) in &a { + let m = b.get(key).copied().unwrap_or(0); + let want = match key.0.as_str() { + "edit.preferences" => PREF_CATEGORIES.len(), + c if moved.contains(&c) => 1, + _ => *n, + }; + assert_eq!(m, want, "{key:?} is listed {m} times on the Mac, {n} in the window"); + } + let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE]; + for key in b.keys() { + assert!(a.contains_key(key) || system.contains(&key.0.as_str()), "{key:?} isn't an in-window item"); + } + let help = ids(menu(&mac, "Help")); + assert!(help.contains(&"help.discord".to_string()) && !help.contains(&"help.about".to_string()), "{help:?}"); + let edit = ids(menu(&mac, "Edit")); + assert!(!edit.contains(&"edit.preferences".to_string()) && edit.contains(&"edit.keyboardShortcuts".to_string()), "{edit:?}"); + assert!(ids(menu(&mac, "Window")).contains(&"[Workspace]".to_string())); +} + +#[test] +fn the_window_menu_has_the_system_items() { + let bar = bar(&app(true)); + let window = ids(&bar.menu(MenuRole::Window).unwrap().children); + assert_eq!(window[..3], [native_menu::MINIMIZE.to_string(), "".into(), "---".into()]); + assert_eq!(window[window.len() - 2..], ["---".to_string(), "".into()]); + assert_eq!(window[3], "window.newWindow", "then VectorCraft's own items"); + assert_eq!(find(&bar, native_menu::MINIMIZE).shortcut, Some("Ctrl+Cmd+M")); + assert!(!window.iter().any(|i| i == "window.brightness"), "the theme is only in the app menu"); + assert_eq!(Standard::Zoom.label(), "Zoom"); +} + +/// A system key a command already has stays the command's: the system item goes without. The +/// user's shortcuts show in the menus. +#[test] +fn a_command_keeps_a_system_key_it_is_bound_to() { + let mut app = app(true); + app.ui.shortcut_overrides.insert("view.grid".into(), "Ctrl+Cmd+H".into()); + crate::shortcut_editor::sync(&app.ui); + let layout = native_menu::mac_layout(&native_menu::from_tree(&app, &menus::menu_tree()), Lang::EN); + app.ui.shortcut_overrides.clear(); + crate::shortcut_editor::sync(&app.ui); + assert_eq!(find(&layout.bar, native_menu::HIDE).shortcut, None); + assert_eq!(find(&layout.bar, "view.grid").shortcut, Some("Ctrl+Cmd+H"), "the user's override shows in the menu"); + assert_eq!(layout.clashes.iter().map(|c| (c.shortcut, c.command)).collect::>(), [("Ctrl+Cmd+H", "view.grid")]); +} + +/// A native menu can't turn a plain item into a check item in place: opening a document or +/// selecting must not change the menus' structure, or every change rebuilds them. +#[test] +fn documents_and_selections_change_state_not_structure() { + let empty = native_menu::structure_key(&bar(&app(false))); + let mut a = app(true); + assert_eq!(native_menu::structure_key(&bar(&a)), empty, "a document with a selected rectangle"); + a.run("select.none", json!({})).unwrap(); + a.run("text.create", json!({"x": 50, "y": 50, "text": "Hi"})).unwrap(); + assert!(!a.session.active().unwrap().selection.objects.is_empty()); + assert_eq!(native_menu::structure_key(&bar(&a)), empty, "type selected"); + // A recent file is a new item: the menus are rebuilt. + crate::io::note_recent(&mut a, "/art/a.svg"); + assert_ne!(native_menu::structure_key(&bar(&a)), empty); +} + +#[test] +fn key_equivalents_are_command_and_function_keys_only() { + let ok = |s| crate::shortcuts::parse(s).is_some_and(|k| native_menu::native_ok(&k)); + assert!(ok("Cmd+B") && ok("F7") && ok("Ctrl+Cmd+M") && ok("Shift+F7")); + assert!(!ok("B") && !ok("Alt+F") && !ok("Shift+X")); +} + +/// What a key equivalent becomes: the press and release egui's window integration sends, or for +/// ⌘X, ⌘C and ⌘V its clipboard events (a paste's release comes from the window). +#[test] +fn key_equivalents_become_the_input_their_keys_make() { + let cmd = |key| KeyboardShortcut::new(Modifiers::COMMAND, key); + let key = |key, pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND }; + assert_eq!(native_menu::key_events(cmd(Key::A), || None), [key(Key::A, true), key(Key::A, false)]); + assert_eq!(native_menu::key_events(cmd(Key::C), || None), [Event::Copy, key(Key::C, false)]); + assert_eq!(native_menu::key_events(cmd(Key::X), || None), [Event::Cut, key(Key::X, false)]); + assert_eq!(native_menu::key_events(cmd(Key::V), || Some("a\r\nb".into())), [Event::Paste("a\nb".into())]); + assert_eq!(native_menu::key_events(cmd(Key::V), || None), [], "a picture: the release tells the paste"); +} + +/// The fake backend: the menus the app synced, and events to report. +#[derive(Clone, Default)] +struct Fake(Rc, Vec)>>); + +impl Backend for Fake { + fn sync(&mut self, bar: &MenuBar) { + self.0.borrow_mut().0.push(bar.clone()); + } + fn drain(&mut self) -> Vec { + std::mem::take(&mut self.0.borrow_mut().1) + } +} + +impl Fake { + fn synced(&self) -> usize { + self.0.borrow().0.len() + } + fn last(&self) -> MenuBar { + self.0.borrow().0.last().cloned().unwrap() + } + fn report(&self, e: native_menu::Event) { + self.0.borrow_mut().1.push(e); + } +} + +/// The app with the fake native menu (or none), driven a whole frame at a time like the desktop +/// app. +struct Mac { + app: VectorcraftApp, + fake: Fake, + ctx: egui::Context, + time: f64, +} + +impl Mac { + fn new(native: bool) -> Self { + let mut app = app(true); + let fake = Fake::default(); + if native { + app.services.native_menu = Some(NativeMenu::new(Box::new(fake.clone()))); + } + let mut m = Mac { app, fake, ctx: egui::Context::default(), time: 0.0 }; + for _ in 0..3 { + m.frame(vec![]); + } + m + } + + fn frame(&mut self, events: Vec) -> egui::FullOutput { + self.time += 0.1; + let mut raw = egui::RawInput { + events, + time: Some(self.time), + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 850.0))), + ..Default::default() + }; + self.app.raw_input_hook(&mut raw); + let app = &mut self.app; + let mut out = self.ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out + } + + /// AppKit chose an item by its key equivalent `sc` (⌘ held). + fn key_equivalent(&mut self, sc: &str) -> egui::FullOutput { + let mut k = crate::shortcuts::parse(sc).unwrap(); + k.modifiers.mac_cmd = k.modifiers.command; + self.fake.report(native_menu::Event::Key(k)); + self.frame(vec![]) + } + + fn selected(&self) -> usize { + self.app.session.active().unwrap().selection.objects.len() + } +} + +/// A key equivalent runs through the app's own key handling, once: ⌘A selects every object, ⌘Z +/// undoes one step; and frames where nothing changed don't read the menus again. +#[test] +fn key_equivalents_run_through_the_normal_shortcut_path_once() { + let mut m = Mac::new(true); + m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap(); + m.app.run("select.none", json!({})).unwrap(); + m.frame(vec![]); + m.key_equivalent("Cmd+A"); + assert_eq!(m.selected(), 2, "⌘A selected every object"); + let undo = |m: &Mac| m.app.session.active().unwrap().history.undo.len(); + let before = undo(&m); + m.key_equivalent("Cmd+Z"); + assert_eq!(undo(&m), before - 1, "⌘Z undid one step"); + let synced = m.fake.synced(); + assert!(synced >= 1, "the menus were built"); + m.frame(vec![]); + m.frame(vec![]); + assert_eq!(m.fake.synced(), synced, "nothing changed: the menus weren't read again"); + assert_eq!(crate::control::inspect(&m.app, &m.ctx)["nativeMenuBar"], json!(true)); +} + +/// A click runs its command like an in-window click, and the menus follow: View › Show Rulers +/// reads Hide Rulers afterwards; the app menu's Appearance items run with their params. +#[test] +fn clicks_run_their_command_and_the_menus_follow() { + let mut m = Mac::new(true); + let rulers = find(&m.fake.last(), "view.rulers").clone(); + let before = m.app.ui.view.rulers; + m.fake.report(native_menu::Event::Click(rulers.clone())); + m.frame(vec![]); + assert_eq!(m.app.ui.view.rulers, !before); + assert_ne!(find(&m.fake.last(), "view.rulers").label, rulers.label, "the label flipped"); + let other = m.fake.last().items().into_iter().find(|it| it.command == Some("window.brightness") && it.checked == Some(false)).cloned().unwrap(); + m.fake.report(native_menu::Event::Click(other.clone())); + m.frame(vec![]); + assert_eq!(Some(m.app.ui.brightness.id()), other.params.get("brightness").and_then(Value::as_str)); + let now = m.fake.last().items().into_iter().find(|it| it.params == other.params).cloned().unwrap(); + assert_eq!(now.checked, Some(true), "the checkmark moved"); +} + +/// With a text field focused, the menu's ⌘A and ⌘C act on the field, as the keys do without the +/// native menu. +#[test] +fn a_focused_field_keeps_select_all_and_copy() { + let mut m = Mac::new(true); + let (id, rect) = m + .ctx + .viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).map(|w| (w.id, w.rect)).collect::>()) + .into_iter() + .find(|&(id, _)| m.ctx.data(|d| d.get_temp::(id)).is_some_and(|t| t == "37 pt") && egui::TextEdit::load_state(&m.ctx, id).is_some()) + .expect("the W field"); + let click = |pressed| Event::PointerButton { pos: rect.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + m.frame(vec![Event::PointerMoved(rect.center()), click(true)]); + m.frame(vec![click(false)]); + m.frame(vec![Event::Key { key: Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(m.ctx.text_edit_focused()); + m.key_equivalent("Cmd+A"); + let r = egui::TextEdit::load_state(&m.ctx, id).and_then(|s| s.cursor.char_range()).unwrap(); + let (a, b): (usize, usize) = (r.primary.index.into(), r.secondary.index.into()); + assert_eq!((a.min(b), a.max(b)), (0, 5), "⌘A selected the field's text"); + assert_eq!(m.selected(), 1, "not the art"); + let out = m.key_equivalent("Cmd+C"); + let copied: Vec<_> = + out.platform_output.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t.as_str()) } else { None }).collect(); + assert_eq!(copied, ["37 pt"]); + assert!(m.app.session.clipboard.is_empty(), "no art copied"); +} + +/// With the macOS menu bar the app bar draws no menu titles: each shows once fewer. +#[test] +fn the_app_bar_hides_its_menus_with_the_mac_menu_bar() { + let texts = |native: bool| { + let mut m = Mac::new(native); + let out = m.frame(vec![]); + out.shapes + .iter() + .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t.galley.text().to_string()) } else { None }) + .collect::>() + }; + let (in_window, native) = (texts(false), texts(true)); + for (title, _) in menus::menu_tree().iter().skip(1) { + let n = |t: &[String]| t.iter().filter(|s| s == title).count(); + assert_eq!(n(&native) + 1, n(&in_window), "{title}"); + } +} diff --git a/crates/ui-egui/src/tests_save.rs b/crates/ui-egui/src/tests_save.rs index 39826283..30b0398e 100644 --- a/crates/ui-egui/src/tests_save.rs +++ b/crates/ui-egui/src/tests_save.rs @@ -107,7 +107,7 @@ fn recent_files_show_as_many_as_the_preference_keeps() { assert!(menus::enabled(&app, "file.openRecent30")); app.session.prefs.recent_files_count = 0; assert_eq!((io::recent_files(&app).len(), listed(&app)), (0, 0), "0 hides the list"); - assert_eq!(menus::listed_slots(&app), 0, "the native menu rebuilds without them"); + assert!(menus::shown_state(&app, "file.openRecent1", &json!(null)).is_none(), "the slots leave the menus"); assert!(app.run("file.openRecent1", json!({})).is_err()); app.session.prefs.recent_files_count = 5; assert_eq!(io::recent_files(&app)[0], "/art/34.svg", "hiding them didn't forget them"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3844c82f..3a2e27f1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -18,7 +18,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) | | `engine.commands` | | every command with label, shortcut, params doc, enablement | | `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does | -| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), view, canvas rect, window size, perf, background saves and exports still running | +| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way) | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | diff --git a/docs/development.md b/docs/development.md index aa441142..ae28520f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,6 +62,7 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`. | Variable | Effect | |---|---| | `VECTORCRAFT_CONTROL_PORT` | Same as `--control ` | +| `VECTORCRAFT_IN_WINDOW_MENUS` | Any value but empty or `0`: same as `--in-window-menus` (see [macOS: the menu bar](#macos-the-menu-bar)) | | `VECTORCRAFT_NO_PREFS` | No preferences read or written, no default Data Recovery folder and no log file (agents' test runs) | | `WGPU_POWER_PREF` | Graphics adapter: `low`, `high` or `none` (see [Desktop graphics processor](#desktop-graphics-processor)) | | `WGPU_ADAPTER_NAME` | Graphics adapter by (part of) its name, any case (see [Desktop graphics processor](#desktop-graphics-processor)) | @@ -69,6 +70,15 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`. | `VECTORCRAFT_GPU_SKIP` | Set by the app when it starts again without a graphics adapter that failed (see [Desktop graphics processor](#desktop-graphics-processor)) | | `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | +## macOS: the menu bar + +On macOS the menus live in the system menu bar, not in the window's app bar (which keeps the brand mark, Home, the search box and the workspace switcher next to the traffic lights). `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps them in the window, as on Windows, Linux and the web; so does a menu bar AppKit refuses to build (logged). `ui.inspect` says which with `nativeMenuBar`. + +- **One menu model.** `crates/ui-egui/src/native_menu.rs` (platform-free, tested on every platform) builds the menu bar from the in-window menus (`menus::menu_tree`, each item through `menus::entry`, which the in-window menus draw too) and lays it out the Mac way: the VectorCraft menu holds About, Settings ▸ (one item per Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own `app.quit`, so unsaved documents are asked about); those leave Edit and Help. Window starts with Minimize ⌃⌘M and Zoom and ends with Bring All to Front, and Help is the system's Help menu (its search field). A system key a command (or a user's shortcut) already has stays the command's, and the log says so. +- **The native side** is `apps/vectorcraft/src/mac_menu.rs`: muda (`=0.21.1`, default features off, macOS only, no `unsafe`) builds the `NSMenu`s. Labels, enabled and checked are updated in place when anything they show may have changed (a command ran, the document, its history or selection changed, a click or a key press); a change of structure (a recent file, the language, a shortcut) rebuilds the menus. +- **Keys stay with egui.** AppKit runs a menu's key equivalents before the window sees the key, so the item's key goes back to egui as the key press it was (⌘X/⌘C/⌘V as egui's Cut/Copy/Paste events), through `raw_input_hook`: `shortcuts::handle` runs it once, user shortcuts apply, and a focused text field keeps ⌘A, ⌘C, ⌘V and ⌘Z. A click runs the item like an in-window click. Bare keys and ⌥/⇧ chords are never key equivalents (they type). +- Built and clippy-checked for `aarch64-apple-darwin` and `x86_64-apple-darwin` from any host: `cargo clippy -p vectorcraft --target aarch64-apple-darwin --all-targets -- -D warnings`. + ## Linux: Wayland and X11 The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: