Electron Adventures: Episode 47: Context Dependent Keyboard Handling
Tomasz Wegrzanowski
Posted on September 10, 2021
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.
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.
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.
<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.
<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:
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:
<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:
<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:
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.
Posted on September 10, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.