Merge pull request #509 from storytold/feat/macos-menu-bar
macOS: the menus live in the system menu bar, settings in the VectorCraft menu
This commit is contained in:
commit
843f46108c
|
|
@ -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]]
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<KeyboardShortcut>,
|
||||
}
|
||||
|
||||
/// 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<Menu>,
|
||||
entries: Vec<Entry>,
|
||||
rx: Receiver<Chosen>,
|
||||
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<Entry>) -> 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<Event> {
|
||||
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::<usize>().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<native_menu::NativeMenu> {
|
||||
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<Accelerator> {
|
||||
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<Code> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
//! VectorCraft desktop app.
|
||||
//!
|
||||
//! Usage: `vectorcraft [--control <port>] [files…]`
|
||||
//! Usage: `vectorcraft [--control <port>] [--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 <port>` (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<native_menu::NativeMenu>,
|
||||
}
|
||||
|
||||
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<u16> = 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 }))
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<String, (String, Value, Handle, String)>,
|
||||
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<Accelerator> {
|
||||
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<String, (String, Value, Handle, String)>,
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -209,11 +209,6 @@ pub fn slot_preset(app: &VectorcraftApp, id: &str) -> Option<String> {
|
|||
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<Item> {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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í
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -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 外観
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -503,6 +503,9 @@ fn menu_catalogs_translate_every_menu_label() {
|
|||
let mut all: Vec<String> = 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 {
|
||||
|
|
|
|||
|
|
@ -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 设置
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -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 設定
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -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<Box<dyn print::PrintService>>,
|
||||
/// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then.
|
||||
pub native_menu: Option<native_menu::NativeMenu>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<Receiver<ControlRequest>>,
|
||||
/// (token, path, reply, deadline ms): a window that isn't presented never delivers its frame.
|
||||
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>, 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<Value, String> {
|
||||
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<egui::Event> = 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1353,14 +1353,20 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
|||
});
|
||||
}
|
||||
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<bool> {
|
|||
"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<bool> {
|
|||
"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<bool>)> {
|
||||
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<swatchlib::LibraryInfo>| 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<bool>,
|
||||
}
|
||||
|
||||
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<Entry<'a>> {
|
||||
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<u64>, 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<egui::Event> {
|
|||
})
|
||||
}
|
||||
|
||||
/// 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<MenuEntry> {
|
|||
fn flatten(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
|
||||
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<String> {
|
|||
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]
|
||||
|
|
|
|||
|
|
@ -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<Menu>,
|
||||
}
|
||||
|
||||
#[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<Node>,
|
||||
}
|
||||
|
||||
/// 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<Node>,
|
||||
},
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
/// 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<menus::Item>)]) -> MenuBar {
|
||||
fn level(app: &VectorcraftApp, items: &[menus::Item]) -> Vec<Node> {
|
||||
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<Node>) -> Vec<Node> {
|
||||
let mut out: Vec<Node> = 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::<u8>().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<String>) -> Vec<egui::Event> {
|
||||
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<Clash>,
|
||||
}
|
||||
|
||||
/// 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<Node> {
|
||||
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<Item> {
|
||||
fn walk(nodes: &mut Vec<Node>, command: &str, out: &mut Vec<Item>) {
|
||||
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<Node> {
|
||||
fn find(nodes: &mut Vec<Node>, command: &str) -> Option<Node> {
|
||||
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<Event>;
|
||||
}
|
||||
|
||||
/// The native menu bar, while VectorCraft uses one (the in-window menus are hidden then).
|
||||
pub struct NativeMenu {
|
||||
backend: Box<dyn Backend>,
|
||||
/// Hash of the state the menus were last read in ([`state_hash`]).
|
||||
state: Option<u64>,
|
||||
clicks: Vec<Item>,
|
||||
/// 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<dyn Backend>) -> 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<KeyboardShortcut> {
|
||||
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>) -> 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()
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
/// `<Standard>` an AppKit item).
|
||||
fn ids(nodes: &[Node]) -> Vec<String> {
|
||||
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]",
|
||||
"---",
|
||||
"<Services>",
|
||||
"---",
|
||||
native_menu::HIDE,
|
||||
native_menu::HIDE_OTHERS,
|
||||
"<ShowAll>",
|
||||
"---",
|
||||
"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<bool>)> = 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<bool>)> = 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(), "<Zoom>".into(), "---".into()]);
|
||||
assert_eq!(window[window.len() - 2..], ["---".to_string(), "<BringAllToFront>".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::<Vec<_>>(), [("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<RefCell<(Vec<MenuBar>, Vec<native_menu::Event>)>>);
|
||||
|
||||
impl Backend for Fake {
|
||||
fn sync(&mut self, bar: &MenuBar) {
|
||||
self.0.borrow_mut().0.push(bar.clone());
|
||||
}
|
||||
fn drain(&mut self) -> Vec<native_menu::Event> {
|
||||
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<Event>) -> 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::<Vec<_>>())
|
||||
.into_iter()
|
||||
.find(|&(id, _)| m.ctx.data(|d| d.get_temp::<String>(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::<Vec<_>>()
|
||||
};
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}` | |
|
||||
|
|
|
|||
|
|
@ -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 <port>` |
|
||||
| `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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue