Skip to content

Application menu ​

The page knows what has the focus, so the page builds the menu: a MenuSpec with a tree of MenuNodes, sent over IPC whenever the focus changes. The shell draws it natively and sends a picked command back by its id. Every label comes from the page, so the whole menu is in the language of the interface.

The shape ​

ts
import type { MenuNode, MenuSpec } from '@basmilius/desktop-shell/bridge';

type ShellAction = 'devtools';

const spec: MenuSpec<ShellAction> = {
    menus: [
        {
            id: 'file',
            label: 'File',
            items: [
                { kind: 'command', id: 'project.new', label: 'New project', accelerator: 'CommandOrControl+N' },
                { kind: 'separator' },
                { kind: 'role', role: 'quit', label: 'Quit' }
            ]
        }
    ]
};
KindWhat the shell draws
commandAn item that sends its id back. checked makes it a check box, radio one of a group.
roleA native role from MENU_ROLES, with the page's label.
shellSomething only the shell can carry out. The type parameter lists an app's own actions.
separatorA line.
submenuA nested menu.

Drawing it ​

menuTemplateOf turns a spec into a template for Menu.buildFromTemplate, or returns null for anything that is no spec. shellItem draws the app's own actions; return null to leave one out.

ts
import { createMenuCommands, createPageKeys, devToolsAccelerator, menuTemplateOf } from '@basmilius/desktop-shell';

const pageKeys = createPageKeys();
const runCommand = createMenuCommands({ pageKeys, window: () => BrowserWindow.getFocusedWindow() });

window.webContents.on('before-input-event', (_event, input) => pageKeys.saw(input));

onFromApp('menu:set', (_event, spec: MenuSpec<ShellAction>) => {
    const template = menuTemplateOf(spec, {
        run: runCommand,
        shellItem: (action, label) =>
            action === 'devtools' ? { label, accelerator: devToolsAccelerator(), click: () => window.webContents.toggleDevTools() } : null
    });
    if (template) {
        Menu.setApplicationMenu(Menu.buildFromTemplate(template));
    }
});

Off macOS an accelerator is only shown, never registered: a registered one would take a key such as Ctrl+W from the page before the page saw it.

A key the page already had ​

On macOS the menu fires its accelerator even when the page answered the key itself. createMenuCommands drops a command Electron says its accelerator fired when the page had a key with Cmd or Ctrl in the last PAGE_KEY_MS, as createPageKeys saw it. A pick with the mouse, through accessibility or from inside the open menu always runs.

keyBypassesPage covers a page inside the app that never hands a key on, such as a <webview>: while it has the keyboard, the menu answers every key. A command reaches the page as menu:run with its id. Only a minimized window is shown first, so a pick from behind other work leaves the app where it is.

The menu before the page's ​

staticMenuTemplate is the menu that stands until the page sends its own, and again after a reload or a crash, so Quit is always there. Its View menu has no reload and no zoom roles: their accelerators would be taken before the page sees them.

ts
import { staticMenuTemplate } from '@basmilius/desktop-shell';

Menu.setApplicationMenu(
    Menu.buildFromTemplate(
        staticMenuTemplate({
            appName: app.name,
            toggleDevTools: () => window.webContents.toggleDevTools(),
            appItems: [{ label: `About ${app.name}…`, click: openAbout }],
            quitItems: []
        })
    )
);

appItems go at the top of the app menu on macOS; quitItems go right above Quit, in a File menu off macOS.