← Keyhac crftwr/keyhac on GitHub · craftware

Configuration

Keyhac is configured with one Python file: ~/.keyhac/config.py (or, in Windows portable mode, the config.py sitting next to Keyhac.exe). On first run it is created from a fully commented template — the template (keyhac/_config.py in the source tree) is a working example of everything on this page, and a good file to keep open while reading.

This page introduces the APIs in the order you meet them. For the exact arguments of any one of them, see the API reference.

The config file

from keyhac import *

def configure(keymap):
    ...

Keyhac calls configure(keymap) at startup and on every reload (“Reload Config” in the tray / menu-bar menu). Errors are contained:

~/.keyhac/extensions/ is on sys.path, so you can split a large config into modules and import them. Reloading re-imports them too, so an edit to an extension is picked up without restarting Keyhac.

The directory is appended to sys.path, not prepended: an extension is named after what it does, and a queue.py next to a queue-handling action would otherwise shadow the standard library for the whole process. If you name a module after one Python already provides, Python’s copy wins and yours is unreachable — rename it.

“Edit Config” in the tray menu opens the file in an editor. Pick yours with:

keymap.editor = "CotEditor"          # app name or path, or a callable(path)

Unset, Keyhac tries VS Code, then Xcode, then TextEdit on macOS; Notepad on Windows.

One config, both OSes

The same file runs on Windows and macOS. Where the OSes genuinely differ, branch on keymap.platform ("windows" or "mac"). Two constants absorb most of it — this is the pattern the template uses throughout:

mac = keymap.platform == "mac"
LEADER = "Fn" if mac else "User0"    # the modifier your bindings hang off
MOD = "Cmd" if mac else "Ctrl"       # the OS's primary shortcut modifier

kt[f"{LEADER}-C"] = f"{MOD}-C"

A plain Python constant is the answer here, not a built-in name: there is no Mod- modifier in the key expression language. Modifier names mean the same thing on both OSes, and one that quietly changed meaning per OS would not survive the round trip — the console reports what actually fired, Cmd-C or Ctrl-C. The constant also scales to whatever else your config wants to vary by OS, which is why LEADER above works the same way.

Config diagnostics are cross-platform aware: a key name that exists only on the other OS says so in the error, and after loading, Keyhac warns once about bindings whose modifiers no key on this OS can produce (for example a Cmd- binding running on Windows — it parses, but would silently never fire).

Key tables

A key table holds bindings and a condition for when they apply:

# global (matches everything)
kt = keymap.define_keytable(focus_path_pattern="*")

# portable: by application, by window title (fnmatch wildcards, "|" alternation,
# case-insensitive; ".exe" optional on Windows)
kt_term = keymap.define_keytable(app="WindowsTerminal|Terminal|iTerm2")
kt_edit = keymap.define_keytable(app="Code", title="*myproject*")

# platform extras
kt_np    = keymap.define_keytable(app="notepad", class_name="Edit")        # Windows
kt_area  = keymap.define_keytable(focus_path_pattern="*/AXTextArea(*)")    # macOS

# arbitrary logic
kt_x     = keymap.define_keytable(custom_condition_func=lambda focus: ...)

# named, detached table = a multi-stroke second stroke (see below)
kt_ctrlx = keymap.define_keytable(name="Ctrl-X")

Matching:

Every matching table is active at once, merged in definition order — later tables win per key. Define your global table first and app-specific tables after it, and the specific ones override exactly the keys they bind.

Key expressions

{O-|D-|U-}{Modifier-}...{Key} — case-insensitive.

Assignments

kt["Fn-J"] = "Left"                          # key -> key
kt["Fn-N"] = "Cmd-1", "Cmd-2"                # key -> sequence
kt["Fn-A"] = some_callable                   # key -> function / action object
kt["Ctrl-X"] = kt_ctrlx                      # key -> multi-stroke table
kt["O-RAlt"] = "Space"                       # one-shot

Anything callable can be bound: a plain function, a lambda, or one of the action objects below (MoveWindow(...), ShowClipboardHistory(), … — they are all callables).

Multi-stroke tables: assigning a named table arms it as a prefix — press Ctrl-X, then a key bound in kt_ctrlx. While armed, a balloon shows the table’s name. A key that is not bound in the armed table cancels it (and is swallowed, so a typo does not leak a stray keystroke into your app).

Remapping and user modifiers

keymap.replace_key("Insert", "LCtrl")        # swap a key outright
keymap.define_modifier("Apps", "User0")      # turn a key into your own modifier

One-shot modifiers (O- prefix) give a modifier key a second life: held with another key it modifies as usual; tapped alone, it fires the one-shot binding.

kt["O-LCmd"] = "Eisu"        # tap left Cmd alone -> IME off; held -> still Cmd

A one-shot is canceled by any intervening key or mouse click, so half-finished shortcuts do not trigger it.

Sending input from functions

def wrap():
    with keymap.get_input_context() as ctx:
        ctx.send_key("Ctrl-C")               # key expression, incl. modifiers
        ctx.send_text("hello")               # literal text, any characters

get_input_context() batches virtual input and reconciles modifiers for you: held physical modifiers are released around the batch and restored after, so ctx.send_key("Ctrl-C") works even while your binding’s own modifiers are still physically down. It is safe from a ThreadedAction worker thread too.

For simple cases there are ready-made actions:

kt["Fn-Semicolon"] = InputText("me@example.com")   # type a literal string

Mouse output: MouseMove(dx, dy), MouseButtonDown/Up/Click(button) ("left" by default, or "right" / "middle"), MouseWheel(notches), MouseHorizontalWheel(notches) (positive is away from you / to the right, 1.0 = one notch) — plus the matching ctx.send_mouse_* methods. Buttons and wheels release held modifiers first (so a User0-… binding does not click with a phantom modifier); moves keep them. Relative moves are injected acceleration-proof on both OSes, and rapid synthetic clicks register as double-clicks.

IME

if keymap.get_ime_status():          # True on, False off, None can't tell
    keymap.set_ime_status(False)     # returns whether the state was reached

Both act on whatever holds the input focus, and neither takes a window. That is the one contract the two OSes can both honor: Windows reaches the state through a window handle and so could address a background window, while macOS only ever exposes “the current input source”. A window argument would mean two different APIs wearing one name.

get_ime_status() returns None — not False — when the state cannot be determined: no IME is installed, or on Windows a TSF-only IME that does not answer the IMM32 query. set_ime_status() reads the state back rather than assuming the call took, so its False means the IME declined or there was none to ask.

Some caveats worth knowing:

For simply toggling, the key names cost nothing and work on both OSes: Eisu is IME off and Kana is IME on, reaching the macOS keys of those names and Windows’ VK_IME_OFF / VK_IME_ON.

kt["O-LCmd"] = "Eisu"        # tap left Cmd alone -> IME off
kt["O-RCmd"] = "Kana"        # tap right Cmd alone -> IME on

Windows additionally has the physical JIS keys Kanji (半角/全角), Henkan (変換) and Muhenkan (無変換), which macOS has no equivalent of — guard those with keymap.platform.

Clipboard

keymap.clipboard_history.max_items = 500
kt["Fn-V"] = ShowClipboardHistory()

ShowClipboardHistory() opens the chooser popup over the focused window: type to filter, Up/Down to select, Enter pastes into the app you came from, Shift-Enter only sets the clipboard, Escape cancels. Pressing the hotkey again closes it.

Filtering is multi-word AND substring, plus Migemo, so typing gijiroku finds an entry containing 議事録 without switching to an input method.

The popup does not take keyboard focus, which is what keeps the application you came from active, keeps the console where it is, keeps you on the desktop you are on, and lets the paste go out with no delay. The keystrokes reach the popup through Keyhac’s own key hook instead. The one consequence to know about: an input method cannot compose in the filter field — composition follows OS keyboard focus, and this window does not take it. Type romaji and let Migemo find the Japanese.

kt["Fn-Shift-V"] = ShowClipboardSnippets([
    ("📧", "me@example.com"),                          # (icon, text)
    ("📮", "Mailing address", "400 Broad St, ..."),    # (icon, label, text)
    ("🕒", "Date", DateTimeSnippet("%Y-%m-%d")),       # (icon, label, callable)
])

kt["Fn-Ctrl-V"] = ShowClipboardTools([
    ("🔄", "Quote", ShowClipboardTools.quote),
    ("🔄", "Upper case", str.upper),
    ("🔄", "Pretty JSON", my_pretty_json),             # str -> str
])

Tools transform the current clipboard text; the built-ins are quote, unindent, to_half_width and to_full_width, and any str -> str callable works.

History behavior is configurable via keymap.clipboard_history: max_items (default 1000), max_data_size (largest text captured, default 10 MB), max_persist_data_size (largest entry written to disk, default 64 KB), and persist = False to keep history in memory only. The API is scriptable too: items(), get_current(), set_current(text).

One window over several sources

Three choosers is three hotkeys, and a hotkey is the scarce thing — there are only so many you can hold. A source is a value you hand to one window instead, so several kinds of row share one key and one incremental search, each row labelled on the right with where it came from:

kt["Fn-P"] = ShowCandidates([
    ClipboardHistorySource(),
    SnippetsSource(my_snippets),
    ClipboardToolsSource(my_tools),
])

Anything that returns a list can be a source — no class to subclass:

def open_windows():
    return [Candidate(icon="🪟", display=w.title, payload=w)
            for w in keymap.list_windows() if w.title]

kt["Fn-Shift-W"] = ShowCandidates(open_windows,
                                  on_chosen=lambda c, mod: c.payload.activate())

Rows are ordered by how well they match, not by which source produced them: a match at the start of a row beats one starting a word, which beats one inside a word, and an earlier or shorter match wins a tie. With an empty query there is nothing to judge, so the sources’ own order stands — clipboard history newest first.

A source with real work to do can yield instead of returning a list. Its first rows appear immediately and the rest arrive while the window is already open, and closing it or switching scope simply stops asking. Yield often, and do not block: a source runs on the main thread, in slices, and one that does not return holds the keyboard.

Give it a class once it wants a name in a shared window and its own idea of what choosing does — CandidateSource with name, candidates() and on_chosen(). Enter runs whatever the chosen row’s source says, so rows from different sources can mean different things in the same window: paste this, activate that, press the other. A single row can override even that with Candidate(action=...).

ShowClipboardHistory() and its two siblings are presets over exactly these sources.

Scopes: one key, several sets

Group the sources into named scopes and one key reaches all of them. Tab and Shift-Tab move along the cycle, and the query comes with you — type what you are looking for, then look for it somewhere else without retyping it. The current scope is named at the right of the filter field.

kt["Fn-P"] = ShowCandidates([
    Scope("All",       [clipboard, snippets, tools, windows]),
    Scope("Clipboard", [clipboard, snippets]),
    Scope("Windows",   [windows]),
])

The built-in sources are ClipboardHistorySource, SnippetsSource, ClipboardToolsSource, MenuItemsSource (every command in the front application’s menus, with its shortcut), KeyBindingsSource (every binding in effect right here, and Enter runs it — a binding you can run from a list does not need a key of its own) and ActionsSource (every ThreadedAction under ~/.keyhac/extensions/, startable without ever binding it to anything) and WindowControlsSource (everything clickable in the front window, by name).

The last two read the front application on every invocation, so give each a Scope of its own rather than putting it in a merged one.

Build each source once and share it between scopes. A source is read once per window and remembered against the object, so a MenuItemsSource that appears both in an everything-scope and in a scope of its own walks the menu bar once — if it is the same instance. Two separately built sources are two sources, which is what you want when they differ.

menus = MenuItemsSource()
kt["Fn-P"] = ShowCandidates([
    Scope("All",   [clipboard, menus]),
    Scope("Menus", [menus]),
])

Scopes are also how an expensive source stays affordable: one that has real work to do — walking the window’s controls, asking a server — costs that work every time it is in the scope being opened, so putting it in a scope of its own means it is paid for only when you ask for it.

Writing a chooser as a class

Custom choosers may also derive from ChooserAction: implement list_items() -> [(icon, label, ...)] and on_chosen(item, modifier_flags); the open/filter flow is inherited. Two class attributes tune it: matcher (default substring + Migemo; WildcardMatcher() for * and ?) and activates (default False; set it True only if the filter field genuinely needs an input method, which costs the focus of the application underneath).

Windows, screens and applications

kt["Fn-1"] = ActivateWindow(app="code|Visual Studio Code")   # bring forward
kt["Fn-T"] = LaunchApplication("Terminal.app")               # launch

kt["Fn-Ctrl-Left"] = MoveWindow(direction="left", distance=20)
kt["Fn-Alt-Left"]  = MoveWindow(direction="left", distance=9999,
                                window_edge=True, screen_edge=True)
kt["Fn-Ctrl-J"] = SnapWindow("left")          # tile: left/right/top/bottom/full
kt["Fn-F"]      = SnapWindow("full")          # ratio=2/3 etc. picks the split

MoveWindow nudges by distance pixels (default 10), or with window_edge=/screen_edge= travels until it hits other windows’ edges / the screen edge (hopping to the next monitor when already there). Only screen_edge is on by default. SnapWindow tiles within the screen’s work area — menu bar, Dock and taskbar stay uncovered — taking half the screen unless ratio= says otherwise.

For your own logic, Window objects are fully portable:

window = keymap.get_active_window()           # or find_window(app=, title=,
x, y, w, h = window.get_frame()               #    class_name=), list_windows()
window.set_frame(x + 100, y)
window.activate(); window.minimize(); window.restore(); window.is_minimized()
window.title; window.app_name; window.pid; window.class_name  # class_name: Windows

find_window matches exactly like define_keytable: wildcards, | alternation, case-insensitive, .exe optional. Screen geometry lives on keymap: screen_frames() (whole screens, primary first), screen_work_frames() (minus menu bar / Dock / taskbar) and window_frames() (all normal on-screen windows) — each frame is an (x, y, w, h) tuple in a shared coordinate space.

Thread contract: Window objects, focus.element and screen_work_frames() are UI-thread only — never touch them from a ThreadedAction.run(). The thread-safe geometry pair is screen_frames() / window_frames(). A ThreadedAction reads windows in starting(), computes in run(), and writes back in finished().

The Focus object

keymap.focus — and the argument of every custom_condition_func — is a snapshot of the current keyboard focus: app_name, pid, window_title, class_name (Windows only), path (the focus path string), element and native.

focus.element is the focused semantic element — an AX element on macOS, a UI Automation element on Windows. Both have the same shape: get_attribute_names(), get_attribute_value(name), get_action_names(), perform_action(name), parent() — but each uses its own OS’s vocabulary:

  macOS (AX) Windows (UI Automation)
role AXRole ControlType ("Edit", "Window", …)
label AXTitle Name
text value AXValue Value
selection AXSelectedText SelectedText
press perform_action("AXPress") perform_action("Invoke")

Element-level code therefore branches on keymap.platform. The failure modes are gentle: an unsupported attribute reads None, an unknown action logs and returns False. focus.native is the platform power object — the same AX element on macOS, an HWND wrapper on Windows.

Driving another application: the action API

Reading and writing another application’s UI — searching element trees, waiting for a screen to change, filling fields — is a separate surface reached through keymap.ui (or self.ui inside a ThreadedAction):

class Extract(ThreadedAction):
    def run(self):
        window = self.ui.window(app="Safari")
        window.wait_for(role="AXTable", message="the results to load")
        for row in window.find_all(role="AXRow"):
            print([cell.all_text for cell in row.children])

It is deliberately its own namespace and method-style: a config binds keys, an action drives somebody else’s UI, and only UINode, WaitTimeout and FillFailed are importable. See Action API for the full surface, and keyhac/skills/keyhac-action-authoring/ for how to write one.

A UINode is a snapshot — it records what an element was when it was read, and the screen moves on without it noticing. reread() refreshes one deliberately, and StaleElement tells you a node you are holding has outlived what it pointed at. AI Integration covers turning the endpoint on and what it can reach.

Keyboard macros

kt["Fn-OpenBracket"]  = ToggleRecordingKeys()     # record on/off
kt["Fn-CloseBracket"] = PlaybackRecordedKeys()    # replay

StartRecordingKeys() / StopRecordingKeys() exist if you prefer separate keys. Replayed keys run back through your keymap, so recorded bindings expand on playback. The buffer is keymap.replay_buffer.

Background work: ThreadedAction

Anything slow — network, subprocess, sleeping, heavy computation — must not run inline: your functions execute inside the keyboard hook’s deadline. Subclass ThreadedAction instead:

class Fetch(ThreadedAction):
    def starting(self):          # main thread, before run
        logger.info("fetching...")
    def run(self):               # worker thread - the slow part
        return do_network_call()
    def finished(self, result):  # main thread, after run
        logger.info(f"got {result}")

kt["Fn-G"] = Fetch()

starting() and finished() run on the main thread (UI and window access allowed); run() runs on a worker (input contexts allowed, windows/elements not — see the thread contract above).

The pool is a single worker shared by every threaded action, so a run() that sleeps or loops delays every other one until it returns. Keep long waits short, and prefer keymap.call_on_main_thread(func) — thread-safe, and the supported way to reach the main thread from anywhere — over holding the worker to wait for something.

It is also what makes an action listable

Put a ThreadedAction subclass in a file under ~/.keyhac/extensions/ and it can be started without a key at all — by name from the candidate window, and by an AI assistant through the MCP endpoint. Subdirectories count, so extensions/mine/extract.py is addressed as mine.extract.Extract; names beginning with _ are treated as helpers and not offered.

Subclassing ThreadedAction is what puts it on those lists. A class that merely defines __call__ binds to a key perfectly well and is deliberately not enumerated: the main thread services the keyboard hook and every window, so a list whose rows might block it is a list that can freeze the keyboard. If you want a fast action on the list, subclass anyway and leave run() short — the worker thread costs nothing when the work is small.

Balloons

keymap.pop_balloon("hello", "Keyhac is running", 2.0)   # name, text, timeout
keymap.close_balloon("hello")

A balloon is a small frameless tooltip near the focused window. Multi-stroke tables pop one automatically. In --no-ui mode these attributes are absent — guard with getattr(keymap, "pop_balloon", None) if your config must run headless.

Logging and the console

print() and getLogger(name) both land in the console window:

logger = getLogger("Config")
logger.info("loaded")

The console’s dropdown filters by level; -d/--debug on the command line enables debug logging from startup. The console also shows the last key event and the live focus path — the two things you need when writing new bindings.

Runtime API summary

API Notes
keymap.platform "windows" / "mac"
keymap.define_keytable(...) key tables + focus conditions
keymap.replace_key(src, dst) / define_modifier(key, mod) pre-engine remap / user modifiers
keymap.get_input_context() virtual input batch; thread-safe
keymap.focus current Focus snapshot
keymap.get_active_window() / list_windows() / find_window(...) portable Window objects; UI thread only
keymap.screen_frames() / screen_work_frames() / window_frames() screen/window geometry; screen_work_frames UI thread only
keymap.get_ime_status() / set_ime_status(on) IME on/off for the input focus; UI thread only
keymap.clipboard_history settings + items() / get_current() / set_current()
keymap.editor / edit_config() / reload_config() config lifecycle (tray menu uses these)
keymap.replay_buffer macro buffer behind the record actions
keymap.pop_balloon(name, text, timeout) / close_balloon(name) balloons (UI mode only)
InputText(s) type a literal string
MoveWindow(...) / SnapWindow(...) / ActivateWindow(...) / LaunchApplication(...) window & app actions
MouseMove / MouseButtonDown/Up/Click / MouseWheel / MouseHorizontalWheel mouse output actions
ShowClipboardHistory() / ShowClipboardSnippets(...) / ShowClipboardTools(...) / DateTimeSnippet(fmt) clipboard UI actions
ChooserAction base class for custom chooser popups
ThreadedAction background work
Start/Stop/Toggle/PlaybackRecordedKeys() keyboard macros
getLogger(name) console logging

Exact signatures, defaults and per-argument notes for all of these are in the API reference.