DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Electron Adventures: Episode 38: Command Palette Highlighting

In previous episode we added a very simple command palette to the file manager. Over this and next few episodes we'll be improving on it. The first feature to add - match highlighting.

Why we need highlighting

It might seed like just an aesthetic issue, but it isn't. If user search for go, and matches are:

  • Go to First File
  • Go to Last File
  • Go to Next File
  • Go to Previous File
  • Page Down

It might be very baffling why that last one is there ("pa*Ge dO*wn"). Especially if for any reason the unexpected match takes priority over expected matches. Any such confusion can break the user out of the flow state.

src/CommandPalette.svelte

CommandPalette stops being responsible for filtering commands, all the responsibility will move to matcher.js

  import matcher from "./matcher.js"
  $: matchingCommands = matcher(commands, pattern)
Enter fullscreen mode Exit fullscreen mode

src/matcher.js

This is a fairly simple implementation, even if it uses a lot of RegExp trickery.

  • first we turn pattern into all lower case and strip everything that's not letter or number
  • every letter in the pattern we turn into regular expression, for example x becomes /(.*?)(x)(.*)/i - that is first parenthesis will match everything left of "x", second will match "x" (case insensitive), third everything to the right of "x" - if there are multiple "x"s, we only match the first one. That's what the question mark is for, to stop as soon as possible, by default regular expressions keep going as far as possible.
  • then we loop over all commands calling checkMatch - if it matches, we add it to results together with the match, otherwise we don't add it to the result
function matcher(commands, pattern) {
  let rxs = pattern
    .toLowerCase()
    .replace(/[^a-z0-9]/, "")
    .split("")
    .map(l => new RegExp(`(.*?)(${l})(.*)`, "i"))
  let result = []
  for (let command of commands) {
    let match = checkMatch(rxs, command.name)
    if (match) {
      result.push({...command, match: match})
    }
  }
  return result
}

export default matcher
Enter fullscreen mode Exit fullscreen mode

In checkMatch, we slice the name one letter at a time. For example if we match "Page Down" against "go", the first iteration will be:

  • "Page Down" becomes ["Pa", "g", "e Down"]
  • ["Pa", false] is added to result, so it won't be highlighted
  • ["g", true] is added to result, so it will be highlighted
  • only "e Down" goes to next iteration

Then in second iteration:

  • "e Down" becomes ["e D", "o", "wn"]
  • ["e D", false] is added to result, so it won't be highlighted
  • ["o", true] is added to result, so it will be highlighted
  • only "wn" remains after the loop, and whatever's left is added to the result non-highlighted as ["wn", false]

Here's the code:

function checkMatch(rxs, name) {
  if (!name) {
    return
  }
  let result = []
  for (let rx of rxs) {
    let m = rx.exec(name)
    if (m) {
      result.push([m[1], false])
      result.push([m[2], true])
      name = m[3]
    } else {
      return null
    }
  }
  result.push([name, false])
  return result
}
Enter fullscreen mode Exit fullscreen mode

This would be somewhat more concide in a language with more powerful regular expressions like Ruby or even Perl, but it's not too bad.

src/CommandPaletteEntry.svelte

And finally we need to add support for displaying highlighted results to CommandPaletteEntry.

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

  export let name
  export let match = undefined
  export let key
  export let action

  function handleClick() {
    eventBus.emit("app", "closePalette")
    eventBus.emit(...action)
  }
  function keyName(key) {
    if (key === " ") {
      return "Space"
    } else {
      return key
    }
  }
</script>

<li on:click={handleClick}>
  <span class="name">
    {#if match}
      {#each match as [part, highlight]}
        {#if highlight}
          <em>{part}</em>
        {:else}
          {part}
        {/if}
      {/each}
    {:else}
      {name}
    {/if}
  </span>
  {#if key}
    <span class="key">{keyName(key)}</span>
  {/if}
</li>

<style>
  li {
    display: flex;
    padding: 0px 8px;
  }
  li:first-child {
    background-color: #66b;
  }
  .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%;
  }
  .name em {
    color: #ff2;
    font-weight: bold;
    font-style: normal;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

There's one extra optional property match. If it's there, we loop over it treating it as array of [part, highlight]. Highlighted parts are wrapped in <em> which is then formatted below to be highlighted in same style as selected files.

This highlighting is not quite as visible as I hoped, so at some point I'll need to adjust the styling.

Result

Here's the results:

Episode 38 Screenshot

This was a nice small feature. In the next episode we'll teach our app how to deal with modifier keys like Control, Command, Shift, and so on, so keyboard shortcuts can be more than one key.

As usual, all the code for the episode is here.

Discussion (0)