Electron Adventures: Episode 33: Event Routing
Tomasz Wegrzanowski
Posted on August 28, 2021
Most web apps have fairly straightforward event system - you click on something, or you focus on some field, then type some stuff into it. That event either affects just the component, or component might send it to its parent.
Unfortunately that's not good enough for our file manager.
File Manager Events
Events can come from multiple sources, and affect multiple components, with dynamic mapping between event type and target. If user wanted to create a new directory, there are so many ways:
- press F7 (or some other shortcut key, if that got changed by the user)
- click "F7 Mkdir" button in the footer
- open command palette, then select "New Folder" from the list
- choose "File > New Folder" from application menu - on Windows it's on top of the window, on OSX on top of the screen
Then whichever way this event triggers, it needs to go to correct active panel. And we should probably ignore such event if some dialog is already open.
So there's a lot of logic, and it would be a huge mess if we smooshed it all over the codebase. There should be some central place where most events are sent, and which then decides what to do with those events.
That doesn't mean we couldn't also have local events - for example clicking a button, or typing something into a field can be managed by a single component just fine.
We're going to to use Svelte stores, Svelte context, and simple EventBus
class to manage all that.
Simple Event Routing App
We'll integrate it into our file manager app, but it's easier to experiment on something smaller first.
So here's the app:
- there are 4 boxes
- keys 1-4 switch between boxes
- letters a-z or A-Z type into the selected box
- backspace deletes the last character in selected box
- to avoid any complications with modifier keys, I'll use F1, F2, and F3 as cut/copy/paste text in current box - it has nothing to do with operating system clipboard, it's just an internal thing
- F10 quits the app
- and for good measure clicking on each box selects it
- and all that is also available in the footer as clickable buttons
We'll add application menu and command palette to the app later, but it's a lot already.
src/EventBus.js
Well, first event bus. It's a very simple Javascript object. You create an instance, then register event handlers with it.
There's emit
method, which takes named event target, event name, and any number of arguments. It also handles *
special event handler, for handling any events that don't have a specific handler.
Right now it will quietly drop any events without specific handler or appropriate target, but maybe we should console.log
a warning about this? It depends on the use case.
export default class EventBus {
constructor() {
this.callbacks = {}
}
handle(target, map) {
this.callbacks[target] = { ...(this.callbacks[target] || {}), ...map }
}
emit(target, event, ...details) {
let handlers = this.callbacks[target]
if (handlers) {
if (handlers[event]) {
handlers[event](...details)
} else if (handlers["*"]) {
handlers["*"](event, ...details)
}
}
}
}
Nothing about it is specific to Electron or Svelte, it's just very simple pattern.
src/App.svelte
template
First, let's get the template and styling as there's nothing fancy here:
<div class="app">
<Box id="box-1" />
<Box id="box-2" />
<Box id="box-3" />
<Box id="box-4" />
<Footer />
</div>
<Keyboard />
<style>
:global(body) {
margin: 0;
}
.app {
background-color: hsl(180,100%,20%);
font-family: monospace;
color: #333;
height: 100vh;
width: 100vw;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr auto;
gap: 10px;
}
</style>
It's a simple grid with 4 boxes and footer. id
does not have anything to do with HTML DOM id
, it actually lets each box identify itself to the event system.
Keyboard
is a bit ununsual component that doesn't generate any DOM - it attaches some event handlers to the main window.
src/App.svelte
script
Now let's get to the juicy part:
<script>
import { writable } from "svelte/store"
import { setContext } from "svelte"
import Box from "./Box.svelte"
import Footer from "./Footer.svelte"
import Keyboard from "./Keyboard.svelte"
import EventBus from "./EventBus.js"
let activeBox = writable("box-1")
let clipboard = writable("")
let eventBus = new EventBus()
setContext("app", {activeBox, clipboard, eventBus})
</script>
We create two Svelte stores here - activeBox
showing which box is currently active, and clipboard
with contents of the clipboard. We also create EventBus
instance, where we can register event handlers.
Contexts and Stores
Then we save all of them into a single context object under key app
. We could alternatively use 3 separate contexts:
setContext("activeBox", activeBox)
setContext("clipboard", clipboard)
setContext("eventBus", eventBus)
It doesn't really make any difference since we're setting them from the same place, but if we had more complicated app, multiple contexts could be necessary.
Why do we put a store in a context, not just value? Contexts are read when component is created, and aren't automatically updated. So this would not really work:
let activeBox = "box-1"
let clipboard = ""
setContext("app", {activeBox, clipboard, eventBus})
This could work:
let activeBox = "box-1"
let activeBoxSubscriptions = []
function changeActiveBox(newValue) {
activeBox = newValue
for (let callback of activeBoxSubscriptions) {
callback(newValue)
}
}
function subscribeToActiveBoxChanges(callback) {
activeBoxSubscriptions.push(callback)
}
setContext("app", { activeBox, subscribeToActiveBoxChanges, ... })
As long as we remember to only change activeBox
through changeActiveBox
. Well, we'd also need to add some mechanism for unsubscribing when component is destroyed.
This kind of subscribing, unsubscribing, callbacks to change values and so on is extremely tedious, so Svelte has stores as shortcut.
If you ever use $activeBox
anywhere in your component, Svelte will automatically try to subscribe to activeBox
store, and update $activeBox
variable for you through such callback. It will also unsubscribe when needed.
This variable is properly reactive, so any changes will automatically apply to template, or to any reactive statements you do.
It should become clearer as we go through a few examples of contexts, stores, and EventBus
usage in various components.
src/App.svelte
event handlers
Application has two event handlers - quit
(F10) closes the window, and changeBox
changes which box is active.
activeBox.set(id)
updates the store, which then runs callbacks in all subscribers (including App
component itself, there's nothing special about it), setting $activeBox
in all of them.
function quit() {
window.close()
}
function changeBox(id) {
activeBox.set(id)
}
eventBus.handle("app", {quit, changeBox})
There's also one more thing to do - we register a wildcard callback for virtual target "activeBox"
, which we then resend to whichever box is actually active right now.
function emitToActiveBox(...args) {
eventBus.emit($activeBox, ...args)
}
eventBus.handle("activeBox", {"*": emitToActiveBox})
src/Footer.svelte
Well, that was a lot. Fortunately the rest of the app is fairly simple. Here's the footer:
<script>
import { getContext } from "svelte"
let { eventBus } = getContext("app")
</script>
<footer>
<button on:click={() => eventBus.emit("app", "changeBox", "box-1")}>Box 1</button>
<button on:click={() => eventBus.emit("app", "changeBox", "box-2")}>Box 2</button>
<button on:click={() => eventBus.emit("app", "changeBox", "box-3")}>Box 3</button>
<button on:click={() => eventBus.emit("app", "changeBox", "box-4")}>Box 4</button>
<button on:click={() => eventBus.emit("activeBox", "cut")}>F1 Cut</button>
<button on:click={() => eventBus.emit("activeBox", "copy")}>F2 Copy</button>
<button on:click={() => eventBus.emit("activeBox", "paste")}>F3 Paste</button>
<button on:click={() => eventBus.emit("app", "quit")}>F10 Quit</button>
</footer>
<style>
footer {
grid-column-start: span 2;
text-align: center;
}
button {
font-size: 24px;
font-weight: bold;
color: inherit;
background-color: hsl(180,100%,40%);
font-family: inherit;
}
</style>
All it does is get eventBus
instance from the context, then when you click on various buttons it calls eventBus.emit(target, event, arguments)
.
How it gets delivered to either app
itself or to the right box is not the footer's business.
src/Keyboard.svelte
<script>
import { getContext } from "svelte"
let { eventBus } = getContext("app")
function handleKey({key}) {
if (key.match(/^[1234]$/)) {
eventBus.emit("app", "changeBox", `box-${key}`)
}
if (key.match(/^[a-zA-Z]$/)) {
eventBus.emit("activeBox", "letter", key)
}
if (key === "Backspace") {
eventBus.emit("activeBox", "backspace", key)
}
if (key === "F1") {
eventBus.emit("activeBox", "cut")
}
if (key === "F2") {
eventBus.emit("activeBox", "copy")
}
if (key === "F3") {
eventBus.emit("activeBox", "paste")
}
if (key === "F10") {
eventBus.emit("activeBox", "quit")
}
}
</script>
<svelte:window on:keydown={handleKey} />
Keyboard is a another pure event source component. It might be a bit unusual in that it doesn't actually add anything to the DOM, it attaches itself to the main window
.
And again, it gets eventBus
from the context, handles keydown
events, and depending on which key was pressed, emits the right event to the right target.
As you can imagine, this component could be extended to handle modifier keys (like Cmd-C or Ctrl-C - this would probably need some platform-specific logic as conventions are different), and even read shortcut preferences from some local configuration, so user can change them. Maybe even to vim keybindings, who knows. All in one place.
src/Box.svelte
With so much logic being elsewhere, the Box
component is fairly simple. First, the template and styling:
<div class="box" class:active on:click={onClick}>
{text}
</div>
<style>
.box {
font-size: 48px;
font-weight: bold;
background-color: hsl(180,100%,30%);
display: flex;
justify-content: center;
align-items: center;
}
.box.active {
background-color: hsl(180,100%,40%);
}
</style>
Nothing unusual here. We have a box, displaying text
, it has active
class if active
variable is true, and clicking on it will call onClick
method.
<script>
import { getContext } from "svelte"
let { eventBus, activeBox, clipboard } = getContext("app")
export let id
let text = "A"
function onClick() {
eventBus.emit("app", "changeBox", id)
}
function letter(key) {
text += key
}
function backspace() {
text = text.slice(0, -1)
}
function cut() {
clipboard.set(text)
text = ""
}
function copy() {
clipboard.set(text)
}
function paste() {
text = $clipboard
}
eventBus.handle(id, {letter, backspace, cut, copy, paste})
$: active = ($activeBox === id)
</script>
We register long list of events with the eventBus
instance. Event handlers are super simple here.
There's small trick here that active
flag changes reactively whenever activeBox
changes. All the subscriptions, and callback, and such, are handled by Svelte without us having to do anything.
Result
Here's the results:
I think it's a fairly clean architecture, code is very concise (unlike with something like let's say Redux), and it's easy to extend it to handle more complex cases.
Svelte stores and contexts are standard part of Svelte, but EventBus
is just something I created for this app.
Would you design it in a different way? If so, let me know of alternative approaches in the comments.
In the next episode we'll add application menu.
As usual, all the code for the episode is here.
Posted on August 28, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.