Electron Adventures: Episode 34: Application Menu

taw

Tomasz Wegrzanowski

Posted on August 28, 2021

Electron Adventures: Episode 34: Application Menu

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 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:

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}
Enter fullscreen mode Exit fullscreen mode

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

index.js

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()
})
Enter fullscreen mode Exit fullscreen mode

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:

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

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

contextBridge.exposeInMainWorld(
  "api", { onMenuEvent }
)
Enter fullscreen mode Exit fullscreen mode

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:

contextBridge.exposeInMainWorld(
  "api", { onMenuEvent, setMenu }
)
Enter fullscreen mode Exit fullscreen mode

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:

<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 />
Enter fullscreen mode Exit fullscreen mode

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:

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

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

  onMount(() => {
    window.api.onMenuEvent(handleMenuEvent)
  })
</script>
Enter fullscreen mode Exit fullscreen mode

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

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.

💖 💪 🙅 🚩
taw
Tomasz Wegrzanowski

Posted on August 28, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related