v4.3.0
What's new
KeyUI
v4.3.0 (2026-10-10)
Full Changelog Previous Releases
- fix: name edit-mode mouse keys the way bindings do
Clicking a key in edit mode with a mouse button other than the left one
renames it to whatever OnMouseDown reported. Only MiddleButton was
translated, so mouse button 4 produced a key called "Button4" -- no binding
has that name, so the key always looked unbound. Translate every ButtonN to
BUTTONn, the names the binding system and the shipped mouse layouts use.
Right-click went through the same path, and it also opens the context menu
on release: the key was renamed to "RightButton" just before the menu for
it appeared. In edit mode right-click now only opens the menu.
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com - refactor: set the right-click no-op once when a key button is built
76e1f75 set type2 = ATTRIBUTE_NOOP in SetButtonActionSlot, which runs for
every button on every refresh_keys -- often twice, once from
reset_button_state and once with the real slot. The value never changes and
nothing else touches type2, so set it once in create_*_buttons, next to the
RegisterForClicks("AnyUp") it exists to answer.
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com - perf: release discarded font strings in one pass per batch
ReleaseFontStrings walked the whole font registry, and each entry's parent
chain, once per call -- and clear_key_button_pool called it once per pooled
key button. A settings reset with about 140 buttons and a registry of a
similar size cost on the order of 100k GetParent calls in a single frame.
It now takes a set of owners, and both teardown paths collect theirs first:
four passes per reset instead of one per button.
The registry is keyed by object, which only deduplicates the three named
tab fonts if CreateFont hands back the existing font for a taken name. Reuse
the global by name first, so a rebuild keeps one entry either way.
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com - fix: draw the layout the selector calls active
GetCurrentLayout picked the alphabetically first entry of a layout_current_*
container, but the layout selector text, the open path and delete_layout all
take next() as the active layout. With a profile that carries two entries,
the screen could show one layout while the selector named the other, and
deleting the drawn layout compared against the wrong name, so it kept
rendering after deletion. Take next()'s entry so they agree, and drop the
second return value nobody read.
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com - fix: clear a Clique key's spell when the modifier layer changes
3ebb6b5 gave Clique spell entries a spellid so they get a cooldown overlay.
Holding a modifier goes through refresh_modifier_layer_for_collection, and a
Clique key reads as unbound in both layers, so it takes the branch that
redraws the key without set_key -- and therefore without reset_button_state.
apply_clique_binding only ever sets spellid, never clears it, so the previous
layer's spell stayed on the key: with Renew on BUTTON2 and nothing on
SHIFT-BUTTON2, holding SHIFT left Renew's tooltip and cooldown swipe on an
empty key.
Clear spellid before redrawing the key, refresh its cooldown afterwards, and
count the key as changed when its spell moved, so the tooltip of a hovered
key is redrawn too.
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com - fix: keep held modifiers in edit-mode key names
Assigning a key in edit mode stores what was pressed as the key's raw_key,
held modifiers included, so a layout can carry keys like CTRL-1 -- what many
MMO mouse side buttons send. d5d404a dropped the modifiers to cure the
ALT-ALT-X double prefix, on the premise that handle_key_press keeps
current_modifier_string in step with the held keys. It does not in edit mode:
MODIFIER_STATE_CHANGED only reaches handle_key_press while every device is
locked. So CTRL+wheel on a mouse key was stored as plain MOUSEWHEELUP and the
key showed the unmodified binding.
Restore the held modifiers in handle_key_down, handle_gamepad_down and
handle_mouse_wheel, but leave out any that current_modifier_string -- the
modifier checkboxes -- already supplies, since get_binding prefixes that
string itself. That is where the double prefix came from. The nil-frame guard
d5d404a added to handle_mouse_wheel stays.
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com - Merge pull request #71 from stroexd/fix/issue-70-review-items
Works through the open items from #70.
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com - refactor: drop two unreachable pieces
addon.class_name was assigned at PLAYER_LOGIN and never read. addon.debug is
never set anywhere, so the version line in VersionCompat.lua could not print.
addon.VERSION.string stays -- it is a labelled debug field worth having in a
bug report, which is not true of either of these.
Refs #70
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: use the real proc alt-glow atlas off Retail too
The flat golden rectangle was a fallback for an atlas that is not missing:
Blizzard_ActionBar/Shared/ActionButtonSpellAlerts.xml declares
ActionButtonSpellAlertTemplate's own ProcAltGlow texture with
atlas="UI-HUD-RotationHelper-ProcAltGlow" on the Classic clients as well, and
has_spell_alert_template() has already established that template exists before
this code runs.
Refs #70
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: merge spellbook tabs that share a name instead of losing one
load_spellbook keyed addon.spells by skill-line name and appended to
spells_tab_order unconditionally, so two lines with the same name overwrote
each other while the menu still listed the tab twice -- both entries then
showing the second line's spells.
Refs #70
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - feat: give Clique spell entries a cooldown overlay
apply_clique_binding deliberately sets no action slot, so GetButtonCooldownData
had nothing to read and the key drew icon and label only. A Clique spell entry
carries the spell name, which resolves through the same
C_Spell.GetSpellIDForSpellIdentifier call process_spell already uses, and that
is all the existing spellid branch needs. The cast itself stays Clique's.
Refs #70
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: read one layout per device, and anchor mouse keys from the left
The three generators iterated keyui_settings.layout_current_* with pairs and
nested a second loop inside, although save_*_layout wipes the container and
writes exactly one entry. A profile that ever carried two rendered both on top
of each other in whatever order pairs returned. GetCurrentLayout names that
contract, and picking the lowest key makes the outcome at least deterministic
if it is ever violated.
In the same block: save_mouse_layout stores GetLeft() - mouse_frame:GetLeft(),
an offset from the frame's left edge, but generate_mouse_key_frames fed it to
SetPoint("TOPRIGHT", ...). That only round-trips while every button is exactly
as wide as the 50px frame -- a layout with differently sized buttons drifts by
50 - width. The keyboard generator already uses TOPLEFT on both sides.
Refs #70
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - perf: enumerate the interface bindings once, not per right-click
build_interface_bindings_submenu walked all ~300 GetNumBindings() entries and
allocated a table per binding every time the context menu opened. The set only
changes on UPDATE_BINDINGS, which the event handler already sees, so that is
where the cache is dropped.
Refs #70
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: let the font registry forget discarded font strings
RegisterFontString appended and nothing ever removed. ResetAddonSettings nils
controls_frame, the selectors, the dialogs and the tooltip frame and empties
the button pools, then everything is built again -- so the registry grew by a
frame's worth of entries per reset and RefreshAllFonts kept calling SetFont on
the orphans. The named CreateFont objects were worse: CreateFont returns the
existing font for a name already taken, so each rebuild added another entry
pointing at the same object.
Keying the registry by the font string makes re-registration idempotent, and
ReleaseFontStrings drops everything parented below a frame that is going away.
The two teardown paths call it; the frames that survive a reset keep theirs.
Refs #70
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: correct the Dvorak layouts against the xkeyboard-config reference
xkeyboard-config ships us(dvorak), us(dvorak-l) and us(dvorak-r), the last two
derived from console-tools. Comparing all six shipped Dvorak layouts position
by position against it turned up twelve wrong labels -- four of them the
duplicate commas from the issue, eight that nobody had spotted:
DVORAK_100% / _PRIMARY AD01 was '`', the Dvorak home position is "'"
DVORAK_RIGHT_100% / _PRIMARY AC01/AC02 were '&' and '*', the shifted
legends; WoW names the keys '7' and '8'
DVORAK_RIGHT_100% / _PRIMARY AB10 was ',', which is AB04 -- it is "'"
DVORAK_LEFT_100% / _PRIMARY AC11 was '9', leaving the board with two 9s
and no 7 at all
DVORAK_LEFT_100% / _PRIMARY AB01 was ',', which is AB08 -- it is "'"
Same slip as adcfcb6 fixed in the QWERTY layouts, and the same consequence:
both buttons resolved to one binding, so one key showed the other's action and
a real binding on the mistyped key was invisible.
Every other position in all four rows of all six layouts matches the reference.
Refs #70
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: stop a right-click on a key from using the action
The key buttons register for AnyUp and SetButtonActionSlot sets a bare "type"
attribute. SecureButton_GetButtonSuffix turns a right-click into suffix "2" and
SecureButton_GetModifiedAttribute falls back to the unsuffixed name when no
"type2" is set, so "action" answered the right-click too -- while the insecure
OnMouseUp opened the assignment menu at the same time. In combat that cost a
GCD on whatever the key happened to hold.
Setting type2 to ATTRIBUTE_NOOP is the mechanism SecureTemplates.lua documents
for excluding one button from a shared attribute. It leaves RegisterForClicks,
useOnKeyDown and the left-click path untouched; the context menu and the drag
handlers run off OnMouseUp and RegisterForDrag and are unaffected either way.
Refs #70
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: drop the duplicate modifier prefix from raw_key
handle_key_down, handle_gamepad_down and handle_mouse_wheel each built an
"ALT-CTRL-" prefix and stored it in frame.raw_key. Every consumer prefixes
current_modifier_string itself -- get_binding, apply_clique_binding, the unbind
action -- and handle_key_press already keeps that string in step with the held
modifiers, so the prefix was applied twice: ALT-ALT-X, which matches nothing.
raw_key is also written to saved layouts by save_mouse_layout and used as the
key_lookup and display_only_keys key, all of which expect the bare name, so the
modifier corrupted those too.
handle_mouse_wheel gains the nil-frame guard its two siblings already have; it
is called with addon.current_hovered_button, which OnLeave clears.
Refs #70
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: integration registration, Clique modifier layers, Dominos slot fallback
Four findings from a pass over Core.lua, plus one optimisation.
initialize_keybind_patterns replaces the pattern table at PLAYER_LOGIN but left
registered_addons untouched, so register_addon_patterns short-circuited for any
integration that had already registered from ADDON_LOADED -- that is every addon
whose folder sorts after "KeyUI" and therefore loads after KeyUI's event frame
exists. OPie is the one that hits today; it survives only because set_key has a
separate ORL_RProxy fallback. Wipe the bookkeeping alongside the table.
The pattern table is now an ordered list. set_key stopped at the first match
while iterating with pairs, whose order is undefined, so a binding matching two
patterns could resolve differently between sessions. The MULTIACTIONBAR pattern
was also unanchored and could match inside a longer binding; it is anchored now.
refresh_modifier_layer_for_collection is the fast path for holding a modifier.
A Clique key reads as empty in both layers, so it took the "binding unchanged"
branch, which hid the label but left the previous layer's icon in place and drew
the red empty-key highlight over a key Clique has bound. Redraw it from the
layer being switched to, as set_key does.
resolve_multibar_click_slot matches Dominos frame names -- Blizzard's own are
MultiBarRightButton1 etc., without the "Action" -- but derived slots from
Blizzard's page offsets. Those agree for the first four bars and disagree by
twelve for MultiBar5/6/7, where Dominos names slot 133 MultiBar5ActionButton1.
Give the fallback its own table.
Range polling ran an OnUpdate every frame for the whole session even if KeyUI
was never opened, and repainted every key's font colour ten times a second
whether or not it had changed. The frame now hides itself while closed and the
colour is only set when the range state flips.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: the QWERTY home-row key beside ENTER is the apostrophe, not a comma
All seven QWERTY layouts labelled the key between ';' and ENTER ',', which is
the bottom-row key. Two buttons therefore resolved to the same binding: the
apostrophe showed whatever was bound to comma, a real apostrophe binding was
invisible, and pressing comma lit up two keys at once.
The Dvorak layouts in the same file already spell it "'", so that is the
spelling KeyUI expects. Found by checking every shipped layout for duplicate
keys and overlapping rectangles; the remaining duplicates are either genuine
(both ENTER keys report as ENTER -- WoW has no NUMPADENTER) or belong to
specialty boards that repeat keys on purpose.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: close three small crash and type hazards
The OnLeave handler on every key button hid the shared tooltip without checking
it exists. ResetAddonSettings sets addon.keyui_tooltip_frame to nil and then
hides the buttons, and hiding a frame under the cursor fires OnLeave -- so a
Full Reset while hovering a key raised. Guard the three call sites.
addon.bonusbar_offset and addon.current_actionbar_page started life as empty
tables, but addon:get_action_button_slot compares them against numbers
(>= 2,== 5), which raises "attempt to compare table with number". Today
PLAYER_LOGIN happens to overwrite both before anything reads them; start from
the neutral numbers instead of relying on that.
NormalizeSettingTypes was missing the six overlay toggles that
InitializeGeneralSettings sets, so an imported profile could carry a non-boolean
into show_charge_cooldown and friends and set_if_nil would keep it.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: drive proc glow and pet autocast through APIs the clients still have
Checked every Blizzard symbol KeyUI calls against the 12.1.5, 5.5.4, 2.5.6 and
1.15.9 interface dumps. Three of them are defined in none of the four:
ActionButton_ShowOverlayGlow only a stale call site in Blizzard_PTRFeedback
ActionButton_HideOverlayGlow no definition and no call site anywhere
AutoCastShine_AutoCastStart ditto, same for ..._AutoCastStop
The glow pair sat behind "isRetail or isMoP or isAnniversary", so Classic Era
fell through to them: every proc on 1.15.9 raised "attempt to call a nil value"
instead of glowing. The premise of that branch is wrong as well --
ActionButtonSpellAlertTemplate is defined on all four clients, including Era,
and its atlases are the same ones KeyUI already sets. Probe the template once
the way the autocast code does and drop the version split.
The autocast pair was nil-guarded, so it never raised -- it just never ran. The
Shine texture inside AutoCastOverlayTemplate starts hidden and only
AutoCastOverlayMixin:ShowAutoCastEnabled starts it, so a pet ability with
autocast on has been indistinguishable from one with autocast off.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - ci: harden the release workflow and keep dev files out of the package
The release workflow fired on '**', so any tag at all - a scratch tag, a
typo - would have packaged a build and pushed it to CurseForge, Wago and
WoWInterface. Narrowed to v* so only deliberate version tags publish.
Other changes:- actions/checkout v3 -> v4; v3 runs on Node 16 and is deprecated.
- Pinned permissions to contents: write. The repository default happens to
be write today, so nothing was broken, but the release should not depend
on that staying true. - Added workflow_dispatch with a required tag input, so a release that fails
halfway can be re-run without cutting a new tag. It checks the tag out
explicitly: without that the packager would see an untagged commit and
publish it as an alpha (release.sh:849). - Added a concurrency group so two runs of the same tag cannot race.
- Added a step that writes the resolved channel and the TOC interface line
to the run summary, so it is visible what a tag is about to publish before
looking at the upload. - Documented the channel convention in the workflow header: the packager
reads it from the tag name, "beta" anywhere in the tag means a beta build.
.pkgmeta now also ignores .gitignore, .luarc.json, .pkgmeta and
COMPATIBILITY.md. Gitignored directories are dropped by the packager on its
own, but these are tracked and were being shipped to users.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
- fix: keep Classic on the numeric cooldown path, honour LoC replacement
Follow-up to #68. The approach there is right - SetCooldown is annotated
SecretArguments = "AllowedWhenUntainted", so addon code genuinely cannot
pass secret values, and duration objects are the way around it. Four things
needed correcting, all checked against the 12.1.5 interface dump.
The important one: GetActionCooldownDuration, GetActionChargeDuration,
GetActionLossOfControlCooldownInfo and SetCooldownFromDurationObject are
documented on all four clients, not just Retail. Testing for their presence
therefore put MoP, TBC and Era on the new path too - the one place nobody
had tested. Gate on C_Secrets.HasSecretRestrictions() instead, which is
false wherever timings are not secret, so Classic keeps the numeric path it
has always used and the new code only runs where it is actually needed.
CooldownInfoIsActive pcall'd reading isActive, but isActive carries
NeverSecret = true (SpellSharedDocumentation.lua) and can never be secret.
The pcall ran for every button on every refresh, and its "return true on
failure" fallback erred toward drawing something wrong.
Blizzard suppresses the normal and charge swipes while a loss-of-control
effect replaces them (shouldReplaceNormalCooldown, also NeverSecret,
Blizzard_ActionBar/ActionButton.lua:858-860). Without it KeyUI drew the
ordinary cooldown on top of the red loss-of-control swipe.
The LoC branch tested for GetActionLossOfControlCooldownDuration but called
GetActionLossOfControlCooldownInfo; it now checks both.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Co-Authored-By: Babbadger 80891322+Babbadger@users.noreply.github.com - feat: show LMB/RMB on the mouse UI, including Clique bindings (#66)
The mouse layouts had slots for BUTTON3 and the wheel but none for BUTTON1
or BUTTON2, so the left and right mouse buttons were simply missing from
the picture. Adds them to all eight layouts, tiled with the wheel as
LMB | wheel | RMB.
The reporter uses Clique, which does its click-casting through secure
attributes on unit frames - those bindings never reach GetBindingAction, so
a plain slot would have stayed empty for exactly the person who asked. Clique
does expose them through its AceDB saved variable, and its key strings use
the same ALT-CTRL-SHIFT ordering KeyUI builds in update_modifier_string, so
entries match KeyUI keys directly and ship an icon path as well. KeyUI now
falls back to that lookup whenever WoW's binding table has nothing for a key.
Read-only: KeyUI never writes to another addon's database.
These two keys are deliberately not assignable. KeyUI binds through the
global SetBinding, so an assignment menu on BUTTON1 would let a player
hijack their own left click. The context menu returns early for them, which
also puts every SetBinding call site out of reach, and the drag path already
bails when a button has no action slot. No secure attributes are set either,
so the slot stays purely visual.
Layout_2+4x3's two extra thumb slots moved down within their column to make
room; they also overlapped each other by 3px before, which this fixes.
Closes #66
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - docs: re-verify the addon integration patterns against current sources
The integration checklist still said "Dominos (11.2.x)" while Retail is on
12.1.x, and the frame names and binding formats KeyUI hard-codes had not
been checked against the addons since.
Checked Dominos, ElvUI and Bartender4 at their current HEADs: all three
patterns in the binding_handlers table still hold. Recorded what was
checked and at which commit, so the next pass has a baseline.
OPie is not published on GitHub and stays unverified - noted as such rather
than left to look checked.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - ci: run the Lua syntax check on pull requests
release.yml triggers only on tags, so pull requests were merged without
anything having compiled them - PR #68 arrived with zero check runs.
Reuses the compiler-resolution and luac -p steps from release.yml verbatim,
including the API/ and Addons/ exclusions, and adds no secrets or packaging
so it is safe to run on forks. Uses checkout@v4; release.yml is still on v3
and can be bumped separately.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - chore: bump TOC to live builds and refresh API snapshot docs
Retail, Anniversary and Classic Era were each a patch behind, so WoW
flagged KeyUI as out of date on three of the four supported clients.
Retail 120005 -> 120105, 120100
MoP Classic 50504 -> 50504 (unchanged)
Anniversary 20505 -> 20506
Classic Era 11508 -> 11509
Two Retail targets on purpose: 120105 is current, but 120100 clients are
still around and naming only one gets the addon flagged on the other.
Bartender4 and AdvancedInterfaceOptions do the same. The Classic values are
cross-checked against ElvUI, Dominos, Bartender4 and AdvancedInterfaceOptions,
which all declare 50504, 20506 and 11509.
Re-ran the documented feature audit against the new snapshots. Two of the
four recorded findings had changed: BINDINGS_LOADED and C_ActionBar.GetSpell
/ IsAssistedCombatAction now exist on all four clients, not just Anniversary
and Retail. Most of C_ActionBar has converged, so feature detection no
longer distinguishes Retail from Classic.
Documented the 12.x secret-value rules, since that is now the real
difference and the reason existence checks mislead: which struct fields
carry NeverSecret, why SetCooldown rejects secret values from tainted code,
and C_Secrets as the way to pick a path.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - Merge pull request #68 from Babbadger/fix/combat-cooldowns-secret-values
fix: keep cooldowns updating in combat on 12.0 (secret values) - fix: withdraw frames from UISpecialFrames during combat
Blizzard's ESC handler (CloseSpecialWindows) calls Hide() on every frame listed
in UISpecialFrames. KeyUI's frames parent SecureActionButtonTemplate buttons, so
that Hide() is blocked during combat and pressing ESC mid-fight produced:
[ADDON_ACTION_BLOCKED] AddOn 'KeyUI' tried to call the protected function
'keyui_keyboard_frame:Hide()'.
Gate ApplyEscClose on combat lockdown and call it from both regen handlers, so
the frames are withdrawn for the duration of the fight and restored afterwards.
ESC is simply inert for KeyUI during combat rather than raising; the frames'
own close paths already defer through SafeHideFrame. Frames are only created out
of combat, so the registrations at creation time cannot re-add them mid-fight.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: read macro limits from Constants.MacroConsts
12.0 removed the MAX_ACCOUNT_MACROS and MAX_CHARACTER_MACROS globals, so
build_macros_submenu ran "for i = 1, nil" and the Macro submenu failed outright:
Core.lua:4662: 'for' limit must be a number
Read them from Constants.MacroConsts, falling back to the globals and then to
the long-standing literals so Classic is unaffected.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: guard key and gamepad handlers against a cleared hover target
Callers pass addon.current_hovered_button, which OnLeave clears while the
OnKeyDown script stays installed on the button. A keypress arriving just after
the cursor leaves a key therefore reached handle_key_down with no frame:
Core.lua:4438: attempt to perform indexed assignment on local 'frame'
(a nil value)
Bail out early in handle_key_down and handle_gamepad_down.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com - fix: render cooldowns from 12.0 duration objects so they update in combat
On 12.0 the action cooldown APIs return secret values during combat. Tainted
code may neither compare them nor hand them to a widget, so SetCooldown raised:
bad argument #1 to 'SetCooldown' ... Secret values are only allowed during
untainted execution for this argument.
UpdateButtonCooldown assumed the opposite and passed them straight through.
Because it sits mid-chain in set_key, the throw aborted the whole refresh pass,
freezing icons, usable state, counts and range as well - the cooldown swipes
kept animating from their last pre-combat value.
Use the 12.0 cooldown duration objects instead: opaque handles that tainted code
cannot read but may pass to a Cooldown widget, driven by the info tables'
isActive field, which stays readable. The numeric path is kept for pet actions
and Classic, with its comparisons guarded so a secret value degrades to "nothing
to draw" rather than erroring.
Charge and loss-of-control overlays get the same treatment, which also lets them
drop their InCombatLockdown bail-outs and update during combat.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
All Relations
- All Relations
- Embedded Library
- Optional Dependency
- Required Dependency
- Tool
- Incompatible
- Include