Keymap
Keymap turns ad-hoc match blocks against KeyDecoder.InputKey
into declarative bindings. The win is a single
Map[InputKey, Msg] you can pass around, merge, and override —
useful for global shortcuts, modal overrides, and feature flags.
import termflow.tui.Keymap
import termflow.tui.KeyDecoder.InputKey
Building a keymap
val km: Keymap[Msg] = Keymap(
InputKey.CharKey('q') -> Msg.Quit,
InputKey.CharKey('?') -> Msg.ToggleHelp,
InputKey.Tab -> Msg.NextFocus,
InputKey.BackTab -> Msg.PrevFocus
)
km.lookup(InputKey.Tab) // Some(NextFocus)
km.lookup(InputKey.F1) // None
Keymap[Msg] is just Map[InputKey, Msg] under the hood — equality,
size, etc. work as you'd expect.
Composing keymaps
Two keymaps merge with ++. The right-hand side wins on conflict —
so put global bindings first, mode-specific bindings last:
val global = Keymap.quit(Msg.Quit) + (InputKey.CharKey('?') -> Msg.ToggleHelp)
val editing = Keymap(InputKey.Escape -> Msg.LeaveEditMode)
// In edit mode: editing's Escape wins over a global Escape, if any.
val active = global ++ editing
The + operator adds or replaces a single binding:
val withSave = global + (InputKey.Ctrl('S') -> Msg.Save)
Builders for common patterns
Keymap.empty[Msg] // {}
Keymap(bindings: (InputKey, Msg)*) // arbitrary
Keymap.quit(Msg.Quit) // Ctrl+C, Esc, q, Q
Keymap.focus(next = Msg.NextFocus, previous = Msg.PrevFocus)
// Tab + Shift+Tab
Keymap.focusVertical(previous = Msg.Up, next = Msg.Down)
// ArrowUp + ArrowDown
Keymap.focusHorizontal(previous = Msg.Left, next = Msg.Right)
// ArrowLeft + ArrowRight
These are the four most common shapes — quitting, tab focus, vertical
focus, horizontal focus. Stack them with ++ to assemble an app's
global keymap.
Wiring into update
case Msg.KeyPressed(key) =>
km.lookup(key) match
case Some(mapped) => Tui(m, Cmd.GCmd(mapped))
case None =>
// Unmapped key — fall through to widget-specific handling.
m.tui
The Cmd.GCmd(mapped) re-enters update with the mapped message,
which update handles like any other domain event. This decouples
"which keys do what" from "what happens when something is done".
For widgets that swallow keystrokes (TextField, MultiLineInput,
Prompt), feed the key into the widget first, and only fall back to
Keymap.lookup when the widget doesn't consume it. The
forms tutorial
walks through this pattern.
Mode stacks
Apps with insert / normal / command modes can stack keymaps:
final case class Model(
mode: EditorMode, // Normal | Insert | Command
/* … */
):
def keymap: Keymap[Msg] = mode match
case Normal => globalKm ++ normalKm
case Insert => globalKm ++ insertKm
case Command => globalKm ++ commandKm
update always calls model.keymap.lookup(key). Mode transitions
just change which keymap is active.
Help overlays
Because Keymap is just a map, you can render a help screen straight
from the bindings:
def renderHelp(km: Keymap[Msg], labelOf: Msg => String): List[VNode] =
km.bindings.toList.sortBy(_._2.toString).map { case (key, msg) =>
TextNode(2.x, 1.y, List(
describe(key).text(fg = Theme.dark.primary),
" ".text,
labelOf(msg).text
))
}
describe(InputKey) is whatever pretty-printer you want — the
Keymap.scala source has a renderChord helper that returns
human-readable strings ("Tab", "Ctrl+S", "Esc").
Reference
File:
modules/termflow-app/src/main/scala/termflow/tui/Keymap.scala.
For a working example, see
apps.forms.FormDemoApp
— it builds a global keymap, layers a per-field keymap on top, and
renders the active bindings as a help footer.