Skip to main content
المدونة

Zalt Blog

Deep Dives into Code & Architecture

AT SCALE

When Keybindings Become a Language

By محمود الزلط
Code Cracking
25m read
<

When keybindings become a language, config stops being random shortcuts and starts acting like a small, programmable system you can actually reason about.

/>
When Keybindings Become a Language - Featured blog post image

MENTORING

Got a quick technical question?

Tooling design, config patterns, DX tradeoffs — bring your question, get a focused answer without the back-and-forth.

We’re dissecting how Ghostty turns keybindings into a tiny language with its own parser, data model, and runtime. Ghostty is a fast, modern terminal emulator, and Binding.zig is the core file that decides what every keypress actually does. I’m Mahmoud Zalt, an AI solutions architect, and we’ll use this file as a case study in how to design configuration as a language instead of a pile of ad‑hoc strings.

We’ll see how Ghostty models triggers and actions as first‑class types, stores bindings in a trie‑like structure that supports sequences and chains, and still keeps lookups cheap enough to run on every keystroke. By the end, you’ll have a concrete pattern for building your own configuration language with a clean, testable runtime.

Bindings as a tiny language

Most applications treat keybindings as a map from stringified shortcuts to callbacks. Ghostty goes further: it defines a small configuration language with prefixes, sequences, chains, and parameters, and then gives that language a proper interpreter.

keybind = global:shift+KeyA=new_window
keybind = a>b=new_tab
keybind = chain=close_surface
Ghostty’s binding language: flags, key sequences, and chained actions.

The language is built around three concepts:

  • Trigger: what key combination the user pressed,
  • Action: what the terminal should do,
  • Set: a structure that maps triggers (including sequences) to actions.

Binding lines are parsed by a small Parser that emits semantic elements instead of substrings:

pub const Parser = struct {
    pub const Elem = union(enum) {
        leader:  Trigger,
        binding: Binding,
        chain:   Action,
    };

    pub fn init(raw_input: []const u8) Error!Parser { ... }
    pub fn next(self: *Parser) Error!?Elem { ... }
};

Each call to next yields one logical piece: a leader key in a sequence, the final binding, or a chain=. Everything above the parser layer works with domain types instead of raw ASCII, which is the core move: configuration is treated as a small language with an AST and runtime, not just text split on delimiters.

Triggers and actions as domain types

With a parser in place, the next question is how to represent the “words” of this language. Ghostty answers with two rich types: Trigger for key input and Action for behavior.

Triggers that match how users think

Trigger is more than a keycode and some bits:

pub const Trigger = struct {
    key: Trigger.Key = .{ .physical = .unidentified },
    mods: key.Mods = .{},

    pub const Key = union(C.Tag) {
        physical: key.Key,
        unicode:  u21,
        catch_all,
    };
};

A trigger can be:

  • a physical key like KeyA or an arrow key,
  • a specific Unicode codepoint (for bindings like ö or +),
  • a catch_all that matches anything not otherwise bound.

The parser for triggers accepts multiple modifiers in any order (shift+ctrl+a, a+shift), human‑friendly aliases (cmd, control, opt), W3C names (KeyA), direct Unicode, and a backwards‑compatibility map for legacy names like zero and kp_1. Internally, it enforces two critical rules:

  1. Exactly one key per trigger. A string like a+b is rejected. Multi‑key sequences are expressed with > at the language level, not by overloading Trigger.
  2. Compatibility is quarantined. Legacy key names live in a dedicated StaticStringMap marked as “Ghostty 1.1.x compatibility,” so the rest of the code doesn’t care about historical quirks.

Actions as verbs, not integer IDs

On the other side of the binding language is Action. Instead of a numeric ID plus a big switch, Ghostty uses a tagged union with strongly typed payloads:

pub const Action = union(enum) {
    ignore,
    unbind,
    csi: []const u8,
    esc: []const u8,
    text: []const u8,
    cursor_key: CursorKey,
    reset,
    copy_to_clipboard: CopyToClipboard,
    // ... many more ...
    crash: CrashThread,
};

The union covers terminal I/O, window management, search, tabs, splits, quick terminal, inspector, and more. To keep this manageable, the implementation leans on Zig’s type reflection (@typeInfo) to derive the parsing logic from the union definition itself:

pub fn parse(input: []const u8) !Action {
    const colonIdx = std.mem.indexOf(u8, input, ":");
    const action = input[0..(colonIdx orelse input.len)];
    if (action.len == 0) return Error.InvalidFormat;

    const info = @typeInfo(Action).@"union";
    inline for (info.fields) |field| {
        if (std.mem.eql(u8, action, field.name)) {
            // dispatch based on field.type via parseParameter
            // ...
        }
    }

    return Error.InvalidAction;
}

parseParameter inspects the type of each variant and chooses how to interpret the parameter:

  • enums via stringToEnum,
  • ints and floats via parseInt/parseFloat,
  • tuple structs (e.g. SplitResizeParameter as direction,amount),
  • custom types with their own parse function, like WriteScreen.

The key property is locality: adding a new action is usually “add one variant with the right type (and maybe a parse method)”, not “touch the parser, formatter, and several switch statements.” The configuration grammar tracks the domain model automatically through reflection.

The binding set as a key tree

Now that we have triggers and actions, we need to store many bindings—including multi‑key sequences like ctrl+x>c—and look them up quickly for each keystroke. Ghostty does this with Set, a small trie‑like structure built on top of hash maps.

Config line  -->  Parser  -->  Trigger / Action / Flags
                            |
                            v
                         Set (trie of triggers)
                            ^
                            |
                        KeyEvent
Set sits between config parsing and runtime key events, acting as a tree of key sequences.

At its core, Set is a hash map from Trigger to a Value union:

pub const Set = struct {
    const HashMap = std.ArrayHashMapUnmanaged(
        Trigger,
        Value,
        Context(Trigger),
        true,
    );

    bindings: HashMap = .{};

    pub const Value = union(enum) {
        leader: *Set,            // next step in a sequence
        leaf: Leaf,             // single action
        leaf_chained: LeafChained, // multiple actions
    };
};

If you think of keys as directories and final actions as files, a binding like a>b=new_window looks like this:

  • in the root Set, trigger a maps to leader: *Set,
  • in that nested Set, trigger b maps to a leaf holding the action and flags.

Insertion is handled by parseAndPut. Instead of mutating as it goes, it runs in two phases:

  1. A dry pass with the parser that fully validates the sequence, actions, and flags.
  2. A second pass that actually walks or allocates nested Set instances, filling in leader and leaf entries and updating a reverse map from Action to Trigger for GUI accelerators (with constraints: no multi‑key sequences, no performable‑only bindings).

Chaining actions safely

Bindings can also chain multiple actions to the same trigger. For example:

keybind = a=new_window
keybind = chain=new_tab
keybind = chain=close_surface

Pressing a now runs new_window, then new_tab, then close_surface. Implementing this well has two parts: representing chains, and deciding where each chain=... attaches.

From single leaf to chained leaf

Chains are represented with a pair of leaf types:

pub const Leaf = struct {
    action: Action,
    flags:  Flags,
};

pub const LeafChained = struct {
    actions: std.ArrayList(Action),
    flags:   Flags,
};

Bindings start life as a leaf. The first time a chain is appended, the code converts the leaf into leaf_chained and builds a small list of actions:

pub fn appendChain(
    self: *Set,
    alloc: Allocator,
    action: Action,
) (Allocator.Error || error{NoChainParent})!void {
    assert(action != .unbind);

    const parent = self.chain_parent orelse return error.NoChainParent;
    switch (parent.value_ptr.*) {
        .leader => unreachable,
        .leaf_chained => |*leaf| try leaf.actions.append(alloc, action),
        .leaf => |leaf| {
            var actions: std.ArrayList(Action) = .empty;
            try actions.ensureTotalCapacity(alloc, 2);
            actions.appendAssumeCapacity(leaf.action);
            actions.appendAssumeCapacity(action);

            parent.value_ptr.* = .{ .leaf_chained = .{
                .actions = actions,
                .flags   = leaf.flags,
            } };

            parent.set.fixupReverseForAction(leaf.action, parent.key_ptr.*);
        },
    }
}

Flags are carried over unchanged, and the reverse Action → Trigger mapping is adjusted so it still reflects the original single action. Chained actions are intentionally omitted from that reverse map, since GUI accelerators do not model “one shortcut triggers three things.”

Tracking where chains attach

The second challenge is figuring out which binding a chain=... refers to. The public API sees only a stream of lines; it doesn’t pass around handles to bindings. To support this, Set keeps a small piece of mutable state:

/// The chain parent is the information necessary to attach a chained
/// action to the proper location in our mapping.
chain_parent: ?ChainParent = null;

const ChainParent = struct {
    key_ptr:   *Trigger,
    value_ptr: *Value,
    set:       *Set,
};

Whenever a binding is successfully inserted or updated (put, putFlags, parseAndPut), chain_parent is set to point at that entry. Whenever a removal or failure occurs, chain_parent is cleared. appendChain uses this pointer to find the correct leaf or leaf_chained to mutate.

This implicit state is one of the more delicate parts of the design. The code mitigates the risk with extensive tests around chain_parent, assertions (for example, a leader can never be a chain parent), and explicit comments documenting when chaining is valid.

Keeping lookups fast

All of this expressiveness—sequences, chains, rich triggers, a large action space—still sits on the hot path. Every key event goes through the binding set. Ghostty’s runtime keeps that cost small and predictable.

Runtime lookup with getEvent

Key events reach Set.getEvent, which tries a short sequence of lookups against the trie:

pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry {
    var trigger: Trigger = .{
        .mods = event.mods.binding(),
        .key  = .{ .physical = event.key },
    };
    if (self.get(trigger)) |v| return v;

    // Try single-codepoint UTF-8 text
    if (event.utf8.len > 0) unicode: {
        const view = std.unicode.Utf8View.init(event.utf8) catch break :unicode;
        var it = view.iterator();
        const cp = it.nextCodepoint() orelse break :unicode;
        if (it.nextCodepoint() != null) break :unicode;

        trigger.key = .{ .unicode = cp };
        if (self.get(trigger)) |v| return v;
    }

    // Fallback to unshifted codepoint
    if (event.unshifted_codepoint > 0) {
        trigger.key = .{ .unicode = event.unshifted_codepoint };
        if (self.get(trigger)) |v| return v;
    }

    // Finally catch_all, with and then without modifiers
    trigger.key = .catch_all;
    if (self.get(trigger)) |v| return v;
    if (!trigger.mods.empty()) {
        trigger.mods = .{};
        if (self.get(trigger)) |v| return v;
    }

    return null;
}

The lookup strategy is straightforward:

  • Try the physical key with modifiers.
  • Try a single Unicode codepoint from the event’s UTF‑8 text.
  • Try an “unshifted” codepoint, if available.
  • Fall back to catch_all, first with modifiers, then without.

The hot path allocates nothing and performs a small, fixed number of hash map lookups. Unicode handling is intentionally constrained to “exactly one codepoint” cases. Case folding for Unicode triggers lives inside Trigger.hash and Trigger.foldedEqual, so the map behaves correctly without complicating callers.

Hashing and equality that match semantics

Trigger and Action both implement custom hashing and equality that match the semantics Ghostty cares about.

For Trigger:

  • modifiers must match exactly,
  • physical keys compare by their enum value,
  • unicode keys use a folded representation for hashing in the binding context so reasonable case handling is possible,
  • catch_all is its own equivalence class.

For Action:

  • equality is deep, including nested structs,
  • hashing uses Wyhash and bitcasts floats to avoid surprises.

This is crucial because the binding set also maintains a reverse map (Action → Trigger) to support GUI accelerators. If hashing or equality disagreed with how bindings are stored, that map would be silently wrong.

Lessons you can reuse

Ghostty’s Binding.zig is a compact example of designing a configuration language and its runtime around a real domain—keybindings—without giving up performance. The same patterns apply to any serious, configuration‑driven system.

  1. Treat configuration as a language.

    Define a small grammar and a parser that emits domain objects like Trigger, Action, and Flags, instead of pushing strings upward. Small iterators such as Parser let you stream elements like sequence leaders and chain actions cleanly.

  2. Model verbs as a typed union.

    Replace integer action IDs with a tagged union whose variants carry meaningful payloads. Use type reflection (or your language’s equivalent) to derive parsing, formatting, cloning, hashing, and equality so adding a new action is a local change.

  3. Use trie‑like structures for sequences.

    A nested Set of leader: *Set entries gives you multi‑key sequences with O(k) lookup in the sequence length and keeps prefixes separate from final actions.

  4. Validate first, mutate second.

    For complex updates—like inserting entire sequences—run a non‑mutating validation pass. Only once the intent is fully valid do you touch internal maps. This keeps the structure consistent even when parsing fails.

  5. Isolate backwards compatibility.

    Legacy formats and names belong in small, well‑named tables with tests, not scattered conditionals. Ghostty’s backwards‑compatible key names are confined to one map marked explicitly as compatibility glue.

  6. Be explicit about tricky state.

    When you need internal mutable state like chain_parent to keep the public API simple, document its invariants clearly and test transitions aggressively. Don’t pretend it’s harmless; constrain it.

Keybindings tend to accrete requirements—global shortcuts, per‑surface actions, sequences, chains, GUI accelerators, and compatibility layers. Ghostty shows that treating them as a proper language with a small runtime lets you keep that complexity under control.

If you’re building configuration for a terminal, a game, or a control plane, the same pattern applies: define a minimal grammar, map it to strong types, and run it through a tight, well‑tested interpreter. That’s the core lesson from Binding.zig—and a design you can adopt far beyond keybindings.

Full Source Code

Here's the full source code of the file that inspired this article.
Read on GitHub

Thanks for reading! I hope this was useful. If you have questions or thoughts, feel free to reach out.

Content Creation Process: This article was generated via a semi-automated workflow using AI tools. I prepared the strategic framework, including specific prompts and data sources. From there, the automation system conducted the research, analysis, and writing. The content passed through automated verification steps before being finalized and published without manual intervention.

Mahmoud Zalt

About the Author

I’m Zalt, a technologist with 16+ years of experience, passionate about designing and building AI systems that move us closer to a world where machines handle everything and humans reclaim wonder.

Let's connect if you're working on interesting AI projects, looking for technical advice or want to discuss anything.

Support this content

Share this article