Create application menus with Vue templates in Electron
For the last few months, I've been working on an app called Serve. It's an Electron app that makes it easy to set up local development environments for Laravel.
In the latest release, I wanted to revamp the application menu. But I ran into some limitations with the existing Electron API, so I set out on a mission to figure out how to define the menu in a Vue component.
The main and renderer context
If you are not familiar with Electron apps, I'll quickly run through the main architecture concepts.
An Electron app has two processes: The main process and the renderer process. The main process is a node environment and has access to the filesystem. The renderer process is a browser environment and is responsible for handling the UI of the application.
The processes can communicate with each other through what's called 'Inter-Process Communication' (IPC). IPC is essentially an event system that works across the processes.
Electron's menu API.
The existing API for creating application menus work in the main process. It involves building a template of JSON objects that represent submenus and menu items.
import { Menu } from 'electron'
Menu.setApplicationMenu(
Menu.buildFromTemplate(
{
label: 'File',
submenu: [
{
label: 'New project',
accelerator: 'CmdOrCtrl+n',
click: () => console.log('New project')
},
{
label: 'Import project',
accelerator: 'CmdOrCtrl+i',
click: () => console.log('Import project')
}
]
}
)
)
The example above creates a 'File' submenu with two menu items.
Problems with the existing API
I found a couple of limitations with the existing API. First of all, it becomes quite a messy JSON tree when building out the entire menu structure. This JSON object is hard to read and understand easily.
Secondly, Serve's renderer process is running a Vue application. But when the menu is defined in the main process, I can't call a method like `createProject' in the example above because that would be an action in the Vuex store.
Lastly, I wanted to update the application menu based on where the user is. If the user has navigated into a project in the app, I want project-specific menu items like 'Start project' to be enabled. But if the user is not inside a project in the app, I want to disable those menu items. In other words, I was looking for a reactive menu.
Defining the API I wish I could use
At this point, I decided to experiment with an alternative syntax. Ideally, I wanted to define the menu structure with Vue components instead of JSON objects. Here is the same menu as above using the syntax I wanted to use.
<template>
<Menu>
<Submenu label="File">
<MenuItem
accelerator="CmdOrCtrl+n"
@click="() => console.log('New project')"
>
New project
</MenuItem>
<MenuItem
accelerator="CmdOrCtrl+I"
@click="() => console.log('Import project')"
>
Import project
</MenuItem>
</Submenu>
</Menu>
</template>
This syntax solves all the limitations I found. It's easier to scan and update the menu structure. It's defined in a Vue component, so it's automatically reactive. And since it's a Vue component, it lives in the renderer process and thus has access to the Vue context.
Implementing the new API
At this point, I had to try and implement the new syntax I had defined.
The first step was figuring out how to tell the main process that the renderer process defines the menu.
I created a registerMenu
method and called it in the main process.
const registerMenu = () => {
ipcMain.on('menu', (__, template = []) => {
Menu.setApplicationMenu(
Menu.buildFromTemplate(templateWithListeners)
)
})
}
It defines a listener on the IPC channel 'menu'. It receives the template for the menu as a parameter in the callback. Lastly, it builds the application menu from the given template.
In the renderer process, I created three Vue components: Menu, Submenu, and MenuItem.
The Menu component
The Menu component is responsible for controlling the state of the menu template and sending it over to the main process when it updates.
`
import { Fragment } from 'vue-fragment'
import EventBus from '@/menu/EventBus'
export default {
components: {
Fragment,
},
data() {
return {
template: {},
}
},
mounted() {
EventBus.$on('update-submenu', template => {
this.template = {
...this.template,
[template.id]: template,
}
})
},
watch: {
template: {
immediate: true,
deep: true,
handler() {
window.ipc.send('menu', Object.values(this.template))
},
},
},
render(createElement) {
return createElement(
Fragment,
this.$scopedSlots.default(),
)
},
}
`
The component doesn't render any UI, but it returns the children of the component to execute them in the render method.
The two most interesting things to look at is the 'template' watcher and the EventBus. The EventBus communicates between the Menu component and the Submenu components nested inside it. I didn't want to manually pass all events from the Submenu components up to the Menu components as that would clutter the API.
The EventBus listen for events from the Submenu components. The submenu emits an event with the template for that submenu. In the Menu component, I update the state of the entire template.
The 'template' watcher is responsible for sending the entire template tree to the main process when the template updates.
The Submenu component
The Submenu component is responsible for controlling all the menu items inside it and sending the state up to the Menu component when it updates.
`
import { v4 as uuid } from 'uuid'
import { Fragment } from 'vue-fragment'
import EventBus from '@/menu/EventBus'
export default {
components: {
Fragment,
},
props: {
label: String,
role: {
type: String,
validator: role =>
[
'appMenu',
'fileMenu',
'editMenu',
'viewMenu',
'windowMenu',
].includes(role),
},
},
data() {
return {
id: uuid(),
submenu: {},
}
},
computed: {
template() {
if (this.role) {
return {
id: this.id,
role: this.role,
}
}
return {
id: this.id,
label: this.label,
submenu: Object.values(this.submenu),
}
},
},
mounted() {
EventBus.$on('update-menuitem', template => {
if (template.parentId !== this.id) {
return
}
this.submenu = {
...this.submenu,
[template.id]: template,
}
})
},
watch: {
template: {
immediate: true,
deep: true,
handler() {
this.$nextTick(() => {
EventBus.$emit('update-submenu', this.template)
})
},
},
},
render(createElement) {
return createElement(
Fragment,
this.$scopedSlots.default(),
)
},
}
`
As with the Menu component, it doesn't render any UI, but the render method still needs to return all its children to execute the code in the MenuItem components.
The component uses the EventBus to communicate with both the Menu component and the MenuItem components. It listens for updates in MenuItem components.
Since the EventBus sends events to all Submenu components, it needs a unique id to control whether the menu item that emits the event is inside this specific submenu. Otherwise, all the submenus would contain all the menu items.
The MenuItem component
The MenuItem component is responsible for controlling the state of a single menu item object and emit it up the tree when it updates.
`
import { v4 as uuid } from 'uuid'
import EventBus from '@/menu/EventBus'
export default {
props: {
role: {
type: String,
validator: role =>
[
'undo',
'redo',
'cut',
'copy',
'paste',
// ...
].includes(role),
},
type: {
type: String,
default: 'normal',
},
sublabel: String,
toolTip: String,
accelerator: String,
visible: {
type: Boolean,
default: true,
},
enabled: {
type: Boolean,
default: true,
},
checked: {
type: Boolean,
default: false,
},
},
data() {
return {
id: uuid(),
}
},
computed: {
template() {
return {
id: this.id,
role: this.role,
type: this.type,
sublabel: this.sublabel,
toolTip: this.toolTip,
accelerator: this.accelerator,
visible: this.visible,
enabled: this.enabled,
checked: this.checked,
label: return this.$scopedSlots.default()[0].text.trim(),
}
},
},
watch: {
template: {
immediate: true,
handler() {
EventBus.$emit('update-menuitem', {
...JSON.parse(JSON.stringify(this.template)),
click: () => this.$emit('click'),
parentId: this.$parent.template.id,
})
},
},
},
render() {
return null
},
}
`
The MenuItem doesn't render any UI either. Therefore it can simply return null.
The component receives many props that correspond to the options you can give a menu item in the existing api.
An example I used earlier is the enabled
prop that can control whether the menu item is active.
When the template is updated, it emits an event to all the Submenu components with the template and parent id.
Putting it all together
With all the individual pieces created, it was time to put it all together. I made an AppMenu component and included it in App.vue
.
<template>
<Menu>
<Submenu label="File">
<MenuItem
accelerator="CmdOrCtrl+n"
@click="() => console.log('New project')"
>
New project
</MenuItem>
<MenuItem
accelerator="CmdOrCtrl+I"
@click="() => console.log('Import project')"
>
Import project
</MenuItem>
</Submenu>
</Menu>
</template>
At this point, I discovered a pretty big issue, though. None of the click event handlers worked.
Dealing with click handlers
After some debugging, I found the issue. IPC communication is event-based, and it's not possible to include a JS function in the event object. But that's what I was doing in the template of a menu item:
{
label: 'New project',
click: () => this.$emit('click'),
// ...
}
The solution was hacky but worked. I omitted the click handler from the menu item objects. In the registerMenu
function, I attached a click handler to all menu items.
export const registerMenus = win => {
ipcMain.on('menu', (__, template = []) => {
let templateWithListeners = template.map(group => {
return {
...group,
submenu: group.submenu.map(item => {
return {
...item,
click: () =>
win.webContents.send('menu', { id: item.id }),
}
}),
}
})
Menu.setApplicationMenu(Menu.buildFromTemplate(templateWithListeners))
})
}
The click handler sends an event on the menu
IPC channel. In AppMenu, I receive the event from the main event and sends another event using the EventBus.
window.ipc.receive('menu', response => {
EventBus.$emit('clicked', response.id)
})
Lastly, in MenuItem, I can listen for the event on the EventBus and emit a click event.
`
EventBus.$on('clicked', id => {
if (id !== this.id) {
return
}
this.click()
})
`
Conclusion
The code examples in this article are simplified a bit. You can view the menu I created for Serve here and view the source code for the menu here.
All in all, I'm happy with the outcome. My menu is now easier to maintain, it's reactive, and it simplified the rest of the app because I can call Vuex actions directly from the menu.
If you are a Laravel developer, you should check out Serve. It automatically manages PHP, Node, databases, and all that kind of stuff for you. If you are not a Laravel developer, keep an eye out because Serve will support other frameworks and languages in the future.
Top comments (0)