mirror of
https://github.com/remorses/gpuix.git
synced 2026-10-29 11:14:49 +08:00
Install the macOS application menu bar
GPUI never calls NSApplication.setMainMenu:, and GPUIX never called
cx.set_menus, so NSApp.mainMenu stayed nil. macOS painted nothing next to
the Apple menu. Verified on the running example app through the
accessibility tree: 'menu bars 0' before, 'Apple, bun, Window' after.
The empty bar was the smaller half of the bug. AppKit only provides the
standard shortcuts through menu items, so cmd-Q, cmd-H, opt-cmd-H, cmd-M
and cmd-W did not exist at all. There was no way to quit a GPUIX app from
the keyboard.
Activation policy was not the cause. did_finish_launching already sets
NSApplicationActivationPolicyRegular, embedded mode included.
Apple <appName> Window
|- Services |- (AppKit window tiling)
|- Hide <appName> cmd-H |- Minimize cmd-M
|- Hide Others o-cmd-H |- Zoom
|- Show All |- Close Window cmd-W
'- Quit <appName> cmd-Q '- (open windows)
crate::app_menu binds the keys, registers the handlers, then calls
set_menus, in that order: gpui::App::set_menus reads each key equivalent
out of the keymap, so a binding added afterwards would show no shortcut.
Quit, Hide, HideOthers and ShowAll are cx.on_action globals.
MinimizeWindow, ZoomWindow and CloseWindow go through
with_window_menu_actions on the root element in GpuixView::render,
because that is the only place in GPUIX where a Window exists.
New appName window option for the name inside 'Hide X' and 'Quit X'. It
defaults to title.
Two things AppKit decides, both found by reading back the live menu
rather than by trusting the code:
- the title of the application menu is the executable name, not the Menu
name passed to set_menus. 'bun app.tsx' shows 'bun', and only a .app
bundle changes it. The items inside do use appName
- the menu named Window is handed to setWindowsMenu:, which prepends
AppKit's own tiling items, 'Enter Full Screen' included. The first
version added that item too and produced two entries sharing a
shortcut, so it is gone
No Edit menu, on purpose. AppKit consumes a menu key equivalent before
the window sees the key event, so an Edit menu carrying cmd-C would take
the keystroke away from the selection listener in text::paint and from
the per-focus clipboard handling in custom_elements::input. An Edit menu
needs those handlers moved into GPUI actions first.
Tests: 198 Rust (2 new), 340 react, 36 examples, 6 example-app.
Session: ses_fc0edf605ffesXw3qtyEm0tZgG
This commit is contained in:
1 parent
2daf9881ba
commit
7baa36fdcf
7 files changed
+264
-2
No files matched your search
@@ -0,0 +1,27 @@
|
||||
---
|
||||
'@gpuix/native': minor
|
||||
'@gpuix/react': minor
|
||||
---
|
||||
|
||||
Install the macOS application menu bar, so a GPUIX app answers `⌘Q`, `⌘H`, `⌥⌘H`, `⌘M` and `⌘W` instead of showing an empty menu bar.
|
||||
|
||||
GPUI never calls `NSApplication.setMainMenu:` on its own, so `NSApp.mainMenu` stayed nil. macOS paints nothing next to the Apple menu, and the standard shortcuts do not exist either, because AppKit only provides them through menu items. There was no way to quit a GPUIX app from the keyboard.
|
||||
|
||||
```
|
||||
Apple <appName> Window
|
||||
├ Services ├ (AppKit window tiling)
|
||||
├ Hide <appName> ⌘H ├ Minimize ⌘M
|
||||
├ Hide Others ⌥⌘H ├ Zoom
|
||||
├ Show All ├ Close Window ⌘W
|
||||
└ Quit <appName> ⌘Q └ (open windows)
|
||||
```
|
||||
|
||||
New `appName` window option for the name inside `Hide X` and `Quit X`. It defaults to `title`.
|
||||
|
||||
```tsx
|
||||
render(<App />, { title: 'Todo', appName: 'Todo' })
|
||||
```
|
||||
|
||||
The **title of the application menu comes from the executable**, not from `appName`. macOS reads it from the running binary, so `bun app.tsx` shows `bun` and a `bun build --compile` binary shows its own file name. Only a real `.app` bundle changes it.
|
||||
|
||||
There is **no Edit menu**, on purpose. AppKit consumes a menu key equivalent before the window sees the key event, so an Edit menu carrying `⌘C` would take the keystroke away from cross-element text selection and from `<input>`.
|
||||
@@ -345,6 +345,39 @@ so it needs a positioned parent. Pass `Some(selectable)` instead of `None` when
|
||||
the element also owns a selection-start region; the editor uses `Some(false)` so
|
||||
a drag moves the caret instead of starting a document selection.
|
||||
|
||||
## A macOS menu item owns its shortcut, so the window never sees it
|
||||
|
||||
`crate::app_menu` installs the App and Window menus during `init_macos`. GPUI
|
||||
does not do this on its own: `NSApp.mainMenu` stays nil, macOS paints an empty
|
||||
menu bar, and `⌘Q`, `⌘H`, `⌘M` and `⌘W` do not exist, because AppKit only
|
||||
provides them through menu items.
|
||||
|
||||
**Never add an Edit menu carrying `⌘C` / `⌘V` / `⌘X` / `⌘A`.** AppKit consumes a
|
||||
key equivalent before the window sees the key event, so those items would take
|
||||
the keystroke away from the selection listener in `text::paint` and from the
|
||||
per-focus clipboard handling in `custom_elements::input`. An Edit menu needs
|
||||
those handlers moved into GPUI actions first.
|
||||
|
||||
`gpui::App::set_menus` reads each shortcut out of the keymap, so bind the keys
|
||||
**before** you call it. Window-level items (`MinimizeWindow`, `ZoomWindow`,
|
||||
`CloseWindow`) go through `with_window_menu_actions` on the root element in
|
||||
`GpuixView::render`, because a `Window` exists nowhere else; app-level ones
|
||||
(`Quit`, `Hide`, `HideOthers`, `ShowAll`) are `cx.on_action` globals.
|
||||
|
||||
Two things real AppKit decides for you. The **title of the application menu is
|
||||
the executable name**, not the `Menu` name you pass, so `bun app.tsx` shows
|
||||
`bun`; only a `.app` bundle changes it. And the menu named `Window` is handed to
|
||||
`setWindowsMenu:`, which prepends AppKit's own tiling items, `Enter Full Screen`
|
||||
included. Do not add that item yourself.
|
||||
|
||||
Verify with the accessibility tree, not a screenshot. The system menu bar is
|
||||
outside the window, so GPUIX automation cannot see it.
|
||||
|
||||
```bash
|
||||
osascript -e 'tell application "System Events" to tell (first process whose unix id is PID) \
|
||||
to get name of every menu bar item of menu bar 1'
|
||||
```
|
||||
|
||||
## Browser keyboard input goes through GPUI's hidden element
|
||||
|
||||
A GPUI web app has two event surfaces. The `<canvas>` takes pointer events.
|
||||
@@ -1015,6 +1048,7 @@ belong in README. This list is only the remaining engineering work.
|
||||
- [x] Headless Select, Combobox, Tooltip
|
||||
- [x] `setWindowTitle`
|
||||
- [x] Window chrome (`titlebarTransparent`, `windowBackground`, traffic-light position)
|
||||
- [x] macOS menu bar (`crate::app_menu`, `appName`)
|
||||
- [x] Last window close quits the process
|
||||
- [x] Debug frame overlay (`setDebugFrameOverlay`)
|
||||
|
||||
|
||||
@@ -387,8 +387,35 @@ terminal.
|
||||
| `windowBackground` | `"opaque"` (default), `"transparent"`, `"blurred"` | Window fill. `"blurred"` is the macOS vibrancy backdrop |
|
||||
| `trafficLightX` / `trafficLightY` | pixels | Traffic-light origin. Waku uses `(16, 17)` |
|
||||
| `transparent` | boolean | Same as `windowBackground: "transparent"` when that option is unset |
|
||||
| `appName` | string | Name inside the macOS application menu, in `Hide X` and `Quit X`. Defaults to `title` |
|
||||
Call it again after a save and it remounts the tree on the same window.
|
||||
|
||||
### The macOS menu bar
|
||||
|
||||
GPUIX installs the application menu bar for you, so a fresh app already answers
|
||||
`⌘Q`, `⌘H`, `⌥⌘H`, `⌘M`, and `⌘W`. Without it `NSApp.mainMenu` is nil, macOS
|
||||
paints an empty menu bar, and those shortcuts do not exist at all: AppKit only
|
||||
provides them through menu items.
|
||||
|
||||
```
|
||||
Apple <appName> Window
|
||||
├ Services ├ (AppKit window tiling)
|
||||
├ Hide <appName> ⌘H ├ Minimize ⌘M
|
||||
├ Hide Others ⌥⌘H ├ Zoom
|
||||
├ Show All ├ Close Window ⌘W
|
||||
└ Quit <appName> ⌘Q └ (open windows)
|
||||
```
|
||||
|
||||
The **title of the application menu comes from the executable**, not from
|
||||
`appName`. macOS reads it from the running binary, so `bun app.tsx` shows `bun`
|
||||
during development and a `bun build --compile` binary shows its own file name.
|
||||
Only a real `.app` bundle can change it. The items inside the menu do use
|
||||
`appName`.
|
||||
|
||||
There is **no Edit menu**, on purpose. A menu key equivalent is consumed by
|
||||
AppKit before the window sees the key event, so an Edit menu carrying `⌘C`
|
||||
would take the keystroke away from text selection and from `<input>`.
|
||||
|
||||
Use **`render()`**, not `createRenderer()`, in the app entry. `bun --hot`
|
||||
re-runs the whole file on save. `createRenderer()` plus `init()` would then
|
||||
build a second host. `render()` is idempotent: the first call owns the window,
|
||||
@@ -2353,6 +2380,8 @@ The test renderer uses `VisualTestAppContext` with a `TestDispatcher` for determ
|
||||
- [x] Native `hover` and `active` styles
|
||||
- [x] Window title (`setWindowTitle`)
|
||||
- [x] Window chrome (`titlebarTransparent`, `windowBackground`, traffic-light position)
|
||||
- [x] macOS menu bar with the standard shortcuts (`appName`)
|
||||
- [ ] App-declared menus and menu callbacks
|
||||
- [x] Last window close quits the process
|
||||
- [x] Debug frame overlay (`debugFrameOverlay` / `setDebugFrameOverlay`)
|
||||
- [ ] Canvas element
|
||||
|
||||
Vendored
+5
@@ -489,6 +489,11 @@ export interface WindowInsets {
|
||||
|
||||
export interface WindowOptions {
|
||||
title?: string
|
||||
/**
|
||||
* The name macOS shows in the application menu, and in its "Hide" and
|
||||
* "Quit" items. Defaults to `title`.
|
||||
*/
|
||||
appName?: string
|
||||
width?: number
|
||||
height?: number
|
||||
minWidth?: number
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! The macOS application menu bar.
|
||||
//!
|
||||
//! GPUI never installs a main menu on its own, so `NSApp.mainMenu` stays nil
|
||||
//! and macOS paints an empty menu bar. Worse, the standard key equivalents that
|
||||
//! AppKit only provides through menu items are missing too, so a GPUIX app
|
||||
//! cannot be quit with cmd-q, hidden with cmd-h, or minimized with cmd-m.
|
||||
//!
|
||||
//! `gpui::App::set_menus` reads the key equivalent for each item out of the
|
||||
//! keymap, so [`init`] must bind the keys before it sets the menus.
|
||||
//!
|
||||
//! There is deliberately no Edit menu. A menu key equivalent is consumed by
|
||||
//! AppKit before the window sees the key event, so an Edit menu carrying cmd-c
|
||||
//! would take the keystroke away from the window listener that
|
||||
//! `crate::text::paint` installs for text selection, and from the per-focus
|
||||
//! clipboard handling in `custom_elements::input`.
|
||||
|
||||
use gpui::{App, Menu, MenuItem, SystemMenuType};
|
||||
|
||||
gpui::actions!(
|
||||
gpuix_app,
|
||||
[
|
||||
/// Quit the application.
|
||||
Quit,
|
||||
/// Hide the application.
|
||||
Hide,
|
||||
/// Hide every other application.
|
||||
HideOthers,
|
||||
/// Unhide every other application.
|
||||
ShowAll,
|
||||
/// Minimize the focused window to the Dock.
|
||||
MinimizeWindow,
|
||||
/// Toggle the focused window between its standard and zoomed size.
|
||||
ZoomWindow,
|
||||
/// Close the focused window.
|
||||
CloseWindow,
|
||||
]
|
||||
);
|
||||
|
||||
/// Binds the standard macOS shortcuts, registers the app-level handlers, and
|
||||
/// installs the menu bar. The window-level actions (`MinimizeWindow`,
|
||||
/// `ZoomWindow`, `CloseWindow`) are handled by the root element in
|
||||
/// `GpuixView::render`, which is the only place a `Window` exists.
|
||||
pub(crate) fn init(app_name: &str, cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
gpui::KeyBinding::new("cmd-q", Quit, None),
|
||||
gpui::KeyBinding::new("cmd-h", Hide, None),
|
||||
gpui::KeyBinding::new("cmd-alt-h", HideOthers, None),
|
||||
gpui::KeyBinding::new("cmd-m", MinimizeWindow, None),
|
||||
gpui::KeyBinding::new("cmd-w", CloseWindow, None),
|
||||
]);
|
||||
|
||||
cx.on_action(|_: &Quit, cx: &mut App| cx.quit());
|
||||
cx.on_action(|_: &Hide, cx: &mut App| cx.hide());
|
||||
cx.on_action(|_: &HideOthers, cx: &mut App| cx.hide_other_apps());
|
||||
cx.on_action(|_: &ShowAll, cx: &mut App| cx.unhide_other_apps());
|
||||
|
||||
cx.set_menus(default_menus(app_name));
|
||||
}
|
||||
|
||||
/// The App menu plus the Window menu, the minimum a macOS app is expected to
|
||||
/// have. `create_menu_bar` gives the menu named `Window` to
|
||||
/// `NSApplication.setWindowsMenu:`, which is what appends the window list.
|
||||
pub(crate) fn default_menus(app_name: &str) -> Vec<Menu> {
|
||||
vec![
|
||||
Menu {
|
||||
name: app_name.to_string().into(),
|
||||
disabled: false,
|
||||
items: vec![
|
||||
MenuItem::os_submenu("Services", SystemMenuType::Services),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(format!("Hide {app_name}"), Hide),
|
||||
MenuItem::action("Hide Others", HideOthers),
|
||||
MenuItem::action("Show All", ShowAll),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(format!("Quit {app_name}"), Quit),
|
||||
],
|
||||
},
|
||||
// No "Enter Full Screen" item: AppKit prepends its own window-tiling
|
||||
// items, that one included, to whichever menu is given to
|
||||
// `setWindowsMenu:`. Adding ours produced two entries sharing a
|
||||
// shortcut.
|
||||
Menu {
|
||||
name: "Window".into(),
|
||||
disabled: false,
|
||||
items: vec![
|
||||
MenuItem::action("Minimize", MinimizeWindow),
|
||||
MenuItem::action("Zoom", ZoomWindow),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Close Window", CloseWindow),
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item_names(menu: &Menu) -> Vec<String> {
|
||||
menu.items
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
MenuItem::Separator => "-".to_string(),
|
||||
MenuItem::Submenu(submenu) => submenu.name.to_string(),
|
||||
MenuItem::SystemMenu(os_menu) => os_menu.name.to_string(),
|
||||
MenuItem::Action { name, .. } => name.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_menu_is_named_after_the_app() {
|
||||
let menus = default_menus("Chat");
|
||||
assert_eq!(menus[0].name.as_ref(), "Chat");
|
||||
assert_eq!(
|
||||
item_names(&menus[0]),
|
||||
vec![
|
||||
"Services",
|
||||
"-",
|
||||
"Hide Chat",
|
||||
"Hide Others",
|
||||
"Show All",
|
||||
"-",
|
||||
"Quit Chat",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// `create_menu_bar` only calls `setWindowsMenu:` for this exact name.
|
||||
#[test]
|
||||
fn window_menu_keeps_the_name_appkit_looks_for() {
|
||||
let menus = default_menus("Chat");
|
||||
assert_eq!(menus[1].name.as_ref(), "Window");
|
||||
assert_eq!(
|
||||
item_names(&menus[1]),
|
||||
vec!["Minimize", "Zoom", "-", "Close Window"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
#![deny(clippy::all)]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod app_menu;
|
||||
mod automation;
|
||||
mod color;
|
||||
mod custom_elements;
|
||||
|
||||
@@ -51,6 +51,23 @@ pub(crate) fn init_key_bindings(cx: &mut gpui::App) {
|
||||
]);
|
||||
}
|
||||
|
||||
/// The Window menu items act on the focused window, and the root element is the
|
||||
/// only place in GPUIX that has one. `crate::app_menu` owns everything else.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn with_window_menu_actions(root: gpui::Div) -> gpui::Div {
|
||||
use crate::app_menu::{CloseWindow, MinimizeWindow, ZoomWindow};
|
||||
use gpui::prelude::*;
|
||||
|
||||
root.on_action(|_: &MinimizeWindow, window, _cx| window.minimize_window())
|
||||
.on_action(|_: &ZoomWindow, window, _cx| window.zoom_window())
|
||||
.on_action(|_: &CloseWindow, window, _cx| window.remove_window())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn with_window_menu_actions(root: gpui::Div) -> gpui::Div {
|
||||
root
|
||||
}
|
||||
|
||||
/// Parse a CSS font-weight value (string or number) into a GPUI FontWeight.
|
||||
/// Accepts named keywords ("bold", "semibold"), numeric strings ("700"),
|
||||
/// and raw numbers (700). Falls back to 400 (normal) for unrecognized values.
|
||||
@@ -747,6 +764,7 @@ impl GpuixRenderer {
|
||||
let width = options.width.unwrap_or(800.0);
|
||||
let height = options.height.unwrap_or(600.0);
|
||||
let title = options.title.clone().unwrap_or_else(|| "GPUIX".to_string());
|
||||
let app_name = options.app_name.clone().unwrap_or_else(|| title.clone());
|
||||
let window_options = options.clone();
|
||||
|
||||
let platform = Rc::new(gpui_macos::MacPlatform::new_embedded());
|
||||
@@ -766,6 +784,9 @@ impl GpuixRenderer {
|
||||
let app_handle = app.run_embedded(move |cx: &mut gpui::App| {
|
||||
init_key_bindings(cx);
|
||||
crate::custom_elements::input::init(cx);
|
||||
// After the other bindings: `set_menus` reads key equivalents out of
|
||||
// the keymap, so every binding must exist before it runs.
|
||||
crate::app_menu::init(&app_name, cx);
|
||||
let bounds = gpui::Bounds::centered(
|
||||
None,
|
||||
gpui::size(gpui::px(width as f32), gpui::px(height as f32)),
|
||||
@@ -3414,10 +3435,11 @@ impl gpui::Render for GpuixView {
|
||||
// longer on screen.
|
||||
let result = {
|
||||
use gpui::prelude::*;
|
||||
gpui::div()
|
||||
let root = gpui::div()
|
||||
.size_full()
|
||||
.on_action(|_: &FocusNext, window, cx| window.focus_next(cx))
|
||||
.on_action(|_: &FocusPrevious, window, cx| window.focus_prev(cx))
|
||||
.on_action(|_: &FocusPrevious, window, cx| window.focus_prev(cx));
|
||||
with_window_menu_actions(root)
|
||||
.child(selection_frame_reset(self.selection.clone()))
|
||||
.child(crate::automation::bounds_frame_reset())
|
||||
.child(result)
|
||||
@@ -5041,6 +5063,9 @@ pub struct DebugFrameOverlayStats {
|
||||
#[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), napi(object))]
|
||||
pub struct WindowOptions {
|
||||
pub title: Option<String>,
|
||||
/// The name macOS shows in the application menu, and in its "Hide" and
|
||||
/// "Quit" items. Defaults to `title`.
|
||||
pub app_name: Option<String>,
|
||||
pub width: Option<f64>,
|
||||
pub height: Option<f64>,
|
||||
pub min_width: Option<f64>,
|
||||
@@ -5062,6 +5087,7 @@ impl Default for WindowOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: Some("GPUIX".to_string()),
|
||||
app_name: None,
|
||||
width: Some(800.0),
|
||||
height: Some(600.0),
|
||||
min_width: None,
|
||||
|
||||
Reference in new issue
Block a user