skhd.zig

repository·main·Indexed 19 days ago

https://github.com/jackielii/skhd.zig

A high-performance hotkey daemon for macOS ported to Zig. It provides advanced keyboard remapping, including QMK-style tap/hold logic, layer support, and device-aware HID remapping via skhd-grabber, while maintaining full compatibility with .skhdrc configurations.

Tokens
17.6K
Snippets
60
Records
81
Agent score
70%

What's inside skhd.zig

  1. Core Functionality of skhd.zig

    main

    skhd.zig is a macOS keyboard remapping and hotkey utility written in Zig. Its core capabilities include:

    • Event capturing: Uses macOS Core Graphics Event Tap for system-wide interception.
    • Hotkey mapping: Maps key combinations (with full modifier support) to shell commands.
    • Process-specific bindings: Execute different commands depending on the active application.
    • Key forwarding/remapping: Remap keys to other key combinations.
    • Modal system: A multi-level modal hotkey system with capture modes.
    • Configuration: Compatible with the original skhd format and supports automatic hot reloading.
    • Device-aware HID remapping: Per-keyboard 1:1 remaps via hidutil and tap-vs-hold rules via the optional skhd-grabber daemon.
  2. Use hotkey sequences (v0.2.0)

    main

    You can bind a sequence of chords to a single action. The action fires only if the chords arrive in order within a specific time budget. The default timeout is 300ms, which can be adjusted using .sequence_timeout.

    Short bindings can also act as a prefix for longer sequences. A shorter binding will fire only when the subsequent chord in a longer sequence does not arrive (similar to Vim's timeoutlen).

    # Two quick Cmd-Q presses to quit Chrome; a single press does nothing.
    cmd - q, cmd - q [
        "Google Chrome" | cmd - q
    ]
    
    # A global binding that acts as a prefix for an app-specific sequence
    lcmd - k : yabai - m window --focus north   # focus north — everywhere, instantly
    cmd - k, cmd - k [
        "Google Chrome" | cmd - k              # …but double-tap sends Chrome its own Cmd-K
    ]
  3. Distinguish between Process Groups and Commands

    main

    The .define directive uses different syntax to distinguish between defining a process group and defining a command. This ensures backward compatibility.

    • Process Groups: Use array syntax [...]. Example: .define name ["app1", "app2"].
    • Commands: Use colon syntax :. Example: .define name : command text.
  4. Create reusable command templates with .define

    main

    The .define directive allows you to create reusable command templates to reduce repetition in your skhd configuration. You can define simple commands without parameters or complex templates using positional placeholders.

    Simple Commands

    Use a colon : to define a command that takes no arguments. Reference it in a hotkey using the @ prefix.

    Template Commands (with Placeholders)

    Use {{n}} syntax to define positional placeholders (where n is the index starting at 1). When calling these templates in a hotkey, you must provide arguments enclosed in double quotes.

    Rules for Templates

    • Placeholder Numbering: Must start from 1 (e.g., {{1}}, {{2}}).
    • Argument Quoting: All arguments passed to a template must be enclosed in double quotes (e.g., @cmd("arg")).
    • Argument Count: You must provide exactly as many arguments as the highest placeholder number in the template.
    • Escaping: Use \" to include a literal double quote inside a quoted argument.
    • Reusability: The same placeholder can be used multiple times within a single definition.
    # Simple definition
    .define focus_recent : yabai -m window --focus recent
    cmd - tab : @focus_recent
    
    # Template definition
    .define yabai_focus : yabai -m window --focus {{1}}
    lcmd - h : @yabai_focus("west")
  5. Use QMK-style tap-hold parameters in skhd.zig

    main

    skhd.zig implements the QMK firmware tap-hold model for its .taphold directives. Instead of Karabiner's JSON dialect, it uses snake_case keywords and parameters that match QMK's config.h semantics.

    If you are familiar with QMK, the following parameters behave identically:

    • timeout (corresponds to QMK TAPPING_TERM, default 200ms)
    • permissive_hold (corresponds to QMK PERMISSIVE_HOLD)
    • hold_on_other_key_press (corresponds to QMK HOLD_ON_OTHER_KEY_PRESS)
    • retro_tap (corresponds to QMK RETRO_TAPPING)
  6. Use Aliases for Modifiers and Keys

    main

    Aliases allow you to define reusable names for modifier combinations or single keys. They are expanded at parse time with zero runtime cost. Aliases must be defined before they are used, and redefinition is an error.

    Modifier Aliases

    • Defined with .alias $name <modifiers>.
    • Must be used in the modifier position (before - or chained with +).
    • Cannot be used as a key (e.g., ctrl - $hyper is an error).

    Key Aliases

    • Defined with .alias $name <key_or_hex>.
    • Can use literal names (e.g., delete) or hex keycodes (e.g., 0x32).
    • Must be used in the key position (after - or standalone).
    • Cannot be used as a modifier (e.g., $grave - h is an error).

    Note: You cannot bake modifiers into a single key alias (e.g., .alias $foo cmd - h is invalid). Instead, define the modifier and key separately and combine them at the use site.

    # Modifier aliases
    .alias $hyper cmd + alt + ctrl + shift
    .alias $super cmd + alt
    
    $hyper - h : echo "hyper-h"
    $super + shift - h : echo "super+shift+h"
    
    # Key aliases
    .alias $grave 0x32
    .alias $del delete
    
    ctrl - $grave : open - a Notes
    $del : echo plain-delete
  7. Implement Layer Holds (Modes)

    main

    A layer hold occurs when the hold attribute in a .remap block references a mode identifier instead of a hid_key. Holding the source key enters the mode; releasing it exits the mode.

    1. Declare the mode: Use :: <name> @ to declare a mode. The @ (capture) flag determines if unbound keys in that layer leak through to the app or are absorbed.
    2. Assign the mode to a key: In a .remap block, set hold : <mode_name>.
    3. Define mode bindings: Use <mode_name> - <key> : <command> to define what happens while the layer is active.

    Layer-hold modes are evaluated on the agent's run loop via IPC messages from the grabber.

    # 1. Declare a capture-mode
    :: fn_layer @
    
    # 2. Hold space to enter fn_layer
    .remap space [device builtin] {
        tap             : space
        hold            : fn_layer
        timeout         : 200ms
        retro_tap       : on
    }
    
    # 3. Define bindings for the layer
    fn_layer < 1 | f1
    fn_layer < 2 | f2
    fn_layer < tab | alt - tab
  8. SKHD Configuration Grammar Overview

    main

    The skhd.zig configuration syntax is fully compatible with the original skhd syntax. It uses a grammar based on modes, hotkeys (triggers), and actions.

    Core Components:

    • Modes: Named contexts that group hotkeys. Modes can be chained (e.g., mode1, mode2).
    • Triggers: A sequence of chords (keysyms). A chord is a combination of modifiers and a key.
    • Actions: What happens when a trigger is activated. Actions include executing shell commands, switching modes, or passing keypresses through.
    • Process Lists: Scoped bindings that apply only to specific applications or process groups.

    Key Syntax Symbols:

    • : or |: Execute a command.
    • ->: Passthrough (the keypress is not consumed by skhd).
    • ~: Unbound (the keypress is forwarded per usual).
    • ;: Activate a mode.
    • [ ]: Define a process list for application-specific scoping.
    hotkey       = <mode> '<' <action> | <action>
    mode         = 'name of mode' | <mode> ',' <mode>
    action       = <trigger> '[' <proc_map_lst> ']'   | <trigger> '->' '[' <proc_map_lst> ']'
                   <trigger> ':' <command>            | <trigger> '->' ':' <command>
                   <trigger> ';' <mode_activation>    | <trigger> '->' ';' <mode_activation>
                   <trigger> '~'
    trigger      = <keysym> | <keysym> ',' <keysym> (',' <keysym>)*
    keysym       = <mod> '-' <key> | <key>
    mod          = 'modifier keyword' | <mod> '+' <mod>
    key          = <literal> | <keycode>
  9. Understand the skhd and skhd-grabber architecture

    main

    The project uses a split-binary architecture to handle different permission levels required by macOS:

    1. skhd (User Agent): Runs as your local user. It handles standard hotkeys, modes, and non-caps .remap rules using hidutil or CGEventTap. If your configuration contains caps_lock tap-hold rules (caps-class .remap {}), the agent automatically attempts to connect to the grabber via a Unix socket.

    2. skhd-grabber (System Daemon): Runs as root. It is responsible for seizing HID devices (using IOHIDDeviceOpen with kIOHIDOptionsTypeSeizeDevice) to enable tap-hold behavior for keys like caps_lock. It processes the raw HID stream and injects resulting key events through the Karabiner virtual HID device (vhidd).

    Key Behaviors:

    • Configuration: The user agent (skhd) owns the configuration file (~/.config/skhd/skhdrc). It parses the file and sends only the relevant caps-class rules to the grabber.
    • User Switching: The grabber tracks the console user. When you switch users, the grabber applies the rules for the new active user and releases the seize on devices if no rules are active for that user.
    • Coexistence: If Karabiner-Elements is already seizing a device, skhd-grabber will fail to seize it and log a warning. The first seizer wins.
  10. Remap keys using HID identifiers

    main

    Remapping in skhd.zig operates at the HID layer, meaning it uses layout-independent physical-position names rather than macOS virtual keycodes.

    Supported HID names include: caps_lock, lctrl, non_us_backslash, az, 09, f1f20, minus, equal, lbracket, rbracket, backslash, semicolon, quote, grave, comma, period, slash, space, return, tab, escape, backspace, etc.

    To find the full list of identifiers, run skhd --grabber-status or cross-reference with Karabiner-Elements documentation.

  11. Use the local debug workflow with zig build run

    main

    The zig build run command uses a separate development bundle and certificate to avoid interfering with your production TCC entries. This allows you to debug without resetting your main accessibility permissions.

    TargetPathBundle IDCertificate
    Prod (sign-app)zig-out/skhd.appcom.jackielii.skhdskhd-cert
    Dev (run)zig-out/skhd-dev.appcom.jackielii.skhd.devskhd-dev-cert

    Setup for Dev:

    1. Run zig build run (the skhd-dev-cert is auto-created).
    2. Add zig-out/skhd-dev.app in System Settings → Privacy & Security → Accessibility and toggle it on.

    Note: To prevent the production daemon from receiving keypresses while debugging, run skhd --stop-service first.

    zig build run
  12. Use hotkey sequences

    main

    A sequence is a hotkey triggered by multiple comma-separated chords. Each step must be completed within 300ms, or the sequence expires.

    Important Rules:

    • Every chord in a sequence must include the complete modifiers.
    • -> (Passthrough) and ~ (Tilde/Conditional) apply only to the final chord. Earlier chords are always consumed.
    • Sequences can be used to protect apps (e.g., requiring two presses of a key to trigger an action).
    # Require two Cmd-Q presses to quit a protected app
    cmd - q, cmd - q [ "Protected App" | cmd - q ]
    
    # Sequence with passthrough on the final chord
    cmd - p -> : echo "This runs but Cmd+P still goes to app"