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).
KeyCondition hashes by vk only, with an L/R-agnostic __eq__ — the dict-lookup
trick behind side-agnostic matching.force_LR).keyhac/platform/mac/hook.py. The third (fresh real
input overtaking re-posted deferred reals during the flush window) was found by the
ordering verification in testing.md.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:
LWin-G opened the Game Bar, although Keyhac emits no Win key at all
and the binding could not match (LWin-G parses to MODKEY_WIN_L, and the key
now produces MODKEY_USER0_L);U0-G bound to an action typing “git status” typed “it status” — the injected
g was swallowed as Win+G. The other letters survived: Win+I, Win+T, Win+S
and Win+A are shell hotkeys, and those go through the path that did honour the
consume.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:
replaceKey("LWin", 235) then defineModifier(235, "User0"),
which is how its sample configuration reached User0 — changes nothing. It renames
the key inside Keyhac; the physical event is identical.define_modifier
promises, so the key is refused instead of patched around.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):
InputContext.send_modifier_keys taps Ctrl between its press and release loops
whenever a lone Win/Alt is being released or taken — the transitions that
reconciling modifiers around a batch produces.Keymap._cancel_oneshot_win_alt taps before a callable action runs and before
entering a multi-stroke table, for actions that emit no key output at all.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.
max_items=1000, label truncation, size quotas);
persistence ~/.keyhac/clipboard.json, format-compatible with keyhac-mac.ClipboardProvider.poll():
sequence-number probe on Windows, changeCount on macOS (~1 s tick — history does
not need 33 ms latency).Ctrl-V/Cmd-V injection. Shift-select = copy-only.ChooserAction.list_items/on_chosen) — keyhac-win’s
blocking popListWindow is deliberately not carried over (its nested message loop
was the worst reentrancy source in 1.x).Matcher (keyhac/core/matcher.py). The default is
multi-word AND substring (keyhac-mac behavior) unioned with Migemo, so romaji
finds Japanese; WildcardMatcher restores 1.x’s */?. Migemo only ever adds
matches — see Migemo below.ListView(allow_no_selection=True), puikit
PR #126) — not the muted unfocused highlight, which still reads as a proposal.
Down steps into the list, Up off its first row steps back out, and so does
anything addressed to the query: typing a character, editing it (Backspace,
Delete) or moving its caret (Left/Right, and their modifier forms — Ctrl for
a word, Shift to select, Cmd for the line start on macOS — which arrive under
the same key names, so naming the bare keys covers the derivatives). The key
is then dispatched as usual and lands in the field, so the keystroke that got
you there is not spent on getting there. Backspace used to do nothing with
the focus in the list, which reads as the window ignoring you when the query
is on screen with a caret in it. Home and End are the exception, and belong
to the list: a list long enough to want a first and last row wants them for
that. They are routed through the window’s own _navigate rather than
answered by the ListView, because every move of the selection has to update
the screen mark too — the ListView answering them itself moved the highlight
and left the outline on the row the selection had just left. Enter
takes the selected row, or the top match while the field has the focus: typing
a few letters and pressing Enter is the flow the window exists for, and making
Enter inert there would have been a regression. A click picks a row and moves
the focus into the list but deliberately does not choose it — the payload
can be a destructive action, so choosing stays an explicit Enter.Frame, so focusing it means the page
focuses the frame and the frame focuses the list; naming the list to the page
marks nothing and the selection draws in the muted unfocused colour. The
symptom was a grey highlight that turned the accent colour only while a mouse
button was held — the press ran focus_on_click, which marks the frame
correctly, and the release handler then put it back to the broken form._DismissWatch in actions.py).
A chooser is transient, and nothing used to end it but Enter/Esc/the hotkey — so
one could survive on another virtual desktop, and the hotkey then toggled closed a
window the user could not see, which read as the chooser refusing to open. Two
triggers: the frontmost window changed, or a click landed outside it. The first is
one observation covering three cases — a window belongs to exactly one desktop, so
a desktop switch necessarily changes which window is frontmost, as do an app
switch and a window switch. It is keyed on (pid, window title) and deliberately
not on the focus path: the macOS path runs down to the focused element, so
it changes when the user Tabs between fields and would pull the chooser out from
under them. Both triggers treat “could not read it” as “change nothing”.
Buttons only, never the wheel. The mouse hook fires on both, and both
still cancel a one-shot — but macOS scrolls the window under the pointer
without focusing it, so dismissing on a wheel turn made the chooser vanish
whenever the user nudged a background list. Spotlight survives that; so
does this. InputHook’s on_mouse carries "button" / "wheel" for it.
Keyhac’s own process is also “could not read it”: the chooser is our
window, so the focus landing on us is the chooser’s doing, not the user’s —
on macOS a click on the popup can make us the AX-focused application even
though a borderless window cannot take key status, and the activating path
focuses us on purpose. Without that check a click on the chooser could close
it, and an activating chooser would have closed itself on its first tick.
Dismissal never refocuses anyone — the user moved away on purpose.NSWorkspaceActiveSpaceDidChange,
SetWinEventHook(EVENT_SYSTEM_FOREGROUND)) and would be two platform-layer
implementations, while FocusProvider.get_focus() already runs on every key
down — a user typing pays it faster than the watch does.activateWithOptions: for self-activation, so there is no narrower call);
reopening could jump to another Space, because the OS follows the app’s frontmost
window; and pasting needed a 150 ms settle delay because the target application
was deactivated and reactivated around it. ChooserAction.activates = True opts
one source back into the old behavior, with the old costs.activates=False alone stops the window
taking focus when it opens; it does not stop a click activating the
application — borderless prevents a window becoming key, not the app coming
forward. That was a real defect: clicking the chooser deactivated the window
underneath and the paste then had nowhere to go. The window is a PuiKit
window PuiKit builds for overlay_input="mouse" (puikit PR #126): clicks reach
it, the application is never activated, and the target keeps its focus, caret
and selection. WS_EX_NOACTIVATE already refuses both on Windows, so the flags
are inert there. frameless goes with them: the panel’s mask forces a title
bar (a borderless panel cannot become key), and frameless is what hides it
again and puts the content rect back to the frame rect. All of these travel
with activates rather than being separately settable, so no caller can ask
for a combination that does not work.gijiroku against ぎじろく plus a
conversion). That is why pymigemo is a hard dependency and not an extra.
The alternative was live and was declined: PuiKit’s overlay_input="keyboard"
gives a window that is key while the app stays inactive — the Spotlight
shape, in which an input method does work — but it is macOS-only, it means
dropping the hook route there (a key window gets the keystrokes itself, and
both paths at once double every character), it takes key status away from the
window being pasted into, and it would leave the two OSes with different
input paths. Not worth it for what Migemo already covers.ChooserAction
promises that the key which opened a chooser closes it and that another
chooser’s key replaces it, and for a non-activating one neither happened: a
grab outranks every table, so neither press ever reached an action. Both
arrived in the filter field as the letters they were bound to — Fn-P
typing a p, Fn-V typing a v instead of opening the clipboard.
push_modal_input therefore takes a second callback, asked of a modified
key down with whatever the tables bind it to; the chooser answers True for
a ChooserAction, and the key is left to the tables. __call__ then does
the toggling and the replacing it has done all along.Left stays the list’s while the window
is up rather than the application’s underneath, and an unmodified key is
text as far as a filter field is concerned. The narrow version of this fix
passed only the opening key back, which left Fn-V broken in exactly the
way Fn-P had been — half of one promise.Keymap.push_modal_input
plus keyhac/ui/keyroute.py. That route carries letters, digits, space and the
named keys, and — through InputHook.char_for_key — every digit and
punctuation mark the active layout produces. That last part is not a table of
our own: a vk does not say which glyph it makes, and Keyhac’s per-layout tables
map names to codes rather than codes to glyphs, so the OS is asked instead
(NSEvent.eventWithCGEvent_(...).characters on macOS, ToUnicodeEx on
Windows) — the same translation it performs for a real keystroke, so it follows
whatever layout is selected. Without it the filter field could not type . /
- _ @ at all, which for clipboard history full of paths and URLs is most
of what one would filter on. What the route still cannot carry is an input
method, ever. Composition follows OS
keyboard focus: IMM32 delivers WM_IME_* only to the focused HWND and
NSTextInputClient serves only the key window. That is why Migemo is part of the
default matcher rather than an option — for a localised list it is what makes the
filter reach the rows.on_key_event normally offers Esc to a running ThreadedAction
before the key tables. While a grab is up it does not: Esc there means “close this
window”, and the window is what the user is looking at.CandidateSource, not Source. from keyhac import * is
flat, and a config writes class Branches(CandidateSource) with no
surrounding call to say which kind of source is meant. ChooserPage keeps the
short name because it is only ever written inside ShowCandidates([...]),
where the context is right there — the test is whether the name appears
somewhere that supplies its own context, not whether the word is generic.keyhac/core/source.py). While the
only way to offer a new kind of row was to override list_items, every new
capability cost an action class and a hotkey to reach it — and the hotkey
is the scarce resource, not the code. As values, several sources go into one
window and one key reaches all of them, with one incremental search across
the lot.candidates(),
rebuilt on every invocation, because anything read from the screen is stale
the moment it is cached) and what choosing one does (on_chosen(),
declared once per source because rows from one source almost always do the
same kind of thing). Candidate.action overrides it for a row that differs —
which is what a unified window needs, since Enter there has to mean whatever
that row means.name is on the source, not the candidate. The window shows each row’s
source beside it, and it already knows which source produced which row, so
copying the name onto every candidate would store the same fact twice.
ChooserAction._collect keeps that mapping for the life of one window.row_factory widget (keyhac/ui/candidate_row.py), not a
PuiKit addition. A trailing label is a reasonable thing for a list widget to
offer in general, but this badge belongs to the unified window, and
building it app-side is also the honest test of whether ListView’s
row_factory is flexible enough. What the toolkit does not hand over with it
is the eliding it applies to plain string rows, so the widget does its own —
and elides the two texts independently, the label yielding only the width the
badge needs, since clipping a long clipboard entry to fit a short source name
would lose the part that makes the row legible.Candidate. It used to unwrap a
tuple-derived row back to its tuple, which meant the window decided how a
row would be routed and a source that legitimately yields tuples had its own
on_chosen skipped. Unwrapping for the pre-source ChooserAction.on_chosen
is the action’s business (_chosen_legacy).ShowClipboardHistory and its siblings are presets over
keyhac/core/sources.py. Porting them was the point as much as the result:
they are the sources that already existed, so if they had not fitted, the
shape would have been wrong. The CandidateSource suffix on the source classes is not
decoration — ClipboardHistory is already the name of the history store,
and two public things with one name in a flat from keyhac import * is a
trap.ChooserWindow._point_at_selection, PuiKit
mark_screen). This is discussion #112’s argument for node.highlight()
made concrete: a row that cannot describe itself — an icon-only control
listed by its tooltip, or by role and position — is a row whose text does not
settle which one it is, and lighting the real one up is the only
confirmation there is.Candidate.rect, which the controls source
fills on every row and nothing read until now.ChooserAction.__call__ runs on the hook’s stack, and the engine passes
the key through when handling it raises — a deliberate rule (a broken
config must not swallow the keyboard) that turns any failure while building
the window into the key that opened it arriving in the user’s document. When
the failure came after the window existed (the dismissal watch, the
activation) the chooser was on screen and the “P” of User0-P had
leaked, which is exactly what was reported.keymap.call_on_main_thread. Both backends queue that
rather than calling back on the spot, so the window is built a millisecond
later with nothing left on the hook’s stack to fail into. With no dispatcher
wired (a library use, or a test) it runs inline, which is what every caller
there already had._pending is what the queued callback checks before building anything.LowLevelKeyboardProc’s
documentation: the callback must finish inside LowLevelHooksTimeout
(HKCU\Control Panel\Desktop, in ms; capped at 1000 ms since Windows 10
1709), and past it “the hook is silently removed without being called. There
is no way for the application to know whether the hook is removed” — the
event that overran reaches the application regardless. Its own advice is the
shape used here: return immediately and hand the work off. Building a
chooser is past that budget — measured: 390 ms for this module’s import of
the chooser alone on a first press, before a source is read (84 ms for a
small menu bar, 590 ms for a heavy window’s controls). macOS disables a slow
tap in the same spirit, but says so (kCGEventTapDisabledByTimeout), which
is why the asymmetry below is Windows-only.LowLevelHooks
Timeout is unset here. The caveat is that the keys were injected
(SendInput); a physical key may be judged differently, and the symptom was
reported for physical ones. So: the documented hazard, not the demonstrated
cause.LowLevelHooksTimeout, and no
registry write of any kind, anywhere in keyhac-win; pyauto’s kbhook.cpp
has none either and calls the Python hook synchronously inside the hook proc
exactly as this port does. Its answer was recovery, not prevention — 1.x’s
changelog: 「キーフックが強制解除されたことを検出し、フックを再設定するように
した」 — which is checkSanity, ported here as InputHook.check_health().WinInputHook.SLOW_CALLBACK_SECONDS, 200
ms) and warns. The sanity check recovers after a silent unhook; the
warning is the only evidence available that a key which leaked was a key
that overran, and the only notice at all before the recovery. It is
Windows-only because macOS’s tap reports its own timeout._foreground_is_out_of_reach() reads the foreground
window’s integrity level on every strike (0.16 ms, and a strike is
already “something looks wrong”) and, above our own, calls it what it is.
Every strike rather than the fourth: four hundred milliseconds is long
enough to close Task Manager in, and the evidence of blindness would then
be four ticks old while the foreground is read now. Keyhac’s bindings genuinely do not work in such a window and cannot
without running elevated: that is Windows protecting it. Calling it a force
cancellation was the bug. “Cannot tell” counts as in reach — the hook being
gone is the case that costs the user their keyboard.release_stuck_modifiers() therefore releases every modifier
GetAsyncKeyState still reports as down, right after the re-install and
before on_restored resets the engine’s own state. A key genuinely still
held is released with them — this runs only where events have already been
missed, and a modifier that has to be pressed again beats one nobody can
find. MASK_VK (0xE8, unassigned, AutoHotkey’s masking key) goes first
when a Windows key is among them: a Win down followed by its up with
nothing in between is the Start menu’s shortcut, and the down already
happened.A z-order band is absolute. WS_EX_TOPMOST and SetWindowPos sort a
window within its band and cannot lift it out of one. Measured on Windows
11 26200 with the Start menu open — which is SearchHost.exe’s
Windows.UI.Core.CoreWindow on this build and
StartMenuExperienceHost.exe on others, which is why the check is a band
and not a name:
the Start menu band 6 (ZBID_IMMERSIVE_MOGO)
a PuiKit window / a screen mark band 1 (ZBID_DESKTOP)
SetWindowPos(HWND_TOPMOST) moved ours no further than the top of band 1.
So a balloon under the Start menu is hidden, and there is no arrangement of
window styles that changes it.
uiAccess="true", Authenticode-signed and installed under %ProgramFiles%,
may create windows in ZBID_UIACCESS and is exempt from UIPI — it would fix
both this and the Task Manager blindness above. But a packaged app cannot
have it: a Desktop AppX process with uiAccess fails to launch outright
(UI Access is not supported for Desktop AppX processes, 0x80070032,
microsoft/WindowsAppSDK#1669), and Keyhac ships through the Store as an
MSIX. The classic road is shaky too — uiAccess apps meeting every stated
requirement are reported failing to launch on 24H2. So: accepted, not
fixed.WindowProvider.foreground_hides_our_windows() answers whether
the window in front is in a band above ours (GetWindowBand, undocumented,
exported since Windows 8, read-only, and False on any failure), and a
non-activating chooser refuses to open when it is. The activating path is
exempt: taking the focus closes the Start menu, so that chooser is seen.
Refusing to open is the whole remedy — there is nothing else safe to do
with the key, since the alternative is a window that captures input nobody
can see.window.element, never window.nativeMenuItemsSource, WindowControlsSource) go
from the front window to an element. native is the platform’s own
object, and that is an AX element on macOS but the HWND wrapper on
Windows — which has no children(), no role() and no menu_bar(). Both
sources caught the resulting AttributeError, logged at debug and returned
nothing, so on Windows the Control and Menu pages were silently empty while
the identical code worked on macOS.Window.element is the documented bridge for exactly this (“macOS already
holds the AX element; Windows resolves the HWND through UI Automation”) and
answers an element on both. The tests missed it because the fake window
offered native, i.e. the macOS shape; they now offer the Windows one
(native is the window) and would fail on the old code.keymap.focus: a source
that reaches for a platform object gets a different platform object on the
other OS, and every one of these has surfaced as an empty list rather than
an error._press(element) for both sources, and it asks the element which
actions it has before trying any. “AXPress” is not a Windows action name
that happens to be unavailable — it is one UIA has never heard of, and the
Windows element logs a warning for each unknown name, so probing the pair
blind put Unknown UI Automation action: 'AXPress' in the console on every
single press. An element that cannot say is still probed in order.AXPress activates a
button, a tab, a checkbox and a disclosure triangle alike, while UIA gives
each its own pattern — so the list is Invoke, Select, Toggle, Expand, in
the order of what a click would do: run it, else select it, else flip it,
else open it. Measured in VS Code, which has all of them: 279 rows offer
only Invoke, 19 only Select, 26 only Expand/Collapse, 5 only Toggle. A list
of Invoke alone therefore refused every tab in the activity bar — the
reported symptom was Extensions (Ctrl+Shift+X) - 1 requires update: the
control refused to be pressed.set_focus(), so the fallback
is portable rather than a Windows special case.AXEnhancedUserInterface unset, AXPress on
the panel’s Problems tab and on the activity bar’s Search returns success
and moves nothing; with it set, the same presses switch the panel and the
sidebar. The same holds for a plain <button> in a page in Chrome: pressed
with the flag unset, its click listener never runs.AXPress on its
New Tab button opens a tab with the flag unset — measured. So a chooser row
can look like it works in Chrome, where the Control page is mostly browser
chrome, and not in VS Code, whose entire UI is the page. That is the trap
this fix removes: which rows work depended on which layer drew them.keymap.ui.enable_content_access(), which
belongs to the AI integration and is off unless the operator turns the MCP
server on — so a control press worked in a session where an Action or an MCP
tool had touched the same application, and silently did nothing in one where
none had. That is also why the source does not simply call it: the flag puts
VS Code into its screen-reader-optimised rendering, which is why enabling it
is an explicit call and never something a walk does on its own. The row said
“press this”, the press said yes, the panel did not move, and there was
nothing to report — the silent wrong thing §3.7 exists to refuse. So
WindowControlsSource clicks: it is what the row meant (the source lists
what you could click) and it is the one thing no widget can accept and
ignore._press, so a menu item (its menu is closed, so nothing of it is
at its rectangle) and an off-screen control keep the behaviour they had.CFEqual; Windows answers None to identity_key() — its control view is
a real tree, so nothing pays for a runtime id — and None == None would
make every element the same one. The description (place, role, name) stands
in there.MacFocusProvider.get_focus() turns it on (measured 13 → 443,
with AXEnhancedUserInterface still False), and _check_focus_change calls
that on every key down and key up. So Keyhac has always paid for its own
Control page as a side effect of focus tracking, one keystroke before anyone
could open the window — which is why the page is never empty in practice,
and why measuring “is the flag needed to read?” on a running system always
answers no. AXManualAccessibility alone turns it on too, and unlike
AXEnhancedUserInterface it does not put VS Code into screen-reader
rendering — worth remembering if a Chromium application that has never been
frontmost ever needs reading.AXScrollToVisible, which every Chromium node offers. Doing it while the
selection moves was rejected: a list that scrolls the document you are
reading it against changes what it is describing, which is the same reason
ChooserWindow does not confirm on a row click. It also avoids the question
it would raise — the outline is drawn from the walk’s rectangle, so a scroll
under it would leave the outline pointing at nothing. At click time the
window is already closed and there is no rectangle left to invalidate. The
wait for the scroll to land is hard-bounded at 150 ms because this runs on
the event-loop thread.logger.error
alone is exactly what “nothing happened and nothing was reported” looked
like, because the console is not up during a keyboard flow — so the
fallback path pops a balloon too, the way ReportCaretAnchor answers a
keystroke. It goes over the window rather than at the control: the control
not being where it said it was is the failure, so there is nothing to point
at. The hit test therefore has three answers, not two — True, False, and
None for “this platform has no hit test” — because reporting the third
as the second would put a warning in front of every press on Windows.UI.content_access), but a press only starts working about two seconds
later — 1.5 s and 2.0 s pressed nothing, 2.5 s worked. A chooser answers a
selection immediately, so it has no two seconds to spend, and a retry loop
is not open to it either. An action has both, which is why the verb-first
layer is where until= belongs (discussion #98) and the chooser’s answer is
a click that cannot be inert in the first place.act.click refusing a covered point stays a plain refusal for everybody
else. The looks are bounded because another application’s window is not
going to move, and the user is owed the message rather than a longer wait.given, until and wait: who causes itThe 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.
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.
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.
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.
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.
interval, and a word on every other oneUI.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 lineui.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 goneRun 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.
MenuItemsSource possible
— every command in the application, flattened, without opening anything.MenuItem reports no children (measured, Notepad and tk alike).
Reading the leaves would mean expanding each item, finding the popup (hosted
outside the item, not under it), reading it and collapsing again — menus
visibly flashing open, a cost per top-level item, and a modal menu loop in
the target application while it happens. Attempting it against a tk window
wedged the UIA call until the process was killed.WindowControlsSource already lists them (role MenuItem) — measured on a
tk window: File, Edit, Shell, Debug, Options, Window, Help. Choosing one
opens that menu, which is what clicking it does.UIElement.menu_bar() returning
None on Windows. MenuItemsSource asks the platform whether there is a
menu bar, and there the honest answer to that question is no — so the
decision lives in the platform layer and keyhac/core/ stays free of
OS knowledge, rather than the source branching on the platform name.@Name is which of the sources on that page you
meant. Conflating them is what the first two attempts did, and it is what
made them confusing.Ctrl-Left is a word, Shift-Left a selection and
Cmd-Left the line start, and all three arrive under the same key name;
taking them too would leave the field unable to do any of it. Bare Left and
Right therefore left _FIELD_KEYS, and their modifier forms are let through
beside it.@Name narrows to one source, inside the current page. It filters by
the name the badge beside every row already shows, which is the whole
reason it does not need explaining: the thing the sigil names is on screen
before the sigil is typed. @Action on a page without that source empties
the list, and the empty list explains itself.act
to filter and landing in a different page is the symptom. A sigil cannot do
that: the user typed it, so it is already on screen.@ touches the
query’s survival. The second was overstated: the wildcard alphabet is *
and ?, and Migemo’s input is romaji, so @ is reserved by neither. What
is true is narrower — @ at the start of a token can be a thing someone
wants to search for — and that collision is visible, unlike the one it
replaced.@ narrows to nothing and is not searched for. Treating it as
text empties the list for one keystroke, which reads as “no results” at
exactly the moment the user is starting to say which source they mean.@ stays in the field as ordinary text, rather than becoming a chip:
Backspace then edits it like anything else, and nothing has to be built to
draw it.@ token and opens no list. The names are on screen
already, and the chooser’s only list is the rows — borrowing it to show
sources would cover the badges that are the answer. So Tab commits nothing,
opens no mode, and has nothing to dismiss; where it cannot lengthen, the
badges are the list. It does nothing at all to a bare word, which is the
surprise it replaced.source_of is not badge_of. With one source the badge slot belongs to
the source itself — the menu source puts a keyboard shortcut there — so the
two questions have different answers, and only one of them is always a
source’s name.@ is what makes it enough. The
config shipped eight, and it shipped eight because a page was the only way
to narrow to one source. Give @ that job and the pages collapse to three:
Run, Paste, Click. More than three is a key of your own bound to another
ShowCandidates — a key is the scarce thing, but it is yours to spend.@ is which of the things there
you meant. That is what picked those three words. The first names were Do,
Paste, Screen — two verbs and a place, because page 3’s rows share no single
verb (a menu item is invoked, a control pressed, a window raised), and
“Screen” was the only word covering all three. But the query carries across
pages, and naming them for what Enter does is what gives that carrying a
meaning: type mail, and Left/Right asks run it, paste it, click it. Under
place-names the same gesture only says “look somewhere else”. “Do” also
excluded nothing — every row in the window does something — and sat directly
above its own near-synonym, the Action badge. The one thing paid for is
OpenWindows on the Click page: raising a window is the weakest fit of the
three, and worth it. Both sources already describe themselves in the word —
WindowControlsSource is “everything you could click in the front window”,
and MenuItemsSource ends “with everything else clickable”.ChooserPage, not Scope. Renamed while it was free: Scope had never
shipped (v2.2.3 predates the candidate window), and once @ does the
narrowing, “scope” actively misleads — it sounds like a filter. Long name
for the same reason CandidateSource has one: keyhac import * is flat.Keymap.effective_keytable() — the table the hook resolves
against, not a re-derivation. Answering “what can I press here” by walking
the configuration again would be a second implementation of the
merge-in-definition-order rule, and the two would drift. It also means the
armed multi-stroke table is what shows when one is armed, which is the right
answer to the question.Fn-X › A is the sequence you would type,
and those are exactly the bindings nobody remembers. The prefix itself is
not a row — it is not a command.D- is stripped from a key expression because almost everything in the list
is a key down; U- and O- stay, because those are the unusual thing
about the binding.MenuItemsSource that
sits both in an everything-page and in a page of its own. Sharing the
instance is how a config says “this is the same source”; building two says
they are two, which is the right answer when they differ — two
SnippetsSource with different snippets are not interchangeable.platform/base.py says AX into our own process off the main thread
crashes with SIGTRAP, and that had been read as “accessibility calls must
stay on the main thread”. The sources that are slow read other processes
by definition. See “Streaming a source” below for what was measured. What is
still true is the other half of the objection: walking on every focus change
spends the cost for windows the user never asks about, so prefetching is a
question about when, not about whether it is possible.save listing every clipboard entry containing “save” before
File › Save. Match.rank(text) is the sort key, lower first.spans(), so a matcher earns ranking by being able to
localise its hit and needs no separate implementation. The empty query ranks
everything equal, so an unfiltered window keeps the order its sources
produced — clipboard history newest first.set_items drops the viewport, so _append restores the offset before
moving the selection. The other order leaves the selection off-screen
whenever ranking moved it.Match is kept on the window. Recompiling per arriving
slice would pay Migemo’s whole cost — the regex build — dozens of times
while one source streams.background. False
by default, which keeps a generator on the main thread in 2 ms slices —
right for anything touching the UI or the keymap, and the only safe default
for a source someone else wrote. The accessibility sources set it True.work / 2 ms × 100 ms. Measured end to end, Chrome’s
menu bar: 11.7 s to fill. This is why the earlier note here — “first row
at 16 ms, all 89 over 344 ms” — did not match live use: it timed the
generator, not the delivery.kAXErrorCannotComplete on AX’s own ~1.5 s messaging timeout) while the main
thread’s Python throughput did not move — 4.91M vs 5.0M iterations per half
second. The contention also runs the right way round: a main thread busy with
Python starves the worker (149 AX calls/s against 24,400), not the reverse,
so a keystroke never queues behind the walk. The cost to a hook-sized piece
of main-thread work, with the walk running flat out, is 0.19 ms median
against 0.06 idle.candidates() is always called on the main thread, whatever background
says; only the generator it returns moves. That is where a source resolves
what it needs from the UI — which application is frontmost, which window —
and WindowControlsSource was reshaped from a generator into a function
returning one so that its get_active_window() stays there._append’s re-rank and redraw once per row; per completion would
give up the streaming the whole shape exists for._collect splits the unfinished sources by
background and hands the window two generators. Merging them would put
every row of both on whichever thread drained first.MemoryBackend has no
call_on_main_thread — the base raises — so there is nowhere for a worker to
land rows. The tick drain already had this shape for the same reason.ChooserAction._collect splits at the source level: lists go
straight into the window, generators are left for it to drain.add_items, which is the one part of its shape that transfers unchanged.MemoryBackend.run_animation_ticks), which is also what
proves the rows arrive through the pump rather than from the constructor.keyhac/mcp/extensions.py’s AST scan reads a file without executing it, so
a module no config.py imports stays inert on disk and a class runs at one
moment — when the operator picks it. Auto-registration via
__init_subclass__ was considered and cannot work here: registration
happens at class-definition time, so it would see exactly the actions
already bound to a key (which KeyBindingsSource already lists) and miss
every unbound one, unless every half-written file were imported to
enumerate it.ThreadedAction subclasses only — not every callable class. A class
defining __call__ binds to a key perfectly well and is deliberately not
offered: the main thread services the keyboard hook and every PuiKit
window, so a list whose rows might block it is a list that can freeze the
keyboard. The vocabulary makes this confusing (there is no Action class,
and “action” is otherwise a role) — recorded in
next-major.md.KeyBindingsSource: one asks what the keys do, this
asks what there is to run.roles= does not reduce that
(495 ms — the walk is the cost, filtering only changes what is reported),
and depth trades everything away (VS Code yields 4 controls at depth 6
against 117 at depth 40). So the walk yields, and the first controls appear
in ~16 ms while the rest arrive over the rest of it.Where the time actually goes, over VS Code’s 4000 nodes:
| ms | |
|---|---|
AXChildren traversal alone — the floor |
224 |
+ one AXRole read per node |
332 |
+ describe() on every node |
587 |
So the walk reads the role first and describes only the elements the
role says are worth reporting — 376 ms live, against 590 before, for the
same 157 controls. describe() is a batched read of nine attributes and
most of a window is groups and static text, so describing everything paid
eight reads per node to learn one thing.
AXVisibleChildren was tried as a cheaper traversal and is not one: the
window answers it with nothing.seen set is load-bearing, not cycle paranoia. A table’s cells are
children of their row and of their column, so without it every cell of
every table is reported twice — the same finding uitree.py records.
Windows returns no identity (its control view is a real tree), and two
distinct controls must not collapse into one, so “no key” means “do not
dedupe” rather than “the same”.provenance was for, and the numbers justify it. Of VS
Code’s 131 controls, 113 are named only by AXDescription and 18 by a
real title; Claude’s 134 split 80/53/1. Without the name fallback chain
the source would list 18 controls instead of 131. Recording which
attribute answered matters because it decides what else can find the
element: one reachable only through its tooltip cannot be found by
find(name=...) in an action either.UTF-8 whose tooltip says Select Encoding is the obvious argument for
preferring the tooltip, or for searching both. Neither survives the data.
That particular tooltip is not in the accessibility tree at all — the
button carries AXDescription = "UTF-8" and an empty AXHelp; VS Code
draws its own, so nothing reachable holds the better name. And across every
running application, the controls whose names do disagree fall into three
shapes and none of them is a better name: identical text, one a truncation
or continuation of the other (Copy code against Copy code to clipboard),
or boilerplate that belongs to the page rather than the control — GitHub
puts “This path skips through empty directories” on a path segment, and
making it searchable would match that link on a query about empty
directories. In a ranked merged list a false positive costs more than a
missing alternative name. The sample is Chromium-heavy, which is not an
accident: there AXHelp is the HTML title attribute, so duplication and
boilerplate are what it is for.help-derived name is the weakest of the three and it shows — macOS’s
window zoom button lists as “this button also has an action to zoom”,
which is a tooltip sentence rather than a label. Kept, because a bad name
beats no row, and provenance is what a future view can weigh it by.rect is exercised for the first time here, on every row. It is what
the overlay view will draw against.roles= does not help (495 ms — the walk is the
cost, and filtering only changes what is reported), nor does pruning content
subtrees (433 ms). Depth is the only lever, and at depth 6 VS Code yields 4
elements instead of 117. There is no cheap way to enumerate a heavy app’s
controls. The menu bar, by contrast, is bounded and almost entirely named:
77–202 items in 84–298 ms. That is why menus came first.ChooserPage of its own for the same reason — a merged page
opened on every keystroke cannot afford a quarter of a second.0x08 clears the otherwise implicit
Command, and 0x10 is Fn. Terminal’s Split Pane (Cmd-D) reports 0, Show
Next Tab (Ctrl-Tab) reports 0x08|0x04, Fill (Fn-Ctrl-F) reports 28.keymap.focus — the same trap
that made the dismissal watch close on a click. A Focus mixes its sources:
its pid is the frontmost application, which the popup never becomes, while
its window title and element come from the AX-focused application, which
the popup does become. Reading the menu bar from there gets Keyhac’s,
and Keyhac is an accessory app with no menu bar — so the page came up
empty. get_active_window() reads the frontmost application throughout.pymigemo — pure Python, BSD-3, dictionary bundled in the wheel.
Not a hard dependency: absent, keyhac/core/migemo.py degrades and the default
matcher is exactly SubstringMatcher.array('L'), which is 8 bytes on macOS/Linux; _Array32 swaps the binding before
the engine is built. Inert on Windows and once upstream lands its own fix. Pin the
version — the patch reaches into internals.*/?, so a
query using them keeps exactly the wildcard semantics it asked for.LogView ring buffer with per-level colors, log-level dropdown, hook on/off toggle
(with AX permission recheck on macOS), last-key + focus-path inspector fields with
copy buttons. Log text at 11pt, one below the 12pt UI font.print() and getLogger both land here; stdout/stderr are redirected into the
console ring buffer.settings.json (polled from the console’s
health tick — PuiKit has no visibility-change callback).mark_screen). It was a frameless
topmost non-activating window with a Label in it — five window-style fields
spelling out “a tooltip” — and it could be clicked, which for a tooltip is
simply wrong. A mark says the intent once, comes with click-through, and
schedules its own timeout.min(70, max(14, len(text) + 4)). That was
a wrap width with no name, and no way for a long balloon to do anything but
be cut short; it is max_width now and the text wraps.pop, not in the caller (issue #152). It first lived
in the multi-stroke callback, which is the one balloon Keyhac pops for
itself — so keymap.pop_balloon(...) from a config had no anchor and went
to the corner, on a multi-monitor setup a screen the user was not looking
at. keyhac-win resolved the caret inside popBalloon itself, and for a
reason that is not about tidiness: a balloon answers a keystroke, so where
the keystroke was typed is not a policy any individual caller should be
choosing. anchor= names the exceptions — "window" for a balloon about
the window rather than a place in it (the chooser’s unreachable-row report),
and "corner" for a balloon that is not about a place at all, which is the
one placement that asks the platform nothing. None of them makes pop
callable off the main thread — the mark is a window, and that was always
main-thread work. An explicit near=/over= still wins, which is what
ReportCaretAnchor passes: it has just reported an anchor, and a second
read could answer differently._MAX_PLACE_HEIGHT) rather than by
category. Without that step every Electron application is a corner forever —
see below.keymap.focus. The snapshot was the free answer — refreshed at the top
of _on_key_down, so it belongs to the very keystroke that armed the
prefix, and reading it keeps a second focus lookup off the hook’s clock.
On macOS it is also true: that provider reads AXFocusedUIElement fresh
every time. On Windows it is a cache keyed on (foreground window, focused
child window, title), because a full UIA walk measures 33 ms and cannot run
on every key — and none of those three change when the focus moves inside
a window. In a Chromium or Electron window they cannot: the whole UI is one
HWND, so tabbing from the address bar into the page changes nothing the
probe can see, and the balloon opened at the field the user had just left.
Issue #44 is the same staleness found in the action API, where
keymap.ui.focused() stopped reading the snapshot for the same reason.
get_focused_element() measures 2.1 ms against a cross-process Edit, paid
once per balloon rather than once per key, against a 300 ms hook deadline. The snapshot stays as the fall-back: it holds the window, and
then the application, where the provider would rather say nothing. The
chooser never had the problem — it reads the caret one turn of the loop
later, where nothing is on the hook’s deadline.AXBoundsForRange over AXSelectedTextRange, Windows
GetBoundingRectangles over the TextPattern selection — and applications
answer wrongly without failing. Measured in VS Code: the caret comes back as
(0, 1112, 0, 0) for a text area whose own frame is
(1275, 981, 409, 40). No height, x at the screen edge, y outside the
element entirely. A popup placed there is worse than the window centre it
replaced, and nothing in the return value says so.set_manual_accessibility, which exists for exactly this class of
Chromium problem) changes nothing: measured before and after, identical.AXBoundsForRange does not. Chromium and
WebKit carry a second text interface keyed on opaque markers, and in a
Gmail compose body — 24 characters of real text, AXBoundsForRange dead to
both spellings and AXLineForIndex with it —
AXBoundsForTextMarkerRange over AXSelectedTextMarkerRange answers
(142, 413, 0, 14) for an element at (107, 341, 512, 295). Zero width,
one line tall, inside its element: a caret. So it is the last road tried,
and it is tried only after the first has failed, being two more
cross-process round trips.AXBoundsForRange in Excel, with no cell being edited. The grid is
an AXLayoutArea of no characters, and every spelling — the character,
the insertion point, even the caret’s line — comes back as
(482, 293, 945, 624), which is the grid.Believing either is worse than having no caret at all, because a caret
bypasses the height limit that keeps a popup from opening under a document
— and a grid and an editor are exactly what that limit is for. The rule is
usable_caret’s, not any platform’s: it was written once in the macOS
marker read and Excel walked straight past it through the other road, which
is what a rule in the wrong layer does.
A newline is not drawn where the caret is — the Windows half of the same character macOS meets from the other side. Measured in the Claude Code chat input inside VS Code, empty, caret at the start of it:
the selection's text : ''
its first bounding rectangle : None
expanded to its character, that is : '\n'
its first bounding rectangle : (3316, 1803, 32, 32)
the caret's line : 'Queue another message…'
the line's rectangle : (2156, 1805, 276, 32)
The degenerate range answers nothing, so the read expands to the character at the caret — and that character is a newline, whose rectangle Chromium draws at the end of the line box, which in a field 1240 wide is the far right of it. The balloon opened five hundred points right of the caret, in an empty box. macOS asks for the character before the caret and reads a newline as “the caret is at the start of the line below”; the Windows expansion runs forward instead, where a newline means only that there is nothing at the caret to bound. So it is refused, and the field — one line tall, and a place — is what the balloon opens under.
(0, 0, 0, 0) — what an element moved off screen
answers — while the TextPattern selection over it is a true caret,
(900, 1116, 1, 32) on a 200% display. Checking that caret against a
rectangle at the origin rejected it for being nowhere near an element that
is nowhere, and the balloon went to the title bar with a perfectly good
caret in hand. An empty frame is the same answer as no frame at all: there
is nothing to check against, so the caret is taken on trust, exactly as it
is when get_rect() returns nothing.AXBoundsForRange(0, 1) on a static text at (88, 47, 40, 16) answers
(88, 47, 8.8, 16) — inside the element, one character wide, the line’s
height. So the mechanism is sound and the split is per-toolkit: native
answers, Chromium/Electron does not, and the size fall-through above is
what keeps the second half usable.usable_caret in
keyhac/core/anchor.py rather than in either platform, because the two
platforms must not disagree about it and because it has to be testable
without an application to be wrong.(101, 497, 0, 14), the newline at offset 62 gives (101, 497, 576, 14),
the caret’s line agrees with both of them — and the caret is at
(101, 511). The balloon covered the line being typed on, and only ever on
the last one, which is what a document ending in a newline makes of every
other answer.(378, 1) with (524, 619, 756, 15); TextEdit
answers the zero-length one and has nothing at (44, 1), the caret being
past its last character. Neither spelling alone reaches both. The second’s
width is whatever that character occupies — a newline runs to the end of
the line, hence 756 — which costs nothing: placement uses the left edge and
the height. Windows has the same split, a degenerate TextRange being
allowed to have no bounding rectangles at all, and the same answer:
ExpandToEnclosingUnit(Character) and ask again.anchor="window" opts a
chooser out at the top: a window switcher has no business opening beside
your caret.(924.5, 202, 0, 16) inside a field at (891, 207, 242, 38) — it starts
five points above the field and ends twenty-seven above its bottom, so a
popup under the caret opened inside the box it had been typed into. The
caret’s height is extended to the field’s bottom edge; its x is left
alone, the column being the one thing the field cannot say and the reason
to read a caret at all. Only for a field — under a document’s bottom edge
is nowhere near the caret, which is what _is_place is for._MAX_PLACE_HEIGHT counts lines of text
and the caret slack counts points. Measured in Chrome’s New Tab on one: the
Google search field, one line high, is (1180, 1086, 1179, 112) — refused
as a place, and the balloon went to the title bar of the window whose search
box the user was typing in. macOS never showed it, AX being in points on
every display. So the element is asked get_coordinate_scale() — the DPI of
the monitor its own rectangle is on, two displays at different scales
being ordinary — and the limits are multiplied by it. It answers 1.0 on
macOS and means it, and 1.0 for anything that cannot answer, which is the
old behaviour exactly.tools/caret_probe.py is how the next bad answer gets found; the survey
mode is the weak one, since a background application reports no focused
element and a blank row there means “not asked”, not “cannot answer”.Menu model on both OSes: Open Console, Edit Config, Reload Config,
Keyboard Hook (toggle), Quit.art/ (icon.svg for the app
icon, MenuExtraTemplate.svg for the macOS menu extra — an AppKit-template glyph:
line art only, since template rendering keeps only alpha; strokes kept light so the
tapering side faces don’t fuse shut at menu-bar size). tools/make_icons.py
renders both through tools/svgrender.py (a pure-stdlib SVG-subset rasterizer that
runs identically on both OSes) into keyhac/ui/assets/ — .ico, .icns, and the
menu-extra PNG pair (deliberately bitmaps, not runtime-loaded SVG: macOS caches a
system-side rasterization of vector status-item images by file identity).core/replay.py: drop unmatched downs,
1000-event cap, release-modifiers-before-play.MoveWindow: keyhac-mac’s direction/edge/multi-monitor logic in core, backed by
Window.set_frame (SetWindowPos / AXPosition+AXSize). Reads windows on the UI
thread, computes on the worker — the ThreadedAction pattern.SnapWindow(position, ratio=0.5): left/right/top/bottom/full tiling within the
screen’s work area; a plain main-thread action (no edge scan, and the work-area
source on macOS is AppKit — UI-thread only).ActivateWindow(app=, title=): on Windows, Window.activate() attaches to
both the foreground and target threads (the classic single attach is no longer
honored by Windows 11 under an armed foreground lock); on macOS it writes
AXFrontmost, with the cooperative activateWithOptions: as fallback (macOS 14+
ignores the cooperative call when the caller is not the active app).InputHook.send_mouse/cursor_pos. Buttons/wheels release
held modifiers (keyhac-win behavior); relative moves are injected as absolute
positions so pointer acceleration cannot distort them (native on Windows; on macOS
CG events are inherently absolute and relative moves accumulate onto
cursor_pos()).kCGMouseEventClickState escalates rapid same-button downs so synthetic
double-clicks register; wheels scroll 3 lines per notch (Windows default feel).WH_MOUSE_LL on Windows; button-down +
scrollWheel types join the tap mask on macOS (motion deliberately untapped —
Python must not sit in the path of every pointer move). Own output is recognized
via dwExtraInfo / event source and ignored.set_ime_status() only drives the second. Asking for “on” while
a plain layout like en-US is active therefore returns False instead of
switching the input language.HKL. Both were
measured on Windows 11 — the language switch does work through
WM_INPUTLANGCHANGEREQUEST posted to the focused window, so this is a choice
about page rather than about what is possible.tests/test_win_ime.py accounts for
that configuration: its plain-layout half skips when every installed layout has
an IME behind it.None.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:
--config PATH — state beside the named config. Existed before this module; a
sandboxed run must not touch the real ~/.keyhac.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.~/.keyhac — the default.bundle_dir() calls it a bundle
when <dir-of-sys.executable>\app\keyhac\ exists (what windows_app/build.ps1
step 4 assembles). Matching on Keyhac.exe instead would break a renamed
launcher, and — worse — matching on nothing at all would let a stray config.py
beside a venv’s python.exe switch a source run (python -m keyhac) into
portable mode.config.py an admin placed). Settings and
ClipboardHistory already log-and-continue on OSError; configure()’s
extensions/ creation now does too, so a read-only directory costs that
directory and not the config load.HKCU\Software\PuiKit\FrameAutosave (frame_autosave_name="KeyhacConsole"), and
the Windows launcher writes keyhac-error.log to ~/.keyhac on a bootstrap
crash — deliberately, since the bundle directory may be read-only. Both leave a
trace outside a portable install.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.