← 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

A chooser is never built inside the hook callback

The Start menu is above every band we can reach

An element source reads window.element, never window.native

Pressing what a row stands for

given, until and wait: who causes it

The verb-first layer (discussion #98) has three ways to say “not yet”, and one question tells them apart — who causes the thing being waited for.

  Caused by You are Repeating
ui.wait(cond) someone else a spectator there is no act to repeat
given= someone else a gate no — it only refuses to proceed
until= your own act the claim yes, and it is the only place repeating means anything

The test, in order: is there an act at all? No — ui.wait, and waiting is the whole strategy because nothing you could do would help (a file appearing, a job finishing). It returns what the condition returned, because waiting for a thing and receiving it are one motion. Does my act produce this condition? Yes — until, which is the definition of the act having landed, and is a separate clause from the act only because the platform lies about success. No — given, the state of the world someone else has to have arranged first.

Misplacing them fails in two shapes. A given-shaped condition written as until hammers a door that is not open: send_key("Cmd-P", until=Appears(title="Print")) fires Cmd-P into the download popup, again and again, while the popup owns the front. An until-shaped condition written as given waits for something nothing has yet caused, and times out.

One vocabulary, three slots — and the one value that is not portable

All three take the same two types: a zero-argument callable, or one of the values. That is deliberate — the values compose in every slot, so the only thing an author decides is which slot, which is the “who causes it” question and nothing else.

Changed is the exception, and it failed silently until it was looked for. “Changed since when?” needs a moment remembered, and only until= has one: the instant before the act. Given no baseline it compared against None and therefore held immediately — ui.wait(Changed(node)) returned at once, and given=Changed(...) was a gate that never gated. So wait() now takes its own baseline (meaning “changed since the wait began”, a question with an answer) and given= refuses the value outright, naming what to say instead. A condition that always holds is the worst failure available to this layer, and this was one.

A locator is a value, and the verbs take only that

Discussion #98’s open question — “value-like enough to patch, Python enough to stay honest” — settled by counting. In one real action the same selector was written four times: the act, its postcondition, the dismissal and a read. As keyword arguments spread through four calls, those are four places anything repairing the action has to find and understand; as a value they are one, and its repr is a Python literal — storable, diffable, replaceable without parsing code. That is the line the first post drew, and a frozen dataclass lands exactly on it: no grammar, no parser, no config format.

Three forms were on the table. Keyword arguments, which cannot be lifted out. A value class. And a string expression, with focus_path_pattern as the in-house precedent — rejected, for two reasons that outrank its compactness: a path is structural, and resilience to a changed structure is the whole point of the layer, so a notation that encourages structure-dependence works against it; and #98’s own anti-goal says keyword arguments are harder for a generator to get wrong, while a string is the least checkable form there is — a wrong keyword raises, a wrong string silently matches nothing. If a string is ever wanted it arrives as Locator.parse(...), an entrance rather than a replacement.

The verbs take the value and nothing else. They are a high-level layer and need not match UINode.find’s keyword style, which stays as it is: one addresses an element for acting-with-retries, the other is a plain read.

within is deliberately not in the value. Scope is a live node, and a value holding a handle cannot be written down, kept or compared. It stays an argument of the call. predicate is in the value, and is the one field nothing can patch — worth showing rather than hiding, so a reader can see which part of a locator is opaque. And node= remains, because “the third tab from the list the previous step enumerated” is not a locator and will not become one: what to wait for is data, what to act on is sometimes a handle.

What may be combined, and what is refused out loud

locator and within compose: the value says which element, the scope says where to look for it. That is the pair the design is built on.

node excludes both, and says so rather than quietly outranking them. Passing a node and a locator used to drop the locator in silence, which is “the argument I wrote was ignored” — the same failure as a press that reports success and does nothing, and it reads on screen as acting on a stale node while the code plainly says otherwise. Same for node with within.

Appears had the same hole from the other end: a locator together with title but no app fell through to “return the window”, answering a question about a button with a window. The rule is now one sentence — with a locator it is an element question, without one a window question — and app / title scope the search rather than replacing it.

scroll had a worse version of it, which was ambiguity rather than silence: within meant the view with no locator and where to look for the view with one. Documented as such, which does not make it better. It takes node for the view in hand now, like every other verb, and within means one thing everywhere.

Which verbs exist, and the test they had to pass

click, send_key, activate, fill, scroll, choose. The test for admission is not “an action does this often” but does it have an act, and a postcondition a caller would otherwise loop over. Reading has no act, so find stays a read. Scrolling to click something has an act but the postcondition belongs to the click, so it lives inside the ladder rather than in the vocabulary.

scroll is in the vocabulary anyway, for the case reading cannot serve at all: a virtualised list has no element for a row it has not drawn, so no locator finds it and no walk bound helps — the only way to read the fortieth row is to bring it into view. Its hazard is the opposite of click’s: not double-acting but scrolling past, so its retry_interval is short and it looks between turns rather than after a page of them.

fill is the thin one, and knows it: set_text already focuses, verifies the focus landed, writes and reads back. What the verb adds is the locator, the precondition, and the one shape. It deliberately does not retry a FillFailed — that means the write happened and the read-back disagreed, so repeating it is the double-act hazard; a field that is not ready yet is a given=.

menu is macOS-shaped and says so. There the menu bar is an OS-level part readable in full while closed, so it finds the leaf in the closed tree and presses that, opening nothing on the way — which is what MenuItemsSource already relies on. Windows has no menu bar in that sense, and it raises naming the fact rather than half-working.

It was called choose for a day, which was a naming failure worth recording: the first reader took it for “pick an option out of a list” — a dropdown, a combo box, a picker. That is a different verb, and a plausible one, but it is not in the vocabulary yet on purpose: no action has asked for it. Every verb here arrived because a real action needed it, and the one picker in the one real action turned out to be a sidebar row, which is a plain click. Deferred, not forgotten: when a picker does ask, the discussion reopens, and choose or pick is the name waiting for it.

One unqualified interval, and a word on every other one

UI.wait(interval=) is released and means the polling gap, so that settles what a bare interval means: how often to look. Every other rate carries a word saying which it is — retry_interval on the verbs, how long to watch before acting again.

The pair was briefly interval and retry_every, which was unreadable from the names alone: two different grammatical shapes, neither saying which layer it belonged to. A shared suffix with a discriminating prefix is the fix, and it leaves room — if looking ever needs exposing on a verb it arrives as interval there too, beside the released name rather than against it.

Only retry_interval is a parameter today, because only acting again can be got expensively wrong: too short, and a print dialog that takes three seconds to open is opened three times. Looking is wait_for’s, whose default backs off — quick to notice a fast answer, cheap when the answer is slow — and one waiting policy for the whole codebase rather than a second one in ui.py.

given exists because of the retry, not to save a line

ui.wait(X); verb() and verb(given=X) are the same thing when there is no until — the implementation’s until is None branch is literally wait-then- act, and given there is sugar. With until, they are not the same: the hoisted ui.wait guards only the first attempt, while given is re-checked before every one. And a retry means time has passed and something moved, so the moment a hoisted guard is least trustworthy is exactly the moment it is used. Hoisting given out means hoisting the loop out with it, which is the thing this layer removes.

Two smaller reasons, neither load-bearing: the failure message names the guard and what it was guarding (sending 'Cmd-P' never started: waited for the front window…), which matters under a premise of mechanical diagnosis; and one call per step is one shape for a generator to get right rather than two.

Stable is a given, never an until — and Changed is gone

Run Stable (the screen has stopped moving) through the same test and it falls out. It is not something an act produces — it is the absence of change — and it is satisfied by the calm before the act’s effect starts as readily as by the calm after. until=Stable(...) therefore has a race built into its meaning. given=Stable(...) is coherent: too-early satisfaction just means acting on a quiet screen, which was the point, and per-attempt re-checking is right because the screen can start moving again between tries.

“Click, then wait for the dependent field to re-render” is then not the verb’s business at all — it is the next step’s precondition, or a bare ui.wait(Stable(...)) before a read.

And the rule that generalises: where a state can be named, name it — Appears(text="page 2"), Reads(row, value=…). Stable is the escape for what cannot be named, and an escape is easy to reach for, so it says so in its own docstring.

Changed — the same escape for state rather than timing — was taken out of the public surface, having been the thing that was wrong three times: it is a difference-wait, which the authoring rule above exists to warn against; it held immediately wherever no baseline was remembered, which was silent; and its timing twin turned out to need refusing as a postcondition for a third reason again. Reads says what Changed was reached for, and says it correctly. The whole baseline mechanism went with it — nothing else needed a remembered moment, and an abstraction kept for a future caller is one nobody has specified.

Stable is refused as an until by the API rather than by documentation (Condition.postcondition), and it cannot be asked at an instant at all: its check() raises, because a single look cannot answer “has it been quiet”. It brings its own wait(), which is wait_for_stable — the one value whose question is about a stretch of time, and the reason Condition has a wait() at all.

There is no Menu page on Windows

Candidate pages

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

Where a popup opens (issue #118)

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.