Electron Adventures: Episode 47: Context Dependent Keyboard Handling

taw

Tomasz Wegrzanowski

Posted on September 10, 2021

Electron Adventures: Episode 47: Context Dependent Keyboard Handling

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.logs 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.

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

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.

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

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.

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

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.

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

src/App.svelte

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

  let paletteOpen = false
  let preview = null
  let keyboardMode

  $: {
    keyboardMode = "default"
    if (paletteOpen) keyboardMode = "palette"
    if (preview) keyboardMode = "preview"
  }
Enter fullscreen mode Exit fullscreen mode

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

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

<Keyboard mode={keyboardMode} />
Enter fullscreen mode Exit fullscreen mode

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:

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

Result

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

Episode 47 Screenshot

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.

💖 💪 🙅 🚩
taw
Tomasz Wegrzanowski

Posted on September 10, 2021

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

Sign up to receive the latest update from our blog.

Related