Electron Adventures: Episode 34: Application Menu
Tags: javascript, electron, svelte
Published: 2021-08-28 02:45:47

In previous episode we implemented a toy app with a number of commands. Wouldn't it be nice if those commands were available in menu bar as well?

Well, this runs into a lot more trouble than you'd expect:

* operating systems (OSX vs everything else) have drastically different conventions for application menu, so to do things properly, we'd basically need to do things at least twice
* in Electron, menu is responsibility of the backend, not the frontend! This means we'll need to send messages back and forth between the two for all menu interactions
* if you want menu to update dynamically based on state of the frontend, you'll need to keep sending updates about it to the backend every time we want to change something
* there's no way to add to the menu - if we call `Menu.setApplicationMenu` it wipes out the whole default menu with helpful operations such as Quit, Copy, Paste, Reload, Developer Tools, etc.
* `Menu.getApplicationMenu` does not return default menu we could modify, it will be `null` if we didn't set it - there really is no way to get the default menu to just add our stuff, we have to replace the whole damn thing! This is embarassing, and Electron really should get its shit together. Yeah, eventually you'll need to replace the whole thing, but it makes development miserable at this point.
* on OSX not having them in the menu means keyboard shortcuts like Cmd-C or Cmd-Q no longer work! This is not how other operating systems work, but if we want to run on OSX, we need to play nice here, and Electron does not help - we can't just ignore the issue

So a huge headache.

On the plus side, once you go through that trouble, you could just put all the application commands in the menu, and let it handle all the keyboard shortcut logic. You can even add invisible menu entries with their active application shortcuts to have shortcuts while keeping menu small, but to be honest handling keyboard shortcuts from Javascript isn't exactly rocket science, so we won't be doing this.

### Create menu

[I had to dig the default menu out of Electron source code](https://github.com/electron/electron/blob/main/lib/browser/default-menu.ts) and copy paste it. There's even npm package with basically that, but it's an older version.

As menu will be completely static, and all we're going to do is set it once. If we had to modify it depending on application state, this code would need to do a lot more.

Here's `main/menu.js`:

```javascript
let { Menu } = require("electron")

let isMac = process.platform === "darwin"
let defaultMenuTemplate = [
  ...(isMac ? [{ role: "appMenu" }] : []),
  { role: "fileMenu" },
  { role: "editMenu" },
  { role: "viewMenu" },
  { role: "windowMenu" },
]

let extraMenuTemplate = [
  {
    label: "Box",
    submenu: [
      {
        label: "Box 1",
        click: (item, window) => window.webContents.send("menuevent", "app", "changeBox", "box-1"),
      },
      {
        label: "Box 2",
        click: (item, window) => window.webContents.send("menuevent", "app", "changeBox", "box-2"),
      },
      {
        label: "Box 3",
        click: (item, window) => window.webContents.send("menuevent", "app", "changeBox", "box-3"),
      },
      {
        label: "Box 4",
        click: (item, window) => window.webContents.send("menuevent", "app", "changeBox", "box-4"),
      },
    ],
  },
  {
    label: "BoxEdit",
    submenu: [
      {
        label: "Cut",
        click: (item, window) => window.webContents.send("menuevent", "activeBox", "cut"),
      },
      {
        label: "Copy",
        click: (item, window) => window.webContents.send("menuevent", "activeBox", "copy"),
      },
      {
        label: "Paste",
        click: (item, window) => window.webContents.send("menuevent", "activeBox", "paste"),
      },
    ],
  },
]

let menu = Menu.buildFromTemplate([
  ...defaultMenuTemplate,
  ...extraMenuTemplate ,
])

module.exports = {menu}
```

Does it look like those events are going straight to event bus? Yes it does!

### `index.js`

```javascript
let { app, BrowserWindow, Menu } = require("electron")
let { menu } = require("./main/menu")

function createWindow() {
  let win = new BrowserWindow({
    webPreferences: {
      preload: `${__dirname}/preload.js`,
    },
  })
  win.maximize()
  win.loadURL("http://localhost:5000/")
}

Menu.setApplicationMenu(menu)

app.on("ready", createWindow)

app.on("window-all-closed", () => {
  app.quit()
})
```

Me only needed to modify three things:

* import our new static `menu` from `main/menu.js`
* import `Menu` from `electron`
* set it with `Menu.setApplicationMenu(menu)`

### `preload.js`

We'll have to bounce the event around a bit before we can deliver it to its destination. So first, preload needs to setup the event handler and expose it to the frontend:

```javascript
let { contextBridge, ipcRenderer } = require("electron")

let onMenuEvent = (callback) => {
  ipcRenderer.on("menuevent", callback)
}

contextBridge.exposeInMainWorld(
  "api", { onMenuEvent }
)
```

It's all very simple as we have just one handler for all menu events, but if we did anything complicated or dynamic, we'd need some more code here, something along the lines of:

```javascript
contextBridge.exposeInMainWorld(
  "api", { onMenuEvent, setMenu }
)
```

### `src/App.svelte`

Just like `Keyboard` logic lived in its own component, so will `AppMenu`. The `App` just needs to add it to component tree, the rest of the file is like before:

```html
<script>
  import AppMenu from "./AppMenu.svelte"
</script>

<div class="app">
  <Box id="box-1" />
  <Box id="box-2" />
  <Box id="box-3" />
  <Box id="box-4" />
  <Footer />
</div>

<Keyboard />
<AppMenu />
```

### `src/AppMenu.svelte`

And finally, we need to tell the preload that we're interested in `menuevent`, and then whatever we receive, we send straight to the `eventBus` without any further processing:

```html
<script>
  import { onMount, getContext } from "svelte"
  let { eventBus } = getContext("app")

  function handleMenuEvent(event, ...args) {
    eventBus.emit(...args)
  }

  onMount(() => {
    window.api.onMenuEvent(handleMenuEvent)
  })
</script>
```

Depending on app, you might need to also add some cleanup steps for when component is unmounted. We won't be doing it here.

That was a lot of work, but for small menus with static functionality, this is finally ready!

### Result

Here's the results:

![Episode 34 Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hdtly7wufq6jguvrcmem.png)

In the next episode we'll add the best UI innovation of last decade - command palette.

As usual, [all the code for the episode is here](https://github.com/taw/electron-adventures/tree/master/episode-34-application-menu/).