Electron Adventures: Episode 35: Command Palette
Tags: javascript, electron, svelte
Published: 2021-08-29 02:45:24

One of the best UI innovations in the last decade has been the Command Palette - [from Sublime Text it's been spreading like wildfire to all software](https://capiche.com/e/consumer-dev-tools-command-palette).

So obviously we want it in our app too.

There's exising command palette components for pretty much every framework, but we're going to build our own.

### What command palette needs?

There are quite a few parts:

* a shortcut to start the command palette
* modal dialog that should disable most interactions with other parts of the app while it's open
* a list of commands that can be executed
* learnable shortcuts displayed with each command
* fuzzy search for matching commands
* a way to select first command with Enter, or to navigate to other suggestions with mouse or arrow keys
* Escape to leave the command palette

### Fuzzy Search

In principle we could get away with a simple subscring search. If user searches for `abc`, we take it to mean any command that contains `a`, anything, `b`, anything, `c` (`/a.*b.*c/i`). And display them all alphametically or something

This isn't optimal, for example if you have a text editor, and you search `ssm`, then it will match commands like:

* **S**et **S**yntax As**m**
* **S**et **S**yntax **M**arkdown

And you generally want the latter to take priority.

And if you type `cop`, you probably want the first one:

* Open **Cop**ilot
* Docker **Co**ntainers: **P**rune

There are some scoring heuristics such as prioritizing first letters of world (first example), fewest breaks (second example), and so on.

Many programs also remember which commands you use more often or more recently, and prioritize those, so even if they did a poor job at first, they get better soon.

For now we're going to do none of that, and just use a simple substring search. It wouldn't even make sense until we have a lot more commands in the palette.

### Let's Get Started!

First, I want to say I'm already regretting the color scheme I setup in previous two episodes, but let's roll with it. I was supposed to be cute "retro" thing, but it turns out command palette has a lot of visual subtlety to get right, and this isn't it.

I'll fix it in some future episode. And if the whole series ends up looking like pretty close to default VSCode? Nothing wrong with that.

It will also be command palette with very limited functionality for now, to keep this episode to reasonable size:

* you can type a command, then press Enter to execute top match
* you can press Ecape to close the command palette
* you can click on any specific command to execute it

Most command palettes also allow you to navigate by arrow keys, do highlighting, and have a lot more fancy stuff. We'll get there eventually.

### Opening palette

As I'm still trying to get away with not using modifier keys, let's use F5 for it. This means we need to add it to `src/Keyboard.svelte` and `src/Footer.svelte`.

The keyboard component, which runs normal app shortcuts, also needs to be disabled while command palette is open. It will also need to be disabled for other modal dialogs.

Footer just gets this one line added:

```html
  <button on:click={() => eventBus.emit("app", "openPalette")}>F5 Palette</button>
```

Keyboard gets new entry for F5, as well as `active` flag to turn itself off.

```html
<script>
  export let active

  import { getContext } from "svelte"
  let { eventBus } = getContext("app")

  function handleKey({key}) {
    if (!active) {
      return
    }
    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 === "F5") {
      eventBus.emit("app", "openPalette")
    }
    if (key === "F10") {
      eventBus.emit("activeBox", "quit")
    }
  }
</script>

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

### `src/Command.svelte`

This is a simple component, that shows just one of the matching commands.

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

  export let name
  export let keys
  export let action

  function handleClick() {
    eventBus.emit("app", "closePalette")
    eventBus.emit(...action)
  }
</script>

<li on:click={handleClick}>
  <span class="name"> {name}</span>
  {#each keys as key}
    <span class="key">{key}</span>
  {/each}
</li>

<style>
  li {
    display: flex;
    padding:  0px 8px;
  }
  li:first-child {
    background-color: hsl(180,100%,20%);
  }
  .name {
    flex: 1;
  }
  .key {
    display: inline-block;
    background-color: hsl(180,100%,30%);
    padding: 2px;
    border: 1px solid  hsl(180,100%,20%);
    border-radius: 20%;
  }
</style>
```

The command shows its shortcut keys on the right - it's as array as we could be having something like `["Cmd", "Shift", "P"]`, even if right now we only use single keys.

If any command is clicked, two events need to happen:

* palette needs to be closed
* chosen command needs to be executed

### `src/CommandPalette.svelte`

The command palette has a bit more logic to it, even in our very simple version.

First template and styling. We have input for the pattern, we display list of matching commands (which will be all commands if search is empty), and we need `on:keypress` handler to handle Escape and Enter keys.

It's also important that input is focused when the palette is opened, we use `use:focus` for this, with `focus` being a one line function we'll get to.

We can destructure all fields of `command` and pass them as individual props with `{...command}` instead of writing `<Command name={command.name} keys={command.keys} action={command.action} />`

```html
<div class="palette">
  <input use:focus bind:value={pattern} placeholder="Search for command" on:keypress={handleKey}>
  <ul>
    {#each matchingCommands as command}
      <Command {...command} />
    {/each}
  </ul>
</div>

<style>
  .palette {
    font-size: 24px;
    font-weight: bold;
    position: fixed;
    left: 0;
    top: 0;
    right: 0;
    margin: auto;
    max-width: 50vw;
    background-color: hsl(180,100%,25%);
    color: #333;
    box-shadow: 0px 0px 16px hsl(180,100%,10%);
  }

  input {
    background-color: inherit;
    font-size: inherit;
    font-weight: inherit;
    box-sizing: border-box;
    width: 100%;
    margin: 0;
  }

  input::placeholder {
    color: #333;
    font-weight: normal;
  }

  ul {
    list-style: none;
    padding: 0;
  }
</style>
```

In the script section we have a lot of things to do. First we need the list of commands.

List of commands here, list of commands in the Keyboard component, and list of commands in ApplicationMenu component are highly overlapping set, but they're not identical. For now let's accept duplication, but this will need to change at some point.

```javascript
let commands = [
  {name: "Cut", keys: ["F1"], action: ["activeBox", "cut"]},
  {name: "Copy", keys: ["F2"], action: ["activeBox", "copy"]},
  {name: "Paste", keys: ["F3"], action: ["activeBox", "paste"]},
  {name: "Quit", keys: ["F10"], action: ["app", "quit"]},
  {name: "Box 1", keys: ["1"], action: ["app", "changeBox", "box-1"]},
  {name: "Box 2", keys: ["2"], action: ["app", "changeBox", "box-2"]},
  {name: "Box 3", keys: ["3"], action: ["app", "changeBox", "box-3"]},
  {name: "Box 4", keys: ["4"], action: ["app", "changeBox", "box-4"]},
]
```

For matching function, we strip all special characters, ignore case, and then treat search for `o2` as search for: "anything, letter o, anything, number 2, anything".

```javascript
function checkMatch(pattern, name) {
  let parts = pattern.toLowerCase().replace(/[^a-z0-9]/, "")
  let rx = new RegExp(parts.split("").join(".*"))
  name = name.toLowerCase().replace(/[^a-z0-9]/, "")
  return rx.test(name)
}
```

And here's all of it connected together. `focus` is called when the palette is opened, `matchingCommands` reactively calls our function if `pattern` changes, and `handleKey` is called when any key is pressed, dealing with `Escape` and `Enter`, but letting all other keys be handled by the `<input>` itself.

If you try to press `Enter` when there are no matching commands, it will also close the palette.


```javascript
import Command from "./Command.svelte"
import { getContext } from "svelte"
let { eventBus } = getContext("app")

let pattern = ""

$: matchingCommands = commands.filter(({name}) => checkMatch(pattern, name))

function handleKey(event) {
  let {key} = event;

  if (key === "Enter") {
    event.preventDefault()
    eventBus.emit("app", "closePalette")
    if (matchingCommands[0]) {
      eventBus.emit(...matchingCommands[0].action)
    }
  }
  if (key === "Escape") {
    event.preventDefault()
    eventBus.emit("app", "closePalette")
  }
}
function focus(el) {
  el.focus()
}
```

### `src/App.svelte`

And finally, to enable it we need to do a few things in the main component.

I'm skipping the styling section, as it didn't change:

```javascript
<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 AppMenu from "./AppMenu.svelte"
  import CommandPalette from "./CommandPalette.svelte"
  import EventBus from "./EventBus.js"

  let activeBox = writable("box-1")
  let clipboard = writable("")
  let eventBus = new EventBus()
  let commandPaletteActive = false

  setContext("app", {activeBox, clipboard, eventBus})

  function quit() {
    window.close()
  }
  function changeBox(id) {
    activeBox.set(id)
  }
  function emitToActiveBox(...args) {
    eventBus.emit($activeBox, ...args)
  }
  function openPalette() {
    commandPaletteActive = true
  }
  function closePalette() {
    commandPaletteActive = false
  }
  eventBus.handle("app", {quit, changeBox, openPalette, closePalette})
  eventBus.handle("activeBox", {"*": emitToActiveBox})
</script>

<div class="app">
  <Box id="box-1" />
  <Box id="box-2" />
  <Box id="box-3" />
  <Box id="box-4" />
  <Footer />
</div>

<Keyboard active={!commandPaletteActive} />
<AppMenu />
{#if commandPaletteActive}
  <CommandPalette />
{/if}
```

So we have extra flag `commandPaletteActive`, which controls boths the `CommandPalette` and `Keyboard`, so keyboard is inactive when the palette is open. There are two simple events `openPalette` and `closePalett` which just flip this flag. And that's all it took.

### Result

Here's the results:

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

And that's a good time to stop our side quest with the retro looking four box app. Over the next few episodes, we'll be taking the lessons learned and enhancing the file manager we've been working on.

As usual, [all the code for the episode is here](https://github.com/taw/electron-adventures/tree/master/episode-35-command-palette).