← Keyhac crftwr/keyhac on GitHub · craftware

Feature design notes

Per-feature design decisions and the subtle behaviors deliberately carried over from the predecessors (keyhac-win 1.83 / keyhac-mac 1.68 — the frozen feature references). Keyhac2 targets the union of both feature sets; the few remaining gaps are tracked as GitHub issues (migemo matching, cron/periodic API, themes/fonts, i18n, rich clipboard formats).

Deliberate ports of subtle behaviors — do not “simplify” these

Why a Windows key cannot be a user modifier

define_modifier refuses LWin / RWin. The reason is that consuming a key-down does not take the key away from everything.

The Windows sample configuration nevertheless reaches User0 through the Win key, via replace_key("LWin", 235) — the route keyhac-win always took, and one the refusal does not close. That is deliberate, not an oversight: define_modifier promises a modifier nothing outside Keyhac can see, and that promise it cannot keep for a Win key; replace_key promises only “this key is now that key”, which is true. Windows has no other key every keyboard can spare, so the alternative was no sample user modifier at all. What the operator has to know is in the template’s own comment: an action under that modifier must not begin by typing g, and Win+L still locks.

Returning 1 from WH_KEYBOARD_LL keeps the Win key-down out of every message queue, and that much works: no application receives it, and the Start menu does not open on a tap (the shell decides that on the key-up, which is consumed too). Two things survive it anyway.

Win+L locks the screen. Windows reserves that combination, like Ctrl+Alt+Del, and resolves it before the hook chain runs, so the physical Win key is tracked somewhere below where any program can reach. Nothing Keyhac does — consuming, renaming through replace_key — changes it.

Win+G opens the Game Bar, and it eats the keystroke. Live findings, with define_modifier("LWin", "LUser0") in effect:

None of this is new. All of it reproduces on keyhac-win 1.83 - the released build, run portable with its stock configuration, on the same machine, with the Win keys retired by replaceKey/defineModifier exactly as that configuration ships them. Win+L locks; Win+G opens the Game Bar; and a U0-G bound to InputKeyCommand("G","I","T",...) types “it status” there too. The same command under a real Alt (A-G) keeps its g, which places the loss on the Win key being held rather than on anything about the head of an injected batch. So this is not a Keyhac2 regression; the Win key has never been fully retirable, and the sample configuration inherits the trade rather than introducing it.

By what route the Game Bar sees the key is not settled. It is not the route a bystander would take: a probe that consumes a physical key in its own low-level hook and watches for it on the two channels open to any process — GetAsyncKeyState and a raw input sink — sees nothing on either, for a Win key and for an ordinary key alike (tools/-grade scratch probe, not in the suite; the same probe with the key passed through registers on both channels, which is what validates it). So a hook installed after Keyhac’s, and therefore called before it, remains the most likely explanation, and the reserved-combination path that Win+L proves exists is the other candidate. Restarting Keyhac (making its hook the most recent) would separate them and has not been tried.

Two dead ends, both measured:

keyhac-win refused every key that already was a modifier. Keyhac2 refuses only the Win keys: the rest of that rule would break define_modifier("RAlt", "RUser0"), which migrated keyhac-mac configurations use. Redefining a modifier is reported at INFO rather than refused — legitimate, but it costs that key its modifier everywhere, so the sample no longer demonstrates the feature that way.

Related, and a separate problem — about real Win/Alt, not user modifiers: a held Win or Alt whose companion key Keyhac consumed reaches the OS as a lone tap, opening the Start menu or moving focus to the menu bar. keyhac-win cancelled that with a VK_LCONTROL tap, in two places, and both are ported (Windows only; macOS has no such behavior to cancel):

A callable that does send keys therefore emits the tap three times, which is what keyhac-win emitted too, and none of the three is safe to drop on its own:

SEND : D-LCtrl U-LCtrl                          <- before the callable
SEND : D-LCtrl U-LCtrl U-LAlt D-G ... D-LAlt D-LCtrl U-LCtrl

The last one is unconditional: the re-pressed D-LAlt is a fresh Alt-down as far as the OS is concerned, so the user’s own eventual release opens the menu bar unless something marks it used. The middle one is the only chance a key-output assignment gets — those never reach _cancel_oneshot_win_alt. The first is the only chance an action that emits nothing at all gets. The redundancy is between the first two, and only for the case where the callable happens to send keys, which cannot be known before running it.

Both places ask “is a lone Win/Alt held?” of the emitted modifiers only — the user bits are masked out first, because a user modifier is never sent (except in replay, where the original key is reproduced and does count). U0-Alt-V on a chooser is the case that showed why: the OS saw nothing but Alt going down and coming back up, so without the mask the menu bar took the focus at the moment the popup appeared.

Clipboard history

Chooser

Candidate sources

Pointing at what a row stands for

Candidate scopes

The key-bindings source

Making a slow source feel fast

Ranking a merged list

Streaming a source

The actions source

The window-controls source

The menu-items source

Migemo

Console

Balloon

Tray / menu-bar extra

Macro record/playback

Window actions

Mouse output

IME on/off

Data directory and Windows portable mode

keyhac/core/paths.py is the single place that decides where config.py and the state files beside it (extensions/, clipboard.json, settings.json) live. Three ways, first match winning — main() resolves once and hands explicit paths to Keymap, ClipboardHistory and Settings, so nothing downstream re-derives a default:

  1. --config PATH — state beside the named config. Existed before this module; a sandboxed run must not touch the real ~/.keyhac.
  2. Portable mode (Windows) — a config.py next to Keyhac.exe makes the bundle directory the data directory. Straight port of keyhac-win 1.x (keyhac_main.py: “exeと同じ位置にある設定ファイルを優先する”), including the opt-in: the file’s presence is the whole switch, deleting it reverts to ~/.keyhac. No macOS counterpart — an .app is signed and Gatekeeper re-validates it, so writing state inside the bundle is not an option.
  3. ~/.keyhac — the default.

First-run migration from keyhac-win 1.x

keyhac/platform/win/migrate.py. On Windows, on a first run only (no ~/.keyhac/config.py, a %APPDATA%\Keyhac\config.py present), a MessageBoxW offers to copy the 1.x config across — the move migration-from-keyhac-win.md already prescribes before translating. It is a prompt rather than a silent copy because the two APIs are not interchangeable: the copied file will not load until it is translated, and declining leaves the working stock template. Skipped in --no-ui runs (no message box) and in portable mode (which has a config.py by definition). A message box rather than a PuiKit dialog because this runs before the console window exists — the same fallback instance.py’s already-running notice uses.