Electron Adventures: Episode 35: Command Palette

taw

Tomasz Wegrzanowski

Posted on August 29, 2021

Electron Adventures: Episode 35: Command Palette

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.

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:

  • Set Syntax As*m*
  • Set Syntax Markdown

And you generally want the latter to take priority.

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

  • Open Copilot
  • Docker Containers: Prune

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:

  <button on:click={() => eventBus.emit("app", "openPalette")}>F5 Palette</button>
Enter fullscreen mode Exit fullscreen mode

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

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

src/Command.svelte

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

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

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} />

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

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.

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

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

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

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.

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

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:

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

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

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.

💖 💪 🙅 🚩
taw
Tomasz Wegrzanowski

Posted on August 29, 2021

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

Sign up to receive the latest update from our blog.

Related