Electron Adventures: Episode 47: Context Dependent Keyboard Handling
Tags: javascript, electron, svelte
Published: 2021-09-10 22:05:49

I wanted to add dialogs next (copy, move, mkdir, delete), but it just got back to situation where there were too many components handling keyboard shortcuts, so maybe it's best to clean this up first.

This is reality of software development. If you're developing something new, it's best to start with a very simple design, then as it gets more complex to refactor it to support the complexity.

A lot of code will follow, but these are mostly tiny changes from previous versions, so if you've been more or less following Electron Adventures along, there shouldn't be too many surprises. If you want a deeper look into any specific code, check out earlier episodes.

### `src/EventBus.js`

First tiny change is adding some `console.log`s to the `EventBus`, so I'll be told when I made a typo. Crashing application on typos is generally annoying in development, as crashed JavaScript apps tend to lose their state.

```javascript
class EventTarget {
  constructor(bus, target) {
    this.bus = bus
    this.target = target
    return new Proxy(this, {
      get: (receiver, name) => {
        return (...args) => {
          bus.emit(target, name, ...args)
        }
      }
    })
  }
}

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)
      } else {
        console.log(`Target ${target} has no handler for ${event}`)
      }
    } else {
      console.log(`Target ${target} not defined`)
    }
  }

  target(t) {
    return new EventTarget(this, t)
  }
}
```

### `src/commands.js`

Instead of just supporting shortcuts for the main mode, we now list them for each mode separately, so `Preview`, `CommandPalette` etc. don't need to do their keyboard handling.

As overlap between different modes is currently non-existent, each mode is just separate. If modes shared different shortcuts a lot, then it would make sense to have one list and `modes:` as attribute of each command.

```javascript
export default {
  default: [
    {
      shortcuts: [{key: "F2"}, {key: "P", cmd: true, shift: true}],
      action: ["app", "openPalette"]
    },
    {
      name: "Close Palette",
      shortcuts: [{key: "Escape"}],
      action: ["app", "closePalette"],
    },
    {
      name: "Enter Directory",
      shortcuts: [{key: "Enter"}],
      action: ["activePanel", "activateItem"],
    },
    {
      name: "Flip Selection",
      shortcuts: [{key: " "}],
      action: ["activePanel", "flipItem"],
    },
    {
      name: "Go to First File",
      shortcuts: [{key: "Home"}],
      action: ["activePanel", "firstItem"],
    },
    {
      name: "Go to Last File",
      shortcuts: [{key: "End"}],
      action: ["activePanel", "lastItem"],
    },
    {
      name: "Go to Next File",
      shortcuts: [{key: "ArrowDown"}, {key: "N", ctrl: true}],
      action: ["activePanel", "nextItem"],
    },
    {
      name: "Go to Previous File",
      shortcuts: [{key: "ArrowUp"}, {key: "P", ctrl: true}],
      action: ["activePanel", "previousItem"],
    },
    {
      name: "Page Down",
      shortcuts: [{key: "PageDown"}],
      action: ["activePanel", "pageDown"],
    },
    {
      name: "Page Up",
      shortcuts: [{key: "PageUp"}],
      action: ["activePanel", "pageUp"],
    },
    {
      name: "Quit",
      shortcuts: [{key: "F10"}],
      action: ["app", "quit"],
    },
    {
      name: "Switch Panel",
      shortcuts: [{key: "Tab"}],
      action: ["app", "switchPanel"],
    },
    {
      name: "View File",
      shortcuts: [{key: "F3"}],
      action: ["activePanel", "viewFocusedFile"],
    },
    {
      name: "Edit File",
      shortcuts: [{key: "F4"}],
      action: ["activePanel", "editFocusedFile"],
    },
  ],
  palette: [
    {
      shortcuts: [{key: "Escape"}],
      action: ["app", "closePalette"],
    }
  ],
  preview: [
    {
      shortcuts: [{key: "Escape"}, {key: "Q"}, {key: "F3"}, {key: "F10"}],
      action: ["app", "closePreview"],
    }
  ],
}
```

### `src/Keyboard.svelte`

`Keyboard` component gained two features. First, its `active` flag got replaced by `mode`. Second, it now supports `fakeKey` event so components like the Footer can send it fake keys, without bothering with things like `e.preventDefault()` on that fake key. To support this interface better, modifier key checks all look like `(!!shortcut.ctrl) === (!!e.ctrlKey)` so missing and `false` values are treated the same.

Crazy thing is that JavaScript has `==` loose equality checks, but somehow they don't think `false == undefined` or `false == null`. [In any case it's best to forget `==` even exists](https://i.redd.it/h7nt4keyd7oy.jpg).

```html
<script>
  export let mode

  import commands from "./commands.js"
  import { getContext } from "svelte"

  let { eventBus } = getContext("app")

  function matchingShortcut(e, shortcut) {
    return (
      (shortcut.key.toLowerCase() === e.key.toLowerCase()) &&
      ((!!shortcut.ctrl) === (!!e.ctrlKey)) &&
      ((!!shortcut.alt) === (!!e.altKey)) &&
      ((!!shortcut.shift) === (!!e.shiftKey)) &&
      ((!!shortcut.cmd) === (!!e.metaKey))
    )
  }

  function findMatch(e) {
    for (let command of commands[mode]) {
      for (let shortcut of command.shortcuts) {
        if (matchingShortcut(e, shortcut)) {
          return command.action
        }
      }
    }
  }

  function handleKey(e) {
    let action = findMatch(e)
    if (action) {
      e.preventDefault()
      e.stopPropagation()
      eventBus.emit(...action)
    }
  }

  function fakeKey(e) {
    let action = findMatch(e)
    if (action) {
      eventBus.emit(...action)
    }
  }

  eventBus.handle("keyboard", {fakeKey})
</script>

<svelte:window on:keydown={handleKey} />
```

### `src/Footer.svelte`

This lets us refactor the Footer to not know which command it needs to send to which component for which button. Pressing unsupported button like F8 will just be ignored, just like pressing F8 button on the keyboard would.

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

  let keyboard = eventBus.target("keyboard")
  function click(key) {
    keyboard.fakeKey({key})
  }
</script>

<footer>
  <button on:click|preventDefault={() => click("F1")}>F1 Help</button>
  <button on:click|preventDefault={() => click("F2")}>F2 Menu</button>
  <button on:click|preventDefault={() => click("F3")}>F3 View</button>
  <button on:click|preventDefault={() => click("F4")}>F4 Edit</button>
  <button on:click|preventDefault={() => click("F5")}>F5 Copy</button>
  <button on:click|preventDefault={() => click("F6")}>F6 Move</button>
  <button on:click|preventDefault={() => click("F7")}>F7 Mkdir</button>
  <button on:click|preventDefault={() => click("F8")}>F8 Delete</button>
  <button on:click|preventDefault={() => click("F10")}>F10 Quit</button>
</footer>

<style>
  footer {
    text-align: center;
    grid-area: footer;
  }

  button {
    font-family: inherit;
    font-size: inherit;
    background-color: #66b;
    color: inherit;
  }
</style>
```

### `src/App.svelte`

`App` component needs to bring those changes together. So first it needs to define `keyboardMode` property:

```javascript
  let paletteOpen = false
  let preview = null
  let keyboardMode

  $: {
    keyboardMode = "default"
    if (paletteOpen) keyboardMode = "palette"
    if (preview) keyboardMode = "preview"
  }
```

Reactive block statement does exactly the right thing and handles dependencies just fine.

Then we just pass it as a prop to `Keyboard` component:

```html
<Keyboard mode={keyboardMode} />
```

The rest of this big component is just as before.

### `src/CommandPalette.svelte`

And finally the `CommandPalette` changes.

It's now a `<form>` not a `<div>` so pressing Enter key triggers `submit` handler naturally. It doesn't matter a huge deal for this component, but some dialogs will need `Cancel` / `OK` buttons, and they really want to be `<form>`s.

Second thing is that we get commands only from default mode with `matchingCommands = matcher(commands.default, pattern)` as there are modes now, but command palette will never be open in any mode other than default.

At least for now, it's pretty clear that `Preview` component will want `CommandPalette` support at some point, but we'll get there when we get there.

We also don't handle `Escape` key at all. It's listed as command for `palette` mode, but it goes to `App` component telling it to close to palette, not to `CommandPalette` component. That follows the usual HTML logic where parents open and close their children.

Here's the code, skipping the unchanged style:

```html
<script>
  import commands from "./commands.js"
  import matcher from "./matcher.js"
  import { getContext } from "svelte"
  import CommandPaletteEntry from "./CommandPaletteEntry.svelte"

  let { eventBus } = getContext("app")
  let pattern = ""

  $: matchingCommands = matcher(commands.default, pattern)

  let app = eventBus.target("app")

  function submit() {
    app.closePalette()
    if (matchingCommands[0]) {
      eventBus.emit(...matchingCommands[0].action)
    }
  }
  function focus(el) {
    el.focus()
  }
</script>

<form class="palette" on:submit|preventDefault={submit}>
  <input use:focus bind:value={pattern} placeholder="Search for command">
  <ul>
    {#each matchingCommands as command}
      <CommandPaletteEntry {...command} />
    {/each}
  </ul>
</form>
```

### Result

Here's the result, the code is cleaned up, but the app is working just as before:

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

In the next episode we'll get back to adding some modal dialogs to the app.

As usual, [all the code for the episode is here](https://github.com/taw/electron-adventures/tree/master/episode-47-context-dependent-keyboard-handling).