Electron Adventures: Episode 33: Event Routing

taw

Tomasz Wegrzanowski

Posted on August 28, 2021

Electron Adventures: Episode 33: Event Routing

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

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

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

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

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

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

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

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

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

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

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

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

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:

Episode 33 Screenshot

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.

💖 💪 🙅 🚩
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