benilla's layer speaks the 1.12 API alone: handlers take this and no arguments, GetBinding answers the 1.12 list, the Key Bindings page captures through its window, /errors chains the error handler, and the host hooks leave the VM (#569)

benilla's layer now uses only the 1.12 API and the 1.12 Lua forms, and the host hooks it used are gone from the VM.

- **The VM calls handlers as 1.12.1 does.** A handler body is compiled as the chunk itself, so `self` in it is an ordinary global (`0x704c70`). A handler is called with no arguments (`0x705199`). The loader reads no `function=` attribute.
- **`GetBinding`/`GetNumBindings` answer the 1.12 list** (`0x4b7f60`): `HEADER_*` section rows, plus every key bound to a command, with no category column. The stock `KeyBindingFrame` now shows its section headers and both key columns.
- **The Key Bindings page captures keys through its own window**, as `Blizzard_BindingUI` does: the frame's `OnKeyDown` for keys, clicks for mouse buttons, and the wheel. It takes the `Benilla` prefix, so loading the stock binding addon no longer breaks it. The UI pointer feed now hands frames mouse buttons 4 and 5.
- **`/errors` keeps its log by chaining the Lua error handler**, and the stock ScriptErrors dialog is unchanged. Load failures that never raise now print one chat line each at world entry.
- **Slash commands:** `/reload` and `/errors` are `SlashCmdList` rows in the layer. `/convertraid` is gone; 1.12.1 has no such command. The dev commands are host rows in dev builds only, so a player build answers them with the unknown-command line once.
- **Naming:** every global the layer defines is `Benilla`-prefixed. `BenillaBindCapture`, `KeyBindings_OnHostKey`, the three script-error globals and `SubmitChatInput` are removed.

Verified:
- New tests for each rule and each removed surface; 16 mutations each fail a test.
- Region dumps of the Options window (general page, Key Bindings, armed) are identical before and after.
- `scripts/gates.sh` and `scripts/smoke.sh` are green.
- A live run bound a key, a Shift chord and the middle button through the page, and opened `/errors`.

Built on macOS; no platform seam touched.

Fixes #539
Fixes #544
Fixes #407
Part of #171

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-29 00:12:27 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0f36374445
commit 05e204de10
48 changed files with 1966 additions and 1495 deletions
@@ -10,6 +10,8 @@
-- The stock body, captured before it is shadowed; nil when no client data was sourced.
local refItemButtonOnEnter = ContainerFrameItemButton_OnEnter
-- The stock name, redefined: the stock item buttons call it, and our keyring slot needs
-- the bag-slot tooltip.
function ContainerFrameItemButton_OnEnter(button)
if not button then
button = this
@@ -72,7 +74,8 @@
end
end
-- After the stock ContainerFrame.lua has loaded, so its callers reach these.
-- The stock names, redefined after the stock ContainerFrame.lua has loaded, so its callers
-- (the vendor, bank, mail and trade windows) reach these.
OpenBackpack = BenillaOpenBackpack
CloseBackpack = BenillaCloseBackpack
CloseAllBags = BenillaCloseAllBags
@@ -24,11 +24,11 @@
end
end
-- ERA_WINDOW_SCALE (OptionsFrame.xml) stands in for the era's UIParent scale; read at
-- every show, then the stock OnShow.
-- BENILLA_ERA_WINDOW_SCALE (OptionsFrame.xml) stands in for the era's UIParent scale;
-- read at every show, then the stock OnShow.
local Ref_GameMenuFrame_OnShow = GameMenuFrame:GetScript("OnShow")
GameMenuFrame:SetScript("OnShow", function()
this:SetScale(ERA_WINDOW_SCALE or 1)
this:SetScale(BENILLA_ERA_WINDOW_SCALE or 1)
Ref_GameMenuFrame_OnShow()
end)
+197 -135
View File
@@ -4,14 +4,16 @@
OptionsFrame.xml, which loads after. Only commands the engine dispatches appear.
Deviation: the layout is the era Settings panel's Keybindings page (collapsible category
sections), because 1.12's settings screens are much worse to use. Capture follows 1.12's
Blizzard_BindingUI: the next key or mouse 3/4/5 press binds, left and right clicks only
select, and ESC while armed binds Escape (its OnKeyDown has no ESCAPE filter).
Blizzard_BindingUI: while a capsule is selected the window is keyboard-enabled, so the
next key binds through its OnKeyDown, and a mouse 3/4/5 click or a wheel notch binds;
left and right clicks only select, and ESC while armed binds Escape (its OnKeyDown has no
ESCAPE filter).
Deviation: every bind, unbind and reset saves at once, as the era panel commits, where
1.12 had Okay and Cancel; only Reset To Default and leaving the character set confirm.
-->
<!-- 1.12's KeyBindingFrameBindingButtonTemplate; all five buttons reach the capture. -->
<Button name="KeyBindingButtonTemplate" virtual="true">
<!-- 1.12's KeyBindingFrameBindingButtonTemplate; all five buttons reach the handler. -->
<Button name="BenillaKeyBindingButtonTemplate" virtual="true">
<Size><AbsDimension x="180" y="22"/></Size>
<ButtonText name="$parentText"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
@@ -22,13 +24,13 @@
<DisabledFont inherits="GameFontDisableSmall"/>
<Scripts>
<OnLoad>this:RegisterForClicks("LeftButtonUp", "RightButtonUp", "MiddleButtonUp", "Button4Up", "Button5Up")</OnLoad>
<OnClick>KeyBindingButton_OnClick(arg1)</OnClick>
<OnClick>BenillaKeyBindingButton_OnClick(arg1)</OnClick>
</Scripts>
</Button>
<!-- A binding row or a section header in 1.12's QuestLogTitleButton dress, since the era's
expand arrows are not in 1.12; 25 tall, the era's row height. -->
<Frame name="KeyBindingRowTemplate" virtual="true">
<Frame name="BenillaKeyBindingRowTemplate" virtual="true">
<Size><AbsDimension x="560" y="25"/></Size>
<Layers>
<Layer level="BACKGROUND">
@@ -61,13 +63,13 @@
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<Scripts>
<OnClick>KeyBindings_ToggleSection(this.sectionIndex)</OnClick>
<OnClick>BenillaKeyBindings_ToggleSection(this.sectionIndex)</OnClick>
</Scripts>
</Button>
<Button name="$parentKey1Button" inherits="KeyBindingButtonTemplate" id="1">
<Button name="$parentKey1Button" inherits="BenillaKeyBindingButtonTemplate" id="1">
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="175" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentKey2Button" inherits="KeyBindingButtonTemplate" id="2">
<Button name="$parentKey2Button" inherits="BenillaKeyBindingButtonTemplate" id="2">
<Anchors><Anchor point="LEFT" relativeTo="$parentKey1Button" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
</Frames>
@@ -75,7 +77,7 @@
<Script><![CDATA[
-- ── Strings ── a 1.12 GlobalStrings global, or the fallback for a harness without them.
function KeyBindings_String(name, fallback)
function BenillaKeyBindings_String(name, fallback)
local s = getglobal(name)
if s then return s end
return fallback
@@ -83,9 +85,9 @@
-- ── Key display ── 1.12's GetBindingText in full form: modifier prefixes stay tokens, the
-- base key reads its KEY_* global. "CTRL--" is CTRL plus the minus key.
function KeyBindings_KeyText(key)
function BenillaKeyBindings_KeyText(key)
if not key then
return NORMAL_FONT_COLOR_CODE .. KeyBindings_String("NOT_BOUND", "Not Bound") .. FONT_COLOR_CODE_CLOSE
return NORMAL_FONT_COLOR_CODE .. BenillaKeyBindings_String("NOT_BOUND", "Not Bound") .. FONT_COLOR_CODE_CLOSE
end
local prefix = ""
local rest = key
@@ -104,43 +106,55 @@
break
end
end
return prefix .. KeyBindings_String("KEY_" .. rest, rest)
return prefix .. BenillaKeyBindings_String("KEY_" .. rest, rest)
end
-- ── Page state ── `searchPainted` maps OptionsFrame.xml's search rows to binding indices.
KeyBindingsPage = { sections = {}, items = {} }
BenillaKeyBindingsPage = { sections = {}, items = {} }
KEYBINDINGS_ROW_BASE = "BenillaOptionsFrameContainerBodyKeybindingsRow"
KEYBINDINGS_DISPLAYED = 19
KEYBINDINGS_ROW_HEIGHT = 25
KEYBINDINGS_SEARCH_ROWS = 8
BENILLA_KEYBINDINGS_ROW_BASE = "BenillaOptionsFrameContainerBodyKeybindingsRow"
BENILLA_KEYBINDINGS_DISPLAYED = 19
BENILLA_KEYBINDINGS_ROW_HEIGHT = 25
BENILLA_KEYBINDINGS_SEARCH_ROWS = 8
-- ── Section model ── one section per category in Bindings.xml order, collapsed at first
-- as in the era; a rebuild keeps each state.
function KeyBindings_BuildSections()
local page = KeyBindingsPage
-- ── Section model ── one section per `HEADER_*` row of the binding list, in its
-- order (Blizzard_BindingUI.lua:84-92), collapsed at first as in the era; a rebuild keeps
-- each state. `token` is the header's GlobalStrings key, `fallback` its bare name.
-- Deviation: the spacer headers BLANK, BLANK2 and BLANK3 over multibars 2-4
-- (Bindings.xml:883/967/1051), which 1.12 draws blank (BINDING_HEADER_BLANK is " ", the
-- other two are not defined), continue the section above, because this page's sections
-- are named and collapsible.
function BenillaKeyBindings_IsHeader(command)
return strsub(command, 1, 6) == "HEADER"
end
function BenillaKeyBindings_BuildSections()
local page = BenillaKeyBindingsPage
local was = {}
for _, s in ipairs(page.sections) do
was[s.token] = s.expanded
end
page.sections = {}
local byToken = {}
local sec = nil
for i = 1, GetNumBindings() do
local _, category = GetBinding(i)
local sec = byToken[category]
if not sec then
sec = { token = category, indices = {}, expanded = was[category] }
byToken[category] = sec
table.insert(page.sections, sec)
local command = GetBinding(i)
if BenillaKeyBindings_IsHeader(command) then
local token = "BINDING_" .. command
if not (sec and strfind(command, "^HEADER_BLANK%d*$")) then
sec = { token = token, fallback = strsub(command, 8), indices = {},
expanded = was[token] }
table.insert(page.sections, sec)
end
elseif sec then
table.insert(sec.indices, i)
end
table.insert(sec.indices, i)
end
KeyBindings_Flatten()
BenillaKeyBindings_Flatten()
end
-- The visible list the faux scroll runs over.
function KeyBindings_Flatten()
local page = KeyBindingsPage
function BenillaKeyBindings_Flatten()
local page = BenillaKeyBindingsPage
page.items = {}
for n, sec in ipairs(page.sections) do
table.insert(page.items, { header = n })
@@ -153,38 +167,38 @@
end
-- A header click, voiced with the checkbox sound.
function KeyBindings_ToggleSection(n)
local sec = KeyBindingsPage.sections[n]
function BenillaKeyBindings_ToggleSection(n)
local sec = BenillaKeyBindingsPage.sections[n]
if not sec then return end
PlaySound("igMainMenuOptionCheckBoxOn")
sec.expanded = not sec.expanded
KeyBindings_Flatten()
KeyBindingsPage_Update()
BenillaKeyBindings_Flatten()
BenillaKeyBindingsPage_Update()
end
-- Silent, for the capture fixture.
function KeyBindings_ExpandSection(n, on)
local sec = KeyBindingsPage.sections[n]
function BenillaKeyBindings_ExpandSection(n, on)
local sec = BenillaKeyBindingsPage.sections[n]
if not sec then return end
sec.expanded = on and true or false
KeyBindings_Flatten()
BenillaKeyBindings_Flatten()
end
-- ── Painters ── shared by the list and the search rows, which capture in place.
function KeyBindings_PaintBindRow(name, index)
local command, _, key1, key2 = GetBinding(index)
function BenillaKeyBindings_PaintBindRow(name, index)
local command, key1, key2 = GetBinding(index)
local b1 = getglobal(name .. "Key1Button")
local b2 = getglobal(name .. "Key2Button")
b1.commandName = command
b2.commandName = command
b1:SetText(KeyBindings_KeyText(key1))
b2:SetText(KeyBindings_KeyText(key2))
b1:SetText(BenillaKeyBindings_KeyText(key1))
b2:SetText(BenillaKeyBindings_KeyText(key2))
if key1 then b1:SetAlpha(1) else b1:SetAlpha(0.8) end
if key2 then b2:SetAlpha(1) else b2:SetAlpha(0.8) end
local desc = getglobal(name .. "Description")
desc:SetText(KeyBindings_String("BINDING_NAME_" .. command, command))
desc:SetText(BenillaKeyBindings_String("BINDING_NAME_" .. command, command))
desc:Show()
local page = KeyBindingsPage
local page = BenillaKeyBindingsPage
if page.selected == command and page.keyID == 1 then
b1:LockHighlight()
else
@@ -200,11 +214,11 @@
getglobal(name .. "Header"):Hide()
end
function KeyBindings_PaintHeaderRow(name, n)
local sec = KeyBindingsPage.sections[n]
function BenillaKeyBindings_PaintHeaderRow(name, n)
local sec = BenillaKeyBindingsPage.sections[n]
local header = getglobal(name .. "Header")
header.sectionIndex = n
header:SetText(KeyBindings_String(sec.token, sec.token))
header:SetText(BenillaKeyBindings_String(sec.token, sec.fallback))
local icon = getglobal(name .. "HeaderIcon")
if sec.expanded then
icon:SetTexture("Interface\\Buttons\\UI-MinusButton-Up")
@@ -218,34 +232,34 @@
end
-- ── The list repaint (1.12 KeyBindingFrame_Update) ──
function KeyBindingsPage_Update()
local page = KeyBindingsPage
function BenillaKeyBindingsPage_Update()
local page = BenillaKeyBindingsPage
local scroll = getglobal("BenillaOptionsFrameContainerBodyKeybindingsScrollFrame")
local offset = FauxScrollFrame_GetOffset(scroll)
for i = 1, KEYBINDINGS_DISPLAYED do
local name = KEYBINDINGS_ROW_BASE .. i
for i = 1, BENILLA_KEYBINDINGS_DISPLAYED do
local name = BENILLA_KEYBINDINGS_ROW_BASE .. i
local row = getglobal(name)
local item = page.items[offset + i]
if item and item.header then
KeyBindings_PaintHeaderRow(name, item.header)
BenillaKeyBindings_PaintHeaderRow(name, item.header)
row:Show()
elseif item then
KeyBindings_PaintBindRow(name, item.bind)
BenillaKeyBindings_PaintBindRow(name, item.bind)
row:Show()
else
row:Hide()
end
end
FauxScrollFrame_Update(scroll, table.getn(page.items),
KEYBINDINGS_DISPLAYED, KEYBINDINGS_ROW_HEIGHT)
BENILLA_KEYBINDINGS_DISPLAYED, BENILLA_KEYBINDINGS_ROW_HEIGHT)
-- Our trough follows the bar `FauxScrollFrame_Update` has just shown or hidden.
BenillaScrollTrough_Follow(scroll:GetName())
KeyBindings_UpdateUnbindKey()
BenillaKeyBindings_UpdateUnbindKey()
end
-- The page entry: a rebind or a set switch may have changed the table.
function KeyBindingsPage_Refresh()
KeyBindings_BuildSections()
function BenillaKeyBindingsPage_Refresh()
BenillaKeyBindings_BuildSections()
local box = getglobal("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck")
if GetCurrentBindingSet() == 2 then
box:SetChecked(1)
@@ -253,23 +267,28 @@
box:SetChecked(nil)
end
getglobal("BenillaOptionsFrameContainerBodyKeybindingsScrollFrameScrollBar"):SetValue(0)
KeyBindingsPage_Update()
BenillaKeyBindingsPage_Update()
end
function KeyBindings_Output(text)
function BenillaKeyBindings_Output(text)
getglobal("BenillaOptionsFrameContainerBodyKeybindingsOutput"):SetText(text)
end
-- ── Selection ── arms the host's capture, which hands its chord to KeyBindings_OnHostKey.
function KeyBindings_SetSelected(command, keyID)
KeyBindingsPage.selected = command
KeyBindingsPage.keyID = keyID
BenillaBindCapture(command ~= nil)
-- ── Selection ── while a capsule is selected the window takes every key and the
-- wheel, as 1.12's keyboard-enabled KeyBindingFrame does (Blizzard_BindingUI.xml:73,
-- :541-543); unarmed it takes neither, so keys and the wheel reach the game.
function BenillaKeyBindings_SetSelected(command, keyID)
BenillaKeyBindingsPage.selected = command
BenillaKeyBindingsPage.keyID = keyID
if BenillaOptionsFrame then
BenillaOptionsFrame:EnableKeyboard(command ~= nil)
BenillaOptionsFrame:EnableMouseWheel(command ~= nil)
end
end
function KeyBindings_UpdateUnbindKey()
function BenillaKeyBindings_UpdateUnbindKey()
local unbind = getglobal("BenillaOptionsFrameContainerUnbind")
if KeyBindingsPage.selected then
if BenillaKeyBindingsPage.selected then
unbind:Enable()
else
unbind:Disable()
@@ -277,94 +296,134 @@
end
-- Every search row: a bind from one can unbind a victim shown on another.
function KeyBindings_RepaintVisible()
if KeyBindingsPage.searchPainted then
for name, index in pairs(KeyBindingsPage.searchPainted) do
KeyBindings_PaintBindRow(name, index)
function BenillaKeyBindings_RepaintVisible()
if BenillaKeyBindingsPage.searchPainted then
for name, index in pairs(BenillaKeyBindingsPage.searchPainted) do
BenillaKeyBindings_PaintBindRow(name, index)
end
KeyBindings_UpdateUnbindKey()
BenillaKeyBindings_UpdateUnbindKey()
else
KeyBindingsPage_Update()
BenillaKeyBindingsPage_Update()
end
end
function KeyBindingButton_OnClick(button)
-- As 1.12: while armed, a middle, 4 or 5 press is a bind the capture takes after this.
local page = KeyBindingsPage
-- 1.12's KeyBindingButton_OnClick (Blizzard_BindingUI.lua:228-258): while armed, a middle,
-- 4 or 5 click binds that button.
function BenillaKeyBindingButton_OnClick(button)
local page = BenillaKeyBindingsPage
if page.selected then
if button ~= "LeftButton" and button ~= "RightButton" then
BenillaKeyBindings_OnKeyDown(button)
return
end
if page.selected == this.commandName and page.keyID == this:GetID() then
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
else
KeyBindings_SetSelected(this.commandName, this:GetID())
KeyBindings_Output(string.format(
KeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
KeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
BenillaKeyBindings_SetSelected(this.commandName, this:GetID())
BenillaKeyBindings_Output(string.format(
BenillaKeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
BenillaKeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
end
else
KeyBindings_SetSelected(this.commandName, this:GetID())
KeyBindings_Output(string.format(
KeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
KeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
BenillaKeyBindings_SetSelected(this.commandName, this:GetID())
BenillaKeyBindings_Output(string.format(
BenillaKeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
BenillaKeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
end
KeyBindings_RepaintVisible()
BenillaKeyBindings_RepaintVisible()
end
function KeyBindings_Commit()
function BenillaKeyBindings_Commit()
SaveBindings(GetCurrentBindingSet())
end
-- `chord` is ALT-CTRL-SHIFT-TOKEN; 1.12's OnKeyDown armed branch, then the live commit.
function KeyBindings_OnHostKey(chord)
local page = KeyBindingsPage
-- 1.12's KeyBindingFrame_OnKeyDown armed branch (Blizzard_BindingUI.lua:140-212), then
-- the live commit: `button` is a mouse button's or the wheel's name, else the key is
-- `arg1`, the window's OnKeyDown argument. Unarmed it does nothing: the window takes keys
-- only while armed, so 1.12's unarmed ESCAPE arm never runs here.
function BenillaKeyBindings_OnKeyDown(button)
local page = BenillaKeyBindingsPage
if not page.selected then
return
end
if button == "LeftButton" then
button = "BUTTON1"
elseif button == "RightButton" then
button = "BUTTON2"
elseif button == "MiddleButton" then
button = "BUTTON3"
elseif button == "Button4" then
button = "BUTTON4"
elseif button == "Button5" then
button = "BUTTON5"
end
local chord
if button then
if button == "BUTTON1" or button == "BUTTON2" then
return
end
chord = button
else
chord = arg1
end
if chord == "UNKNOWN" or chord == "SHIFT" or chord == "CTRL" or chord == "ALT" then
return
end
if IsShiftKeyDown() then chord = "SHIFT-" .. chord end
if IsControlKeyDown() then chord = "CTRL-" .. chord end
if IsAltKeyDown() then chord = "ALT-" .. chord end
local selected = page.selected
local oldAction = GetBindingAction(chord)
if oldAction ~= "" and oldAction ~= selected then
local k1, k2 = GetBindingKey(oldAction)
if (not k1 or k1 == chord) and (not k2 or k2 == chord) then
KeyBindings_Output(string.format(
KeyBindings_String("KEY_UNBOUND_ERROR", "|cffff0000%s Function is Now Unbound!|r"),
KeyBindings_String("BINDING_NAME_" .. oldAction, oldAction)))
BenillaKeyBindings_Output(string.format(
BenillaKeyBindings_String("KEY_UNBOUND_ERROR", "|cffff0000%s Function is Now Unbound!|r"),
BenillaKeyBindings_String("BINDING_NAME_" .. oldAction, oldAction)))
else
KeyBindings_Output(KeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
BenillaKeyBindings_Output(BenillaKeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
end
else
KeyBindings_Output(KeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
BenillaKeyBindings_Output(BenillaKeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
end
local key1, key2 = GetBindingKey(selected)
if key1 then SetBinding(key1) end
if key2 then SetBinding(key2) end
if page.keyID == 1 then
KeyBindings_SetOrRestore(chord, selected, key1)
BenillaKeyBindings_SetOrRestore(chord, selected, key1)
if key2 then SetBinding(key2, selected) end
else
if key1 then SetBinding(key1, selected) end
KeyBindings_SetOrRestore(chord, selected, key2)
BenillaKeyBindings_SetOrRestore(chord, selected, key2)
end
KeyBindings_Commit()
KeyBindings_SetSelected(nil)
KeyBindings_RepaintVisible()
BenillaKeyBindings_Commit()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_RepaintVisible()
end
-- 1.12's KeyBindingFrame_SetBinding, message and all; SetBinding refuses only a non-key.
function KeyBindings_SetOrRestore(key, command, oldKey)
function BenillaKeyBindings_SetOrRestore(key, command, oldKey)
if not SetBinding(key, command) then
if oldKey then SetBinding(oldKey, command) end
KeyBindings_Output(KeyBindings_String(
BenillaKeyBindings_Output(BenillaKeyBindings_String(
"KEYBINDINGFRAME_MOUSEWHEEL_ERROR",
"Can't bind mousewheel to actions with up and down states"))
end
end
-- The list's OnMouseWheel while armed (Blizzard_BindingUI.xml:377-387).
function BenillaKeyBindings_OnMouseWheel(delta)
if delta > 0 then
BenillaKeyBindings_OnKeyDown("MOUSEWHEELUP")
else
BenillaKeyBindings_OnKeyDown("MOUSEWHEELDOWN")
end
end
-- 1.12's Unbind Key body, then the live commit.
function KeyBindings_Unbind()
local page = KeyBindingsPage
function BenillaKeyBindings_Unbind()
local page = BenillaKeyBindingsPage
local key1, key2 = GetBindingKey(page.selected)
if key1 then SetBinding(key1) end
if key2 then SetBinding(key2) end
@@ -373,67 +432,70 @@
else
if key1 then SetBinding(key1, page.selected) end
end
KeyBindings_Commit()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindings_RepaintVisible()
BenillaKeyBindings_Commit()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
BenillaKeyBindings_RepaintVisible()
end
-- ── Character-specific set ── checking saves set 2 at once (LoadBindings(2) falls back
-- to the account set); unchecking confirms first, as the era does. The accept loads set 1
-- before saving it, or the character binds would be written into the account set.
function KeyBindings_CharacterToggle(box)
function BenillaKeyBindings_CharacterToggle(box)
PlaySound("igMainMenuOptionCheckBoxOn")
if box:GetChecked() then
LoadBindings(2)
SaveBindings(2)
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindingsPage_Refresh()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
BenillaKeyBindingsPage_Refresh()
else
box:SetChecked(1)
StaticPopup_Show("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS")
StaticPopup_Show("BENILLA_CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS")
end
end
StaticPopupDialogs["CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS"] = {
text = KeyBindings_String("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS",
-- Our key: Blizzard_BindingUI.lua:10 defines the stock one for its own window.
StaticPopupDialogs["BENILLA_CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS"] = {
text = BenillaKeyBindings_String("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS",
"Really switch to general key bindings? All key bindings specific to this character will be permanantly deleted."),
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
button1 = BenillaKeyBindings_String("OKAY", "Okay"),
button2 = BenillaKeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
LoadBindings(1)
SaveBindings(1)
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindingsPage_Refresh()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
BenillaKeyBindingsPage_Refresh()
end,
}
-- Reset To Default confirms, as the era's does; our string, since 1.12 has none.
StaticPopupDialogs["CONFIRM_RESET_KEYBINDINGS"] = {
StaticPopupDialogs["BENILLA_CONFIRM_RESET_KEYBINDINGS"] = {
text = "Are you sure you want to reset all keybindings to their defaults?",
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
button1 = BenillaKeyBindings_String("OKAY", "Okay"),
button2 = BenillaKeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
PlaySound("igMainMenuOptionCheckBoxOn")
LoadBindings(0)
KeyBindings_Commit()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindingsPage_Refresh()
BenillaKeyBindings_Commit()
BenillaKeyBindings_SetSelected(nil)
BenillaKeyBindings_Output("")
BenillaKeyBindingsPage_Refresh()
end,
}
-- ── Search ── a binding's tag is its display name uppercased, as the era's.
-- A static pool of KEYBINDINGS_SEARCH_ROWS rows, where the era's results scroll.
function KeyBindings_SearchMatches(words)
-- A static pool of BENILLA_KEYBINDINGS_SEARCH_ROWS rows, where the era's results scroll.
function BenillaKeyBindings_SearchMatches(words)
local out = {}
for i = 1, GetNumBindings() do
local command = GetBinding(i)
local tag = string.upper(KeyBindings_String("BINDING_NAME_" .. command, command))
local s = OptionsSearch_Score(tag, words)
if s then
table.insert(out, { score = s, index = i })
if not BenillaKeyBindings_IsHeader(command) then
local tag = string.upper(BenillaKeyBindings_String("BINDING_NAME_" .. command, command))
local s = BenillaOptionsSearch_Score(tag, words)
if s then
table.insert(out, { score = s, index = i })
end
end
end
return out
File diff suppressed because it is too large Load Diff
+80 -60
View File
@@ -1,10 +1,10 @@
<Ui>
<!--
The script error log, which 1.12 does not have, so every name carries the `Benilla` prefix
(`tests::reference_surface`); the stock `ScriptErrors` dialog shows one message, this keeps
the log. Shaped like the reference's CraftFrame. It polls the engine:
`BenillaGetNumScriptErrors()` is (retained, total-ever), `BenillaGetScriptErrorInfo(i)` is
`seq, kind, message, count` (1-based, oldest first, nil past the end).
The script error log, which 1.12 does not have, so every name carries the `Benilla` prefix;
the stock `ScriptErrors` dialog shows one message, this keeps the log. Shaped like the
reference's CraftFrame. It collects as a 1.12 error-log addon does: it wraps the handler
`geterrorhandler` holds, the stock `_ERRORMESSAGE` (BasicControls.xml:16), which still runs
after it. An addon that later calls `seterrorhandler` replaces the chain, as in 1.12.
-->
<Script><![CDATA[
-- A pushable above every stock panel's, so a game window opening pushes the log aside.
@@ -18,6 +18,39 @@
-- A `seq`, not an index: indices shift when the cap evicts.
BenillaScriptLogSelected = nil
-- Distinct messages kept before the oldest is evicted.
BENILLA_SCRIPTLOG_CAP = 256
-- Rows oldest first, each { seq, message, count }; `total` is the last `seq` handed out,
-- kept apart from the row count so an eviction or a clear never renumbers.
BenillaScriptLog = { rows = {}, total = 0 }
-- A repeat bumps its row's count and keeps its place.
function BenillaScriptLog_Record(message)
if type(message) ~= "string" then message = tostring(message) end
local log = BenillaScriptLog
local found = nil
for i = 1, table.getn(log.rows) do
if log.rows[i].message == message then found = log.rows[i] end
end
if found then
found.count = found.count + 1
else
log.total = log.total + 1
if table.getn(log.rows) >= BENILLA_SCRIPTLOG_CAP then table.remove(log.rows, 1) end
table.insert(log.rows, { seq = log.total, message = message, count = 1 })
end
if BenillaScriptLogFrame and BenillaScriptLogFrame:IsVisible() then
BenillaScriptLog_Update()
end
end
-- The chain: the log first, guarded so a fault in it cannot stop the stock handler.
local benillaStockErrorHandler = geterrorhandler()
seterrorhandler(function(message)
pcall(BenillaScriptLog_Record, message)
return benillaStockErrorHandler(message)
end)
-- `strsub` counts bytes, so the cut backs up over UTF-8 continuation bytes (0x80..0xBF).
function BenillaScriptLog_RowText(message)
local cut = strfind(message, "\n", 1, 1)
@@ -35,18 +68,10 @@
return head
end
-- `load` red (the addon is not running), `error` amber (code raised), `warn` grey (a call
-- was accepted and quietly did nothing).
function BenillaScriptLog_RowColor(kind)
if kind == "load" then return 1.0, 0.3, 0.3 end
if kind == "warn" then return 0.65, 0.65, 0.65 end
return 1.0, 0.82, 0.0
end
function BenillaScriptLog_Update()
local shown, total = BenillaGetNumScriptErrors()
local shown = table.getn(BenillaScriptLog.rows)
local total = BenillaScriptLog.total
-- "Problems", because two of the three kinds are not errors.
local summary
if total == 0 then
summary = "No problems this session."
@@ -68,18 +93,17 @@
for i = 1, BENILLA_SCRIPTLOG_ROWS do
local button = getglobal("BenillaScriptLogRow" .. i)
local index = offset + i
local seq, kind, message, count = BenillaGetScriptErrorInfo(index)
if seq then
button.seq = seq
button.message = message
local row = BenillaScriptLog.rows[index]
if row then
button.seq = row.seq
button.message = row.message
-- `#12 x1113`: one deduplicated failure that happened 1,113 times.
local label = "#" .. seq
if count > 1 then label = label .. " x" .. count end
local label = "#" .. row.seq
if row.count > 1 then label = label .. " x" .. row.count end
button.index:SetText(label)
button.label:SetText(BenillaScriptLog_RowText(message))
button.label:SetTextColor(BenillaScriptLog_RowColor(kind))
button.label:SetText(BenillaScriptLog_RowText(row.message))
button:Show()
if seq == BenillaScriptLogSelected then selectedRow = button end
if row.seq == BenillaScriptLogSelected then selectedRow = button end
else
button.seq = nil
button.message = nil
@@ -103,11 +127,9 @@
function BenillaScriptLog_UpdateDetail()
local text = ""
if BenillaScriptLogSelected then
local shown = BenillaGetNumScriptErrors()
for i = 1, shown do
local seq, _, message = BenillaGetScriptErrorInfo(i)
if seq == BenillaScriptLogSelected then
text = message
for _, row in ipairs(BenillaScriptLog.rows) do
if row.seq == BenillaScriptLogSelected then
text = row.message
break
end
end
@@ -133,7 +155,7 @@
BenillaScriptLog_Update()
end
function BenillaScriptLogFrame_OnLoad(self)
function BenillaScriptLogFrame_OnLoad()
-- Masks the square icon to the portrait ring.
SetPortraitToTexture("BenillaScriptLogFramePortrait",
"Interface\\Icons\\INV_Misc_Note_01")
@@ -157,7 +179,7 @@
-- `/errors clear`: keeps the numbering, so a number never names two failures.
function BenillaScriptLog_Clear()
BenillaClearScriptErrors()
BenillaScriptLog.rows = {}
BenillaScriptLogSelected = nil
if getglobal("BenillaScriptLogFrame"):IsVisible() then
BenillaScriptLog_Update()
@@ -184,10 +206,10 @@
<HighlightTexture file="Interface\Buttons\UI-Listbox-Highlight2" alphaMode="ADD"/>
<Scripts>
<OnLoad>
self.index = getglobal(self:GetName() .. "Index")
self.label = getglobal(self:GetName() .. "Label")
this.index = getglobal(this:GetName() .. "Index")
this.label = getglobal(this:GetName() .. "Label")
</OnLoad>
<OnClick>BenillaScriptLogRow_OnClick(self)</OnClick>
<OnClick>BenillaScriptLogRow_OnClick(this)</OnClick>
</Scripts>
</Button>
@@ -263,10 +285,10 @@
<Size><AbsDimension x="293" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-314"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:SetScrollChild(getglobal(self:GetName() .. "Child"))</OnLoad>
<OnMouseWheel>BenillaScroll_OnMouseWheel(self, arg1)</OnMouseWheel>
<OnVerticalScroll>BenillaScroll_SyncBar(self)</OnVerticalScroll>
<OnScrollRangeChanged>BenillaScroll_SyncBar(self)</OnScrollRangeChanged>
<OnLoad>this:SetScrollChild(getglobal(this:GetName() .. "Child"))</OnLoad>
<OnMouseWheel>BenillaScroll_OnMouseWheel(this, arg1)</OnMouseWheel>
<OnVerticalScroll>BenillaScroll_SyncBar(this)</OnVerticalScroll>
<OnScrollRangeChanged>BenillaScroll_SyncBar(this)</OnScrollRangeChanged>
</Scripts>
<Frames>
<Slider name="$parentBar" inherits="BenillaScrollBarTemplate" hidden="true">
@@ -274,7 +296,7 @@
<Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="-16"/></Offset></Anchor>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="6" y="16"/></Offset></Anchor>
</Anchors>
<Scripts><OnValueChanged>BenillaScrollBar_OnValueChanged(self, arg1)</OnValueChanged></Scripts>
<Scripts><OnValueChanged>BenillaScrollBar_OnValueChanged(this, arg1)</OnValueChanged></Scripts>
</Slider>
<Frame name="$parentChild">
<Size><AbsDimension x="293" y="128"/></Size>
@@ -304,34 +326,32 @@
</Button>
</Frames>
<Scripts>
<OnLoad>BenillaScriptLogFrame_OnLoad(self)</OnLoad>
<OnLoad>BenillaScriptLogFrame_OnLoad()</OnLoad>
<!-- A spin bubbles up the parent chain; the detail pane's own handler wins there. -->
<OnMouseWheel>BenillaScriptLogFrame_OnMouseWheel(arg1)</OnMouseWheel>
<OnShow>BenillaScriptLog_Update()</OnShow>
<OnUpdate>BenillaScriptLogFrame_OnUpdate()</OnUpdate>
</Scripts>
</Frame>
<!-- Benilla's slash commands, handed to SubmitChatInput (ui_chat::input); loads after the
stock ChatFrame.xml, which declares SlashCmdList. -->
<!-- The player's commands, as an addon registers them; after the stock ChatFrame.lua, which
declares SlashCmdList. -->
<Script><![CDATA[
local BENILLA_COMMANDS = {
{ "CHATTEST", "/chattest" },
{ "SHOT", "/shot" },
{ "LIQUID", "/liquid" },
{ "CASTVIS", "/castvis" },
{ "PARTYTEST", "/partytest" },
{ "REACTION", "/reaction", "/react" },
{ "RELOAD", "/reload" },
{ "ERRORS", "/errors", "/err" },
{ "CONVERTRAID", "/convertraid" },
}
for _, row in ipairs(BENILLA_COMMANDS) do
local key = "BENILLA_" .. row[1]
for i = 2, table.getn(row) do
setglobal("SLASH_" .. key .. (i - 1), row[i])
end
SlashCmdList[key] = function(msg)
SubmitChatInput(row[2] .. " " .. (msg or ""))
-- Deviation: 1.12 has no `/reload` (later clients added it; 1.12 reloads through
-- `/console reloadui`); kept so a player can reload the interface after toggling addons.
SLASH_BENILLA_RELOAD1 = "/reload"
SlashCmdList["BENILLA_RELOAD"] = function()
ReloadUI()
end
-- `/errors` toggles the log and `/errors clear` empties it; `/err` is the short form.
SLASH_BENILLA_ERRORS1 = "/errors"
SLASH_BENILLA_ERRORS2 = "/err"
SlashCmdList["BENILLA_ERRORS"] = function(msg)
local arg = string.lower((string.gsub(msg or "", "^%s*(.-)%s*$", "%1")))
if arg == "clear" then
BenillaScriptLog_Clear()
else
BenillaScriptLog_Toggle()
end
end
]]></Script>
@@ -183,7 +183,7 @@
</Button>
</Frames>
<Scripts>
<OnValueChanged>BenillaFauxScrollBar_OnValueChanged(self, arg1)</OnValueChanged>
<OnValueChanged>BenillaFauxScrollBar_OnValueChanged(this, arg1)</OnValueChanged>
</Scripts>
<ThumbTexture name="$parentThumbTexture" file="Interface\Buttons\UI-ScrollBar-Knob">
<Size><AbsDimension x="16" y="16"/></Size>
+24 -121
View File
@@ -15,10 +15,6 @@
//! An addon's `Bindings.xml` rows ([`benilla_ui::bindings_xml`]) dispatch here too: a resolved
//! chord names a [`Bound`], either a registry [`Cmd`] or an index into the addon table.
//!
//! While the Keybindings page has a capsule selected (`BenillaBindCapture`), raw input is
//! canonicalized (`ALT-CTRL-SHIFT-<TOKEN>`) and handed to the page, by the 1.12 law: lone
//! modifiers ignored, left and right clicks stay UI clicks, ESC binds like any key.
//!
//! Persistence: `benilla-config/bindings/account.txt` and `<Realm>-<Char>.txt` ([`store`]); the
//! character file's existence is the character-set state, as in the reference.
@@ -442,7 +438,6 @@ fn latch_and_dispatch(
// affordances: a player build keeps the reference's `CTRL-SHIFT-P` fallback to `SHIFT-P`.
let dev_plane = ctrl && shift && !alt && crate::run_mode::dev_affordances();
let armed = script.as_ref().is_some_and(|s| s.bind_capture_armed());
// A binding body runs as `ExecuteBinding` runs it, so the movement functions' gate passes.
let run_lua = |script: &mut Option<NonSendMut<UiScript>>, lua: &str, tag: &str| {
if let Some(s) = script.as_mut() {
@@ -452,47 +447,6 @@ fn latch_and_dispatch(
}
};
// ── The capture seam ── a Keybindings capsule is selected: swallow raw input and hand the
// chord string to the page's Lua. The 1.12 law: lone modifiers and unknown keys ignored,
// left and right stay UI clicks, the wheel is a chord. Super is not a 1.12 modifier, so a
// Super press is ignored outright.
if armed {
let mut captured: Option<String> = None;
for ev in keyboard.read() {
if ev.state != ButtonState::Pressed || ev.repeat || sup {
continue;
}
if let Some(token) = chord::key_token(ev.key_code) {
captured = Some(chord::chord_string(alt, ctrl, shift, token));
}
}
for b in [MouseButton::Middle, MouseButton::Forward, MouseButton::Back] {
if buttons.just_pressed(b) && !sup {
if let Some(token) = chord::mouse_token(b) {
captured = Some(chord::chord_string(alt, ctrl, shift, token));
}
}
}
if scroll.delta.y != 0.0 && !sup {
let token = if scroll.delta.y > 0.0 {
"MOUSEWHEELUP"
} else {
"MOUSEWHEELDOWN"
};
captured = Some(chord::chord_string(alt, ctrl, shift, token));
}
if let Some(chord_str) = captured {
// The Keybindings page's own handler, not a binding body.
if let Some(s) = script.as_mut() {
if let Err(e) = s.run(&crate::ui_script::host_key_capture(&chord_str)) {
warn!("bindings(capture): {e}");
}
}
}
// Releases still unlatch below so a key held across the arm cannot stick; nothing new
// latches or fires while armed.
}
// ── Who owns this frame's keys ── a focused EditBox eats every key while it holds focus; a
// shown keyboard frame ate the keys in `capture.consumed`. Both suppress a press and nothing
// else: in the reference a focused box makes the movement handlers no-ops, so the direction
@@ -533,7 +487,7 @@ fn latch_and_dispatch(
if !repeat {
state.down.push(key);
}
if armed || (typing && !arrow_exempt) || eaten || sup || repeat {
if (typing && !arrow_exempt) || eaten || sup || repeat {
continue;
}
if state.latched.iter().any(|&(k, _)| k == BindKey::Key(key)) {
@@ -579,7 +533,6 @@ fn latch_and_dispatch(
MouseButton::Back,
] {
if buttons.just_pressed(b)
&& !armed
&& !sup
&& hover.0.is_none()
&& !state.latched.iter().any(|&(k, _)| k == BindKey::Mouse(b))
@@ -619,7 +572,7 @@ fn latch_and_dispatch(
let wheel = wheel_lines(scroll.unit, scroll.delta.y);
// Over chrome only (`PointerOverUiPanel`): the wheel still zooms with the cursor on a
// nameplate, a mouse-enabled widget but not a panel.
if wheel != 0.0 && !armed && !sup && !over_ui.0 {
if wheel != 0.0 && !sup && !over_ui.0 {
let (key, amount) = if wheel > 0.0 {
(BindKey::WheelUp, wheel)
} else {
@@ -1071,59 +1024,9 @@ mod tests {
);
}
/// The armed capture seam, driven by a real wheel event.
#[test]
fn an_armed_capture_takes_a_wheel_notch() {
let mut script = UiScript::new().expect("VM");
script.register_bindings(&registry_commands());
script
.run(
r#"CAPTURED = nil
function KeyBindings_OnHostKey(chord) CAPTURED = chord end
BenillaBindCapture(true)"#,
)
.expect("arm");
let mut app = vm_harness(script);
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 1.0,
window: Entity::PLACEHOLDER,
});
app.update();
assert_eq!(
lua_str(&app, "tostring(CAPTURED)"),
"MOUSEWHEELUP",
"a wheel notch while armed is a binding key"
);
assert!(
!state(&app).fired(cmd::CAMERA_ZOOM_IN),
"the armed seam swallows the notch — it must not also zoom"
);
// Down, and a modified notch.
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -1.0,
window: Entity::PLACEHOLDER,
});
app.update();
assert_eq!(lua_str(&app, "tostring(CAPTURED)"), "MOUSEWHEELDOWN");
press_key(&mut app, KeyCode::ShiftLeft);
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 1.0,
window: Entity::PLACEHOLDER,
});
app.update();
assert_eq!(lua_str(&app, "tostring(CAPTURED)"), "SHIFT-MOUSEWHEELUP");
}
/// The whole wheel-bind path: the real Keybindings page, a capsule armed by a click, a real
/// notch, then the bound chord dispatching.
/// The whole wheel-bind path: the real Keybindings page, a capsule armed by a click, a notch
/// over the page (its OnMouseWheel, as 1.12's window binds it), then the bound chord
/// dispatching.
#[test]
fn a_wheel_notch_binds_through_the_real_page_and_then_dispatches() {
benilla_formats::wow_data_or_skip!();
@@ -1141,27 +1044,27 @@ mod tests {
.unwrap(),
crate::ui_script::keybindings_tests::label(&s, "BINDING_NAME_JUMP", "JUMP")
);
assert!(s.bind_capture_armed());
s.resolve();
let (x, y): (f32, f32) = s
.eval(&format!(
"return ({ROW}5:GetLeft() + {ROW}5:GetRight()) / 2, \
({ROW}5:GetTop() + {ROW}5:GetBottom()) / 2"
))
.unwrap();
s.mouse_wheel(x, y, 1.0);
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("MOUSEWHEELUP")"#)
.unwrap(),
"JUMP",
"a notch on an armed capsule is a bind"
);
assert!(
s.eval::<bool>("return BenillaKeyBindingsPage.selected == nil")
.unwrap(),
"the completed bind disarms"
);
let mut app = vm_harness(s);
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 1.0,
window: Entity::PLACEHOLDER,
});
app.update();
{
let s = app.world().non_send_resource::<UiScript>();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("MOUSEWHEELUP")"#)
.unwrap(),
"JUMP",
"a notch on an armed capsule is a bind"
);
assert!(!s.bind_capture_armed(), "the completed bind disarms");
}
// The bound chord now dispatches: the next notch jumps rather than binding.
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
+10 -46
View File
@@ -78,22 +78,6 @@ impl Chord {
}
}
/// Build the canonical chord string from live modifier state and a base token.
pub(crate) fn chord_string(alt: bool, ctrl: bool, shift: bool, token: &str) -> String {
let mut s = String::new();
if alt {
s.push_str("ALT-");
}
if ctrl {
s.push_str("CTRL-");
}
if shift {
s.push_str("SHIFT-");
}
s.push_str(token);
s
}
/// The 1.12 token for a physical key; `None` for a key the reference names `UNKNOWN` and for the
/// modifiers, which are only prefixes (`IsKeyPressIgnoredForBinding`). Both Enters are `ENTER`.
pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
@@ -235,29 +219,17 @@ pub(crate) fn normalize_key(k: KeyCode) -> KeyCode {
}
}
/// The 1.12 token for a mouse button: BUTTON4 is winit's `Forward` and BUTTON5 its `Back`, so the
/// 1.12 default `BUTTON4 TOGGLEAUTORUN` sits on `Forward`. Which physical button the reference
/// calls BUTTON4 is untraced.
pub(crate) fn mouse_token(b: MouseButton) -> Option<&'static str> {
Some(match b {
MouseButton::Left => "BUTTON1",
MouseButton::Right => "BUTTON2",
MouseButton::Middle => "BUTTON3",
MouseButton::Forward => "BUTTON4",
MouseButton::Back => "BUTTON5",
// The reference names further buttons `BUTTON<n>` (`0x4b6aa0`'s bit-scan fallback).
// winit passes the platform's button number, so the sixth button is `Other(5)`.
MouseButton::Other(n) => return EXTRA_BUTTONS.get(usize::from(n).wrapping_sub(5)).copied(),
})
}
/// `BUTTON6` to `BUTTON20`, indexed by `Other(n) - 5`.
/// `BUTTON6` to `BUTTON20`, indexed by `Other(n) - 5`: the reference names further buttons
/// `BUTTON<n>` (`0x4b6aa0`'s bit-scan fallback), and winit passes the platform's button number, so
/// the sixth button is `Other(5)`.
const EXTRA_BUTTONS: &[&str] = &[
"BUTTON6", "BUTTON7", "BUTTON8", "BUTTON9", "BUTTON10", "BUTTON11", "BUTTON12", "BUTTON13",
"BUTTON14", "BUTTON15", "BUTTON16", "BUTTON17", "BUTTON18", "BUTTON19", "BUTTON20",
];
/// Token to base input: the inverse of [`key_token`] and [`mouse_token`], plus the wheel pair.
/// Token to base input: the inverse of [`key_token`], the mouse buttons and the wheel pair.
/// BUTTON4 is winit's `Forward` and BUTTON5 its `Back`, so the 1.12 default `BUTTON4
/// TOGGLEAUTORUN` sits on `Forward`; which physical button the reference calls BUTTON4 is untraced.
fn token_key(t: &str) -> Option<BindKey> {
use KeyCode::*;
if let Some(b) = match t {
@@ -455,15 +427,11 @@ mod tests {
"{k:?} names '{token}', which SetBinding refuses"
);
}
for b in [
MouseButton::Left,
MouseButton::Right,
MouseButton::Middle,
MouseButton::Forward,
MouseButton::Back,
for token in [
"BUTTON1", "BUTTON2", "BUTTON3", "BUTTON4", "BUTTON5", "BUTTON20",
] {
let token = mouse_token(b).expect("named");
assert!(normalize_binding_key(token).is_some(), "{b:?} → '{token}'");
assert!(token_key(token).is_some(), "'{token}' names no button");
assert!(normalize_binding_key(token).is_some(), "'{token}'");
}
for token in ["MOUSEWHEELUP", "MOUSEWHEELDOWN"] {
assert!(normalize_binding_key(token).is_some());
@@ -626,10 +594,6 @@ mod tests {
})
);
assert_eq!(Chord::parse("BOGUS"), None);
assert_eq!(
chord_string(true, false, true, "PAGEDOWN"),
"ALT-SHIFT-PAGEDOWN"
);
assert_eq!(
Chord::parse("ALT-SHIFT-PAGEDOWN").unwrap(),
Chord {
+68 -39
View File
@@ -1129,9 +1129,9 @@ const TABLE: &[Spec] = &[
None
),
// ── MultiActionBar (BINDING_HEADER_MULTIACTIONBAR) ──────────────────────────────────
// The four multibars' `runOnUp` pairs (`Bindings.xml:799-1134`), stock MultiActionBars.lua's.
// Deviation: 1.12 files bars 2-4 under the spacer headers `BLANK`, `BLANK2` and `BLANK3`; they
// sit under MULTIACTIONBAR here because this page's sections are named.
// The four multibars' `runOnUp` pairs (`Bindings.xml:799-1134`), stock MultiActionBars.lua's;
// bars 2-4 under the spacer headers `BLANK`, `BLANK2` and `BLANK3`, as `Bindings.xml:883`,
// `:967` and `:1051` file them.
spec!(
"MULTIACTIONBAR1BUTTON1",
MULTIACTIONBAR,
@@ -1254,7 +1254,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR2BUTTON1",
MULTIACTIONBAR,
BLANK,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarBottomRight", 1)"#,
r#"MultiActionButtonUp("MultiBarBottomRight", 1)"#
@@ -1264,7 +1264,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR2BUTTON2",
MULTIACTIONBAR,
BLANK,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarBottomRight", 2)"#,
r#"MultiActionButtonUp("MultiBarBottomRight", 2)"#
@@ -1274,7 +1274,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR2BUTTON3",
MULTIACTIONBAR,
BLANK,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarBottomRight", 3)"#,
r#"MultiActionButtonUp("MultiBarBottomRight", 3)"#
@@ -1284,7 +1284,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR2BUTTON4",
MULTIACTIONBAR,
BLANK,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarBottomRight", 4)"#,
r#"MultiActionButtonUp("MultiBarBottomRight", 4)"#
@@ -1294,7 +1294,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR2BUTTON5",
MULTIACTIONBAR,
BLANK,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarBottomRight", 5)"#,
r#"MultiActionButtonUp("MultiBarBottomRight", 5)"#
@@ -1304,7 +1304,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR2BUTTON6",
MULTIACTIONBAR,
BLANK,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarBottomRight", 6)"#,
r#"MultiActionButtonUp("MultiBarBottomRight", 6)"#
@@ -1314,7 +1314,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR2BUTTON7",
MULTIACTIONBAR,
BLANK,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarBottomRight", 7)"#,
r#"MultiActionButtonUp("MultiBarBottomRight", 7)"#
@@ -1324,7 +1324,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR2BUTTON8",
MULTIACTIONBAR,
BLANK,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarBottomRight", 8)"#,
r#"MultiActionButtonUp("MultiBarBottomRight", 8)"#
@@ -1334,7 +1334,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR2BUTTON9",
MULTIACTIONBAR,
BLANK,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarBottomRight", 9)"#,
r#"MultiActionButtonUp("MultiBarBottomRight", 9)"#
@@ -1344,7 +1344,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR2BUTTON10",
MULTIACTIONBAR,
BLANK,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarBottomRight", 10)"#,
r#"MultiActionButtonUp("MultiBarBottomRight", 10)"#
@@ -1354,7 +1354,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR2BUTTON11",
MULTIACTIONBAR,
BLANK,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarBottomRight", 11)"#,
r#"MultiActionButtonUp("MultiBarBottomRight", 11)"#
@@ -1364,7 +1364,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR2BUTTON12",
MULTIACTIONBAR,
BLANK,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarBottomRight", 12)"#,
r#"MultiActionButtonUp("MultiBarBottomRight", 12)"#
@@ -1375,7 +1375,7 @@ const TABLE: &[Spec] = &[
// The vertical bars: `MultiBarRight` is bar 3, `MultiBarLeft` bar 4.
spec!(
"MULTIACTIONBAR3BUTTON1",
MULTIACTIONBAR,
BLANK2,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarRight", 1)"#,
r#"MultiActionButtonUp("MultiBarRight", 1)"#
@@ -1385,7 +1385,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR3BUTTON2",
MULTIACTIONBAR,
BLANK2,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarRight", 2)"#,
r#"MultiActionButtonUp("MultiBarRight", 2)"#
@@ -1395,7 +1395,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR3BUTTON3",
MULTIACTIONBAR,
BLANK2,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarRight", 3)"#,
r#"MultiActionButtonUp("MultiBarRight", 3)"#
@@ -1405,7 +1405,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR3BUTTON4",
MULTIACTIONBAR,
BLANK2,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarRight", 4)"#,
r#"MultiActionButtonUp("MultiBarRight", 4)"#
@@ -1415,7 +1415,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR3BUTTON5",
MULTIACTIONBAR,
BLANK2,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarRight", 5)"#,
r#"MultiActionButtonUp("MultiBarRight", 5)"#
@@ -1425,7 +1425,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR3BUTTON6",
MULTIACTIONBAR,
BLANK2,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarRight", 6)"#,
r#"MultiActionButtonUp("MultiBarRight", 6)"#
@@ -1435,7 +1435,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR3BUTTON7",
MULTIACTIONBAR,
BLANK2,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarRight", 7)"#,
r#"MultiActionButtonUp("MultiBarRight", 7)"#
@@ -1445,7 +1445,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR3BUTTON8",
MULTIACTIONBAR,
BLANK2,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarRight", 8)"#,
r#"MultiActionButtonUp("MultiBarRight", 8)"#
@@ -1455,7 +1455,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR3BUTTON9",
MULTIACTIONBAR,
BLANK2,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarRight", 9)"#,
r#"MultiActionButtonUp("MultiBarRight", 9)"#
@@ -1465,7 +1465,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR3BUTTON10",
MULTIACTIONBAR,
BLANK2,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarRight", 10)"#,
r#"MultiActionButtonUp("MultiBarRight", 10)"#
@@ -1475,7 +1475,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR3BUTTON11",
MULTIACTIONBAR,
BLANK2,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarRight", 11)"#,
r#"MultiActionButtonUp("MultiBarRight", 11)"#
@@ -1485,7 +1485,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR3BUTTON12",
MULTIACTIONBAR,
BLANK2,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarRight", 12)"#,
r#"MultiActionButtonUp("MultiBarRight", 12)"#
@@ -1495,7 +1495,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR4BUTTON1",
MULTIACTIONBAR,
BLANK3,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarLeft", 1)"#,
r#"MultiActionButtonUp("MultiBarLeft", 1)"#
@@ -1505,7 +1505,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR4BUTTON2",
MULTIACTIONBAR,
BLANK3,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarLeft", 2)"#,
r#"MultiActionButtonUp("MultiBarLeft", 2)"#
@@ -1515,7 +1515,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR4BUTTON3",
MULTIACTIONBAR,
BLANK3,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarLeft", 3)"#,
r#"MultiActionButtonUp("MultiBarLeft", 3)"#
@@ -1525,7 +1525,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR4BUTTON4",
MULTIACTIONBAR,
BLANK3,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarLeft", 4)"#,
r#"MultiActionButtonUp("MultiBarLeft", 4)"#
@@ -1535,7 +1535,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR4BUTTON5",
MULTIACTIONBAR,
BLANK3,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarLeft", 5)"#,
r#"MultiActionButtonUp("MultiBarLeft", 5)"#
@@ -1545,7 +1545,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR4BUTTON6",
MULTIACTIONBAR,
BLANK3,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarLeft", 6)"#,
r#"MultiActionButtonUp("MultiBarLeft", 6)"#
@@ -1555,7 +1555,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR4BUTTON7",
MULTIACTIONBAR,
BLANK3,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarLeft", 7)"#,
r#"MultiActionButtonUp("MultiBarLeft", 7)"#
@@ -1565,7 +1565,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR4BUTTON8",
MULTIACTIONBAR,
BLANK3,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarLeft", 8)"#,
r#"MultiActionButtonUp("MultiBarLeft", 8)"#
@@ -1575,7 +1575,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR4BUTTON9",
MULTIACTIONBAR,
BLANK3,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarLeft", 9)"#,
r#"MultiActionButtonUp("MultiBarLeft", 9)"#
@@ -1585,7 +1585,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR4BUTTON10",
MULTIACTIONBAR,
BLANK3,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarLeft", 10)"#,
r#"MultiActionButtonUp("MultiBarLeft", 10)"#
@@ -1595,7 +1595,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR4BUTTON11",
MULTIACTIONBAR,
BLANK3,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarLeft", 11)"#,
r#"MultiActionButtonUp("MultiBarLeft", 11)"#
@@ -1605,7 +1605,7 @@ const TABLE: &[Spec] = &[
),
spec!(
"MULTIACTIONBAR4BUTTON12",
MULTIACTIONBAR,
BLANK3,
Kind::EdgeUpDown(
r#"MultiActionButtonDown("MultiBarLeft", 12)"#,
r#"MultiActionButtonUp("MultiBarLeft", 12)"#
@@ -2101,6 +2101,35 @@ mod tests {
);
}
/// Each registered command sits under the header the install's `Bindings.xml` files it under:
/// the last `header=` at or before its row, so the host's `HEADER_*` rows (a category change
/// opens one) are the reference's, the spacers over multibars 2-4 included.
#[test]
fn every_command_sits_under_its_bindings_xml_header() {
let Some(binds) = install_bindings() else {
return;
};
let mut section = String::new();
let mut under = std::collections::HashMap::new();
for b in &binds {
if b.platform.as_deref().is_some_and(|p| p != "windows") {
continue;
}
if let Some(h) = &b.header {
section = format!("BINDING_HEADER_{h}");
}
under.insert(b.name.clone(), section.clone());
}
for spec in SPECS {
assert_eq!(
under.get(spec.name).map(String::as_str),
Some(spec.category),
"{} is filed under the wrong header",
spec.name
);
}
}
/// The install's own `Bindings.xml`, parsed.
fn install_bindings() -> Option<Vec<benilla_ui::bindings_xml::AddonBinding>> {
let data = benilla_formats::wow_data_or_skip!(None);
+1 -1
View File
@@ -1036,7 +1036,7 @@ pub(super) fn seed_ui_fixture(
if let Err(e) = script.run(
"ShowUIPanel(BenillaOptionsFrame); \
BenillaOptionsFrameCategoryListRowKeybindings:Click(); \
KeyBindings_ExpandSection(1, true); KeyBindingsPage_Update()",
BenillaKeyBindings_ExpandSection(1, true); BenillaKeyBindingsPage_Update()",
) {
warn!("capture: ui-keybindings seed failed: {e}");
}
+28 -46
View File
@@ -2,7 +2,9 @@
//! player's own strings define, read off the UI VM's globals the way `ChatEdit_ParseText` walks
//! them (`ChatFrame.lua:2164-2200`): `SLASH_<INDEX><n>` for the actions, `EMOTE<i>_CMD<j>` →
//! `EMOTE<i>_TOKEN` for the emotes. An action wins over an emote alias of the same name, as the
//! reference tries `SlashCmdList` first; benilla's own commands, literals, go in after both.
//! reference tries `SlashCmdList` first; benilla's dev instruments, literals, go in after both.
//! benilla's player commands (`/reload`, `/errors`) are its layer's `SlashCmdList` rows, which a
//! line this table does not know reaches through the drain's unknown-command arm.
use std::collections::HashMap;
@@ -50,16 +52,6 @@ pub(crate) enum SlashIndex {
MacroHelp,
/// `/console <line>`: `ConsoleExec(msg)`, as the reference's handler (`ChatFrame.lua:671`).
Console,
/// `/reload`, the rebuild `/console reloadUI` runs. Deviation: 1.12 has no such alias (later
/// clients added it); kept so a player can reload the interface after toggling addons.
ReloadUi,
/// `/errors`, `/err`: opens benilla's script error log. Deviation: 1.12 has only the
/// `ScriptErrors` modal, which shows a burst's first error and keeps none; addon users need
/// the list, so it is in every build.
ScriptErrors,
/// `/convertraid`: convert the party to a raid (`CMSG_GROUP_RAID_CONVERT`, leader only).
/// Deviation: 1.12's only trigger is the Raid tab's Convert button (`RaidFrame.xml`).
ConvertRaid,
}
impl SlashIndex {
@@ -101,15 +93,11 @@ impl SlashIndex {
Self::MacroUi => "MACRO",
Self::MacroHelp => "MACROHELP",
Self::Console => "CONSOLE",
// No shipped strings under these three keys: their aliases are literals in `build`.
Self::ReloadUi => "RELOADUI",
Self::ScriptErrors => "BENILLASCRIPTERRORS",
Self::ConvertRaid => "BENILLACONVERTRAID",
}
}
/// Every registered index; any other command answers `HELP_TEXT_SIMPLE`, as unknown ones do.
const ALL: [Self; 38] = [
const ALL: [Self; 35] = [
Self::Reply,
Self::Join,
Self::Leave,
@@ -145,9 +133,6 @@ impl SlashIndex {
Self::MacroUi,
Self::MacroHelp,
Self::Console,
Self::ReloadUi,
Self::ScriptErrors,
Self::ConvertRaid,
];
}
@@ -199,8 +184,8 @@ pub(crate) enum Command {
#[derive(Resource, Default)]
pub(crate) struct SlashCommands {
by_alias: HashMap<String, Command>,
/// Aliases per source, logged at boot: `(slash, emote, benilla additions, dev instruments)`.
counts: (usize, usize, usize, usize),
/// Aliases per source, logged at boot: `(slash, emote, dev instruments)`.
counts: (usize, usize, usize),
}
impl SlashCommands {
@@ -246,27 +231,7 @@ impl SlashCommands {
}
let emote_aliases = by_alias.len() - slash_aliases;
// 3 · benilla's player-facing additions, in every build; after the walks, so shipped wins.
insert(
&mut by_alias,
"reload",
Command::Slash(SlashIndex::ReloadUi),
);
for alias in ["errors", "err"] {
insert(
&mut by_alias,
alias,
Command::Slash(SlashIndex::ScriptErrors),
);
}
insert(
&mut by_alias,
"convertraid",
Command::Slash(SlashIndex::ConvertRaid),
);
let added_aliases = by_alias.len() - slash_aliases - emote_aliases;
// 4 · the dev instruments, dev builds only; a player build answers them as unknown.
// 3 · the dev instruments, dev builds only; a player build answers them as unknown.
let before_dev = by_alias.len();
if crate::run_mode::dev_affordances() {
for dev in DevCmd::ALL {
@@ -279,13 +244,13 @@ impl SlashCommands {
Self {
by_alias,
counts: (slash_aliases, emote_aliases, added_aliases, dev_aliases),
counts: (slash_aliases, emote_aliases, dev_aliases),
}
}
/// Distinct aliases per source (the shipped strings repeat: `EMOTE87_CMD1` and `_CMD2` are
/// both `"/sit"`); a player build reports 0 dev aliases.
pub(super) fn counts(&self) -> (usize, usize, usize, usize) {
pub(super) fn counts(&self) -> (usize, usize, usize) {
self.counts
}
}
@@ -298,6 +263,23 @@ fn insert(map: &mut HashMap<String, Command>, alias: &str, cmd: Command) {
}
}
/// The dev instruments as `SlashCmdList` rows (`BENILLA_<COMMAND>`), so a typed one reaches the
/// drain through the stock `ChatEdit_ParseText`; registered on a freshly loaded VM in a build with
/// dev affordances only, so a player build answers them `HELP_TEXT_SIMPLE`, as 1.12 does.
pub(crate) fn register_dev_commands(script: &benilla_ui::script::UiScript, dev: bool) {
if !dev {
return;
}
for cmd in DevCmd::ALL {
let aliases: Vec<String> = cmd.aliases().iter().map(|a| format!("/{a}")).collect();
let aliases: Vec<&str> = aliases.iter().map(String::as_str).collect();
let key = format!("BENILLA_{}", cmd.aliases()[0].to_ascii_uppercase());
if let Err(e) = script.register_host_slash_command(&key, &aliases) {
warn!("chat: registering /{}: {e}", cmd.aliases()[0]);
}
}
}
/// Build the table in `PostStartup`, after `Startup` has inserted the VM and the emote catalog.
pub(crate) fn build_slash_commands(
mut commands: Commands,
@@ -314,11 +296,11 @@ pub(crate) fn build_slash_commands(
|name| globals.get::<String>(name).ok().filter(|s| !s.is_empty()),
|token| emotes.text_id(token),
);
let (slash, emote, added, dev) = table.counts();
let (slash, emote, dev) = table.counts();
// The shipped 1.12 data gives the counts `real_alias_table_resolves_the_shipped_commands` pins.
info!(
"chat: slash table — {slash} command aliases, {emote} emote aliases, \
{added} benilla additions, {dev} instrument aliases"
{dev} instrument aliases"
);
if emote == 0 {
error!("chat: NO emote aliases — every /wave-style command is dead (GlobalStrings?)");
+2 -11
View File
@@ -169,8 +169,8 @@ pub(super) fn drain_chat_input(
return;
};
let mut queue = engine_verbs(&mut script, emotes.as_deref());
// benilla's own commands, from the `SlashCmdList` entries in `ScriptLogFrame.xml` once the
// stock `ChatEdit_ParseText` has found no built-in, and probe lines.
// The dev instruments, from their host `SlashCmdList` rows once the stock `ChatEdit_ParseText`
// has found no built-in (dev builds), and probe lines.
for raw in script.take_chat_input() {
let msg = raw.trim();
if msg.is_empty() {
@@ -537,11 +537,6 @@ pub(super) fn drain_chat_input(
let _ = commands.0.send(ClientCommand::GroupInvite { name });
}
}
// The server judges it: a raid-typed `SMSG_GROUP_LIST`, or an
// `SMSG_PARTY_COMMAND_RESULT` error.
ParsedChat::ConvertRaid => {
let _ = commands.0.send(ClientCommand::GroupRaidConvert);
}
ParsedChat::Uninvite { name } => {
if let Some(name) =
name.or_else(|| target_player_name(&selection, &names, &commands))
@@ -800,10 +795,6 @@ pub(super) fn drain_chat_input(
ParsedChat::Quit => {
script.queue_session_request(benilla_ui::script::SessionRequest::Quit)
}
// The deferred rebuild `ReloadUI()` queues.
ParsedChat::ReloadUi => {
script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi)
}
// Deferred to the world, since a command is `fn(&mut World, &str)`; its lines print
// as system text.
ParsedChat::Console { line } => {
+3 -20
View File
@@ -1,8 +1,8 @@
//! What a submitted slash line means: [`parse_line`] resolves it through the boot-built command
//! table ([`super::super::commands`], the shipped `GlobalStrings.lua` aliases) into a
//! [`ParsedChat`] for [`super`] to execute. A typed line reaches it only through benilla's own
//! `SlashCmdList` entries, once the stock `ChatEdit_ParseText` finds no built-in; a probe's line
//! reaches it directly.
//! [`ParsedChat`] for [`super`] to execute. A typed line reaches it only through the dev
//! instruments' `SlashCmdList` rows (dev builds), once the stock `ChatEdit_ParseText` finds no
//! built-in; a probe's line reaches it directly.
use crate::ui_chat::commands::{Command, DevCmd, SlashCommands, SlashIndex};
@@ -49,9 +49,6 @@ pub(in crate::ui_chat) enum ParsedChat {
Liquid,
/// `/reaction [name]`: every input and rung of the reaction ladder for one unit.
Reaction { name: Option<String> },
/// `/convertraid` (`CMSG_GROUP_RAID_CONVERT`), benilla's own: 1.12 converts only from the
/// RaidFrame's Convert button.
ConvertRaid,
/// `/help`.
Help,
/// An `EmotesText` command (`/wave` is 101), sent as `CMSG_TEXT_EMOTE` at the selection.
@@ -102,8 +99,6 @@ pub(in crate::ui_chat) enum ParsedChat {
Follow { name: Option<String> },
/// `/macrohelp`: `ChatFrame_DisplayMacroHelpText`'s five `MACRO_HELP_TEXT_LINE`s.
MacroHelp,
/// `/reload`, benilla's own: the in-world rebuild `ReloadUI()` queues.
ReloadUi,
/// A `/console` line the CVar store did not consume, for the `ConsoleCommand` registry
/// ([`crate::console::execute`]).
Console { line: String },
@@ -288,18 +283,6 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
body: "ShowMacroFrame()".into(),
},
S::MacroHelp => ParsedChat::MacroHelp,
// `/reload` is the rebuild `/console reloadUI` runs (`0x4035f0`, which reads no arguments).
S::ReloadUi => ParsedChat::ReloadUi,
S::ConvertRaid => ParsedChat::ConvertRaid,
// `/errors`, benilla's own: toggles the layer's script error log; `/errors clear` empties
// it. A no-op when the run boots without the layer.
S::ScriptErrors => ParsedChat::Lua {
body: if args.trim().eq_ignore_ascii_case("clear") {
crate::ui_script::ERRORS_CLEAR.into()
} else {
crate::ui_script::ERRORS_TOGGLE.into()
},
},
// `SlashCmdList["CONSOLE"]` (`ChatFrame.lua:671`) is `ConsoleExec(msg)`. A typed line runs
// it; one that skipped the edit box calls the same verb, so a CVar line writes the CVar
// and anything else comes back through `engine_verbs`.
+9 -6
View File
@@ -1437,7 +1437,11 @@ fn real_alias_table_resolves_the_shipped_commands() {
);
}
assert_eq!(parse_line("/macrohelp"), ParsedChat::MacroHelp);
assert_eq!(parse_line("/convertraid"), ParsedChat::ConvertRaid);
// 1.12 has none of these: `/convertraid` (the Raid tab's button converts), and benilla's
// `/reload` and `/errors`, which are its layer's `SlashCmdList` rows, not this table's.
for line in ["/convertraid", "/reload", "/errors", "/err"] {
assert_eq!(parse_line(line), ParsedChat::Unknown, "{line}");
}
// `/console` from a line that skipped the stock edit box forwards to the stock handler's verb.
assert_eq!(
parse_line("/console fpsJournal 1"),
@@ -1476,9 +1480,8 @@ fn real_alias_table_resolves_the_shipped_commands() {
}
}
// The shipped surface: 68 distinct aliases over 36 `SlashCmdList` indices and 225 emote
// commands over 169 `EmotesText` names (aliases repeat; EMOTE27 "UNUSED" has no row). Then
// benilla's own `/reload`, `/errors`, `/err` and `/convertraid`, which are not 1.12 commands,
// in every build, and 7 instrument aliases, in dev builds only.
// commands over 169 `EmotesText` names (aliases repeat; EMOTE27 "UNUSED" has no row), then 7
// instrument aliases, in dev builds only.
let instruments = if crate::run_mode::dev_affordances() {
7
} else {
@@ -1486,8 +1489,8 @@ fn real_alias_table_resolves_the_shipped_commands() {
};
assert_eq!(
table.counts(),
(68, 225, 4, instruments),
"(slash, emote, benilla addition, instrument) aliases"
(68, 225, instruments),
"(slash, emote, instrument) aliases"
);
}
@@ -719,8 +719,8 @@ fn backpack() -> ContainerState {
}
}
/// The menu wears the options window's `ERA_WINDOW_SCALE` on show, as the era client draws the two
/// at one density; loaded with OptionsFrame.xml, which defines it.
/// The menu wears the options window's `BENILLA_ERA_WINDOW_SCALE` on show, as the era client draws
/// the two at one density; loaded with OptionsFrame.xml, which defines it.
#[test]
fn the_menu_rides_the_shared_era_window_scale() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -737,7 +737,7 @@ fn the_menu_rides_the_shared_era_window_scale() {
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let k = s.eval::<f64>("return GameMenuFrame:GetScale()").unwrap();
let want = s.eval::<f64>("return ERA_WINDOW_SCALE").unwrap();
let want = s.eval::<f64>("return BENILLA_ERA_WINDOW_SCALE").unwrap();
assert!((k - want).abs() < 1e-6, "menu scale {k} != knob {want}");
assert!((want - 0.78).abs() < 1e-6, "the knob itself moved: {want}");
}
+39 -5
View File
@@ -47,6 +47,18 @@ impl PointerFeed<'_> {
}
}
/// The buttons the pointer feed hands the UI, by the names its handlers read (`arg1` of `OnClick`,
/// `OnMouseDown` and `OnMouseUp`, and the ones `RegisterForClicks` takes,
/// `Blizzard_BindingUI.xml:10`); buttons 4 and 5 on the physical buttons the binding chords call
/// BUTTON4 and BUTTON5 (`bindings::chord`).
const UI_MOUSE_BUTTONS: [(MouseButton, &str); 5] = [
(MouseButton::Left, "LeftButton"),
(MouseButton::Right, "RightButton"),
(MouseButton::Middle, "MiddleButton"),
(MouseButton::Forward, "Button4"),
(MouseButton::Back, "Button5"),
];
/// One `OnMouseWheel` call per whole notch, `arg1 = ±1`, the fraction carried in `notches`: a
/// trackpad gesture arrives as a `Pixel` trickle, and the stock handlers act on the sign alone
/// (`ScrollFrameTemplate_OnMouseWheel` scrolls half a pane per call, `UIPanelTemplates.lua:150`).
@@ -162,11 +174,7 @@ pub(super) fn feed_ui_input(
);
}
}
for (btn, name) in [
(MouseButton::Left, "LeftButton"),
(MouseButton::Right, "RightButton"),
(MouseButton::Middle, "MiddleButton"),
] {
for (btn, name) in UI_MOUSE_BUTTONS {
if buttons.just_pressed(btn) {
// A left press that would drop into the world (`world_drop_click`: any payload over
// terrain or nothing) is consumed now: the drop fires on the release, but the world
@@ -390,6 +398,32 @@ mod tests {
assert!(sum <= 1.0, "…and they may move at most one notch: {sum}");
}
/// Every button 1.12's frames take reaches the UI, under the name its binding chord agrees
/// with, so the Key Bindings page binds mouse 4 and 5 from a click.
#[test]
fn the_ui_takes_all_five_buttons_under_the_chords_names() {
let names: Vec<&str> = UI_MOUSE_BUTTONS.iter().map(|(_, n)| *n).collect();
assert_eq!(
names,
[
"LeftButton",
"RightButton",
"MiddleButton",
"Button4",
"Button5"
]
);
for (i, (button, _)) in UI_MOUSE_BUTTONS.iter().enumerate() {
let chord = crate::bindings::chord::Chord::parse(&format!("BUTTON{}", i + 1));
assert_eq!(
chord.map(|c| c.key),
Some(crate::bindings::chord::BindKey::Mouse(*button)),
"BUTTON{} is {button:?}",
i + 1
);
}
}
#[test]
fn a_line_notch_fires_once() {
let mut s = wheel_pane();
@@ -11,17 +11,7 @@ use crate::bindings::commands::SPECS;
/// does.
pub(crate) fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
let cmds: Vec<KeybindCommand> = SPECS
.iter()
.map(|spec| KeybindCommand {
name: spec.name,
category: spec.category,
run_on_up: spec.run_on_up(),
default1: spec.d1,
default2: spec.d2,
})
.collect();
s.register_bindings(&cmds);
register_specs(&mut s);
s.set_screen_size(1024.0, 768.0);
for file in [
"Interface\\FrameXML\\GlobalStrings.lua",
@@ -54,10 +44,27 @@ pub(crate) fn harness() -> UiScript {
s
}
/// The host's command registry, as `seed_bindings_for_vm` registers it.
pub(crate) fn register_specs(s: &mut UiScript) {
let cmds: Vec<KeybindCommand> = SPECS
.iter()
.map(|spec| KeybindCommand {
name: spec.name,
category: spec.category,
run_on_up: spec.run_on_up(),
default1: spec.d1,
default2: spec.d2,
})
.collect();
s.register_bindings(&cmds);
}
/// A label as the page resolves it: the GlobalStrings global named `token`, else `raw`.
pub(crate) fn label(s: &UiScript, token: &str, raw: &str) -> String {
s.eval::<String>(&format!(r#"return KeyBindings_String("{token}", "{raw}")"#))
.unwrap()
s.eval::<String>(&format!(
r#"return BenillaKeyBindings_String("{token}", "{raw}")"#
))
.unwrap()
}
/// Open the options window on the Keybindings page.
@@ -69,6 +76,53 @@ pub(crate) fn on_page(s: &mut UiScript) {
const ROW: &str = "BenillaOptionsFrameContainerBodyKeybindingsRow";
/// Whether the window takes keys and the wheel: a selected capsule arms it, as 1.12's
/// keyboard-enabled `KeyBindingFrame` (`Blizzard_BindingUI.xml:73`).
pub(crate) fn armed(s: &UiScript) -> bool {
s.eval::<bool>(
"return BenillaOptionsFrame:IsKeyboardEnabled() == 1 \
and BenillaOptionsFrame:IsMouseWheelEnabled() == 1 \
and BenillaKeyBindingsPage.selected ~= nil",
)
.unwrap()
}
/// A key press as the host feeds it (`ui_script::input`): ENTER, ESCAPE and TAB through the named
/// walk, every other key through the frame walk by its 1.12 name. Answers whether a frame took it.
pub(crate) fn press(s: &mut UiScript, key: &str) -> bool {
match key {
"ENTER" | "ESCAPE" | "TAB" => s.key_input(key),
_ => s.frame_key_input(key),
}
}
/// A frame's centre in screen pixels, where the pointer feed hits it.
pub(crate) fn centre(s: &mut UiScript, frame: &str) -> (f32, f32) {
s.resolve();
let (l, r, t, b, k): (f64, f64, f64, f64, f64) = s
.eval(&format!(
"local f = {frame} \
local k = (f.GetEffectiveScale and f or f:GetParent()):GetEffectiveScale() \
return f:GetLeft(), f:GetRight(), f:GetTop(), f:GetBottom(), k"
))
.unwrap();
(((l + r) * 0.5 * k) as f32, ((t + b) * 0.5 * k) as f32)
}
/// A click of `button` on `frame` through the pointer feed.
pub(crate) fn click(s: &mut UiScript, frame: &str, button: &str) {
let (x, y) = centre(s, frame);
s.mouse_move(x, y);
s.mouse_button(x, y, button, true);
s.mouse_button(x, y, button, false);
}
/// One wheel notch over the page, through the pointer feed.
pub(crate) fn wheel(s: &mut UiScript, delta: f32) {
let (x, y) = centre(s, "BenillaOptionsFrameContainerBodyKeybindings");
s.mouse_wheel(x, y, delta);
}
#[test]
fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
benilla_formats::wow_data_or_skip!();
@@ -90,10 +144,12 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// The categories in `Bindings.xml` order, each a header collapsed by default, as in the era.
// The categories in `Bindings.xml` order, each a header collapsed by default, as in the era;
// the spacer headers over multibars 2-4 continue the section above (the page's deviation).
let mut expected: Vec<&str> = Vec::new();
for spec in SPECS {
if !expected.contains(&spec.category) {
if !expected.contains(&spec.category) && !spec.category.starts_with("BINDING_HEADER_BLANK")
{
expected.push(spec.category);
}
}
@@ -125,7 +181,7 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
r#"BINDING_HEADER_MOVEMENT = "Movement Keys"
BINDING_NAME_MOVEANDSTEER = "Move and Steer"
KEY_BUTTON3 = "Middle Mouse"
KeyBindingsPage_Update()"#,
BenillaKeyBindingsPage_Update()"#,
)
.unwrap();
assert_eq!(
@@ -168,21 +224,23 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
on_page(&mut s);
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
s.take_keybind_requests();
// Row 3 is MOVEFORWARD (W, UP); selecting its Key 1 capsule arms the capture.
assert!(!s.bind_capture_armed());
// Unarmed, the window takes no key: it reaches the game's bindings.
assert!(!armed(&s));
assert!(!press(&mut s, "J"), "an unarmed page lets a key through");
// Row 3 is MOVEFORWARD (W, UP); selecting its Key 1 capsule arms the window.
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(
s.bind_capture_armed(),
"a selected capsule arms the capture"
);
assert!(armed(&s), "a selected capsule arms the window");
assert!(
s.eval::<bool>("return BenillaOptionsFrameContainerUnbind:IsEnabled() ~= 0")
.unwrap(),
"Unbind arms with the selection"
);
// A lone modifier is no key (`Blizzard_BindingUI.lua:172-174`): still armed.
assert!(press(&mut s, "SHIFT"), "the armed window takes it");
assert!(armed(&s), "…and waits for a real key");
// J, a key no command holds, takes slot 1 from W; UP stays in slot 2 and the bind saves.
s.run(r#"KeyBindings_OnHostKey("J")"#).unwrap();
assert!(!s.bind_capture_armed(), "a completed bind disarms");
assert!(press(&mut s, "J"), "the armed window takes the key");
assert!(!armed(&s), "a completed bind disarms");
assert!(s
.eval::<bool>(
r#"local k1, k2 = GetBindingKey("MOVEFORWARD"); return k1 == "J" and k2 == "UP""#
@@ -202,7 +260,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
// T is ATTACKTARGET's only key, so taking it names the victim in red (1.12's
// `KEY_UNBOUND_ERROR`, `Blizzard_BindingUI.lua:185-190`).
s.run(&format!("{ROW}3Key2Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("T")"#).unwrap();
press(&mut s, "T");
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("T")"#).unwrap(),
"MOVEFORWARD"
@@ -214,9 +272,28 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
.contains(&victim),
"the newly-bare victim is named"
);
// A chord: the modifiers held at the press prefix the key, ALT-CTRL-SHIFT order
// (`Blizzard_BindingUI.lua:175-183`).
s.run(&format!("{ROW}3Key2Button:Click()")).unwrap();
s.set_modifiers(true, true, false);
press(&mut s, "K");
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("CTRL-SHIFT-K")"#)
.unwrap(),
"MOVEFORWARD"
);
// ESC while armed binds Escape: 1.12's armed branch has no ESCAPE filter.
s.run(&format!("{ROW}3Key2Button:Click()")).unwrap();
assert!(press(&mut s, "ESCAPE"));
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("ESCAPE")"#)
.unwrap(),
"MOVEFORWARD"
);
// The wheel binds onto a press+release command too: `0x4b7490` never reads the command.
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("MOUSEWHEELUP")"#).unwrap();
wheel(&mut s, 1.0);
assert!(
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "MOUSEWHEELUP""#)
.unwrap(),
@@ -234,7 +311,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
// A key string the engine rejects restores the slot's old key and shows the only refusal text
// 1.12 has, the wheel's (`KeyBindingFrame_SetBinding`, `Blizzard_BindingUI.lua:260-270`).
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("SCROLLLOCK")"#).unwrap();
press(&mut s, "SCROLLLOCK");
assert!(
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "MOUSEWHEELUP""#)
.unwrap(),
@@ -246,15 +323,130 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
"Can't bind mousewheel to actions with up and down states"
);
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(s.bind_capture_armed());
assert!(armed(&s));
s.run(&format!(r#"{ROW}3Key1Button:Click("RightButton")"#))
.unwrap();
assert!(!s.bind_capture_armed(), "right-click deselects");
// Hiding the window disarms: an armed capture with no window would swallow all input.
assert!(!armed(&s), "right-click deselects");
// Hiding the window disarms.
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(s.bind_capture_armed());
assert!(armed(&s));
s.run("HideUIPanel(BenillaOptionsFrame)").unwrap();
assert!(!s.bind_capture_armed(), "OnHide disarms");
assert!(!armed(&s), "OnHide disarms");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Mouse buttons 3-5 bind through the clicks 1.12's window takes: a capsule's own `OnClick`
/// (`Blizzard_BindingUI.lua:246`) and the window's (`Blizzard_BindingUI.xml:538-540`), here the
/// page's `OnMouseUp`; left and right clicks only select.
#[test]
fn mouse_buttons_bind_through_the_capsules_and_the_page() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
on_page(&mut s);
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
let capsule = format!("{ROW}3Key1Button");
click(&mut s, &capsule, "LeftButton");
assert!(armed(&s), "a left click selects");
click(&mut s, &capsule, "MiddleButton");
assert!(!armed(&s));
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("BUTTON3")"#)
.unwrap(),
"MOVEFORWARD",
"a middle click on the armed capsule binds BUTTON3"
);
// Over the page, away from any capsule: mouse 4 with SHIFT held.
click(&mut s, &capsule, "LeftButton");
s.set_modifiers(true, false, false);
click(&mut s, &format!("{ROW}3Description"), "Button4");
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("SHIFT-BUTTON4")"#)
.unwrap(),
"MOVEFORWARD"
);
// Unarmed, a middle click on a capsule selects it, as 1.12's does.
click(&mut s, &format!("{ROW}4Key2Button"), "MiddleButton");
assert!(armed(&s));
s.run("BenillaKeyBindings_SetSelected(nil)").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Loading the stock `Blizzard_BindingUI` (an addon manager's Load button does) defines its own
/// `KeyBindingButton_OnClick` and popup (`Blizzard_BindingUI.lua:228`, `:10`) and changes no other
/// window: the page keeps binding and leaving the character set. The stock window reads the 1.12
/// list: a `HEADER_*` row draws a section header, a command's keys fill Key 1 and Key 2.
#[test]
fn the_page_works_after_the_stock_binding_ui_loads_and_the_stock_window_reads_the_list() {
benilla_formats::wow_data_or_skip!();
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (mut s, failures) =
super::layer_tests::production_load_with("bindingui", false, "", |_| {});
assert!(failures.is_empty(), "load failures: {failures:#?}");
register_specs(&mut s);
s.run(r#"assert(LoadAddOn("Blizzard_BindingUI") == 1)"#)
.unwrap();
assert!(s
.eval::<bool>("return KeyBindingFrame ~= nil and KeyBindingButton_OnClick ~= nil")
.unwrap());
on_page(&mut s);
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
s.take_keybind_requests();
click(&mut s, &format!("{ROW}3Key1Button"), "LeftButton");
assert!(armed(&s), "the page's capsule still arms its own window");
assert!(
s.eval::<bool>("return KeyBindingFrame.selected == nil")
.unwrap(),
"the stock window is untouched"
);
press(&mut s, "J");
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("J")"#).unwrap(),
"MOVEFORWARD"
);
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
// The character set, both ways, through the page's own popup.
let check = "BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck";
s.run(&format!("{check}:Click()")).unwrap();
assert_eq!(s.current_binding_set(), 2);
s.run(&format!("{check}:Click()")).unwrap();
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(
s.current_binding_set(),
1,
"leaving the character set works"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
s.run("HideUIPanel(BenillaOptionsFrame)").unwrap();
// The stock window over the same list.
s.run("ShowUIPanel(KeyBindingFrame)").unwrap();
assert!(s
.eval::<bool>("return KeyBindingFrame:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>(
"return KeyBindingFrameBinding1Header:IsVisible() \
and KeyBindingFrameBinding1Header:GetText() == BINDING_HEADER_MOVEMENT"
)
.unwrap());
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding2Key1Button:GetText()")
.unwrap(),
s.eval::<String>(r#"return GetBindingText("BUTTON3", "KEY_")"#)
.unwrap(),
"Move and Steer's first key in the Key 1 column"
);
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding3Key2Button:GetText()")
.unwrap(),
s.eval::<String>(r#"return GetBindingText("UP", "KEY_")"#)
.unwrap(),
"Move Forward's second key in the Key 2 column"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -283,7 +475,7 @@ fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
// Deviation: a bind saves at once, as the era panel commits, where 1.12 had Okay and Cancel;
// closing the window keeps it.
s.run(&format!("{ROW}9Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("G")"#).unwrap();
press(&mut s, "G");
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
s.run("BenillaOptionsFrameCloseButton:Click()").unwrap();
assert_eq!(
@@ -384,8 +576,8 @@ fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
.unwrap());
s.run("BenillaOptionsFrameContainerBodyKeybindSearch1Key1Button:Click()")
.unwrap();
assert!(s.bind_capture_armed());
s.run(r#"KeyBindings_OnHostKey("H")"#).unwrap();
assert!(armed(&s));
press(&mut s, "H");
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("H")"#).unwrap(),
"JUMP"
@@ -474,8 +666,8 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
on_page(&mut s);
// Every section open: over 100 rows overflow the list's 19 slots.
s.run(
r#"for i = 1, table.getn(KeyBindingsPage.sections) do KeyBindings_ExpandSection(i, true) end
KeyBindingsPage_Update()"#,
r#"for i = 1, table.getn(BenillaKeyBindingsPage.sections) do BenillaKeyBindings_ExpandSection(i, true) end
BenillaKeyBindingsPage_Update()"#,
)
.unwrap();
const SF: &str = "BenillaOptionsFrameContainerBodyKeybindingsScrollFrame";
@@ -536,8 +728,8 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
);
let set_all = |s: &UiScript, open: bool| {
s.run(&format!(
"for i = 1, table.getn(KeyBindingsPage.sections) do KeyBindings_ExpandSection(i, {}) end
KeyBindingsPage_Update()",
"for i = 1, table.getn(BenillaKeyBindingsPage.sections) do BenillaKeyBindings_ExpandSection(i, {}) end
BenillaKeyBindingsPage_Update()",
if open { "true" } else { "nil" }
))
.unwrap();
@@ -603,9 +795,11 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
after_capsule > after_name,
"a spin over a CAPSULE bubbles to the page too: {after_name} -> {after_capsule}"
);
// While a capsule is armed a spin binds, never scrolls; the page's guard catches a spin the
// host lets through.
// While a capsule is armed a spin binds, never scrolls.
s.run(&format!("{ROW}2Key1Button:Click()")).unwrap();
let armed_command = s
.eval::<String>(&format!("return {ROW}2Key1Button.commandName"))
.unwrap();
s.mouse_wheel(wx, wy, -1.0);
assert_eq!(
s.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
@@ -613,7 +807,12 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
after_capsule,
"armed: the wheel must not scroll"
);
s.run("KeyBindings_SetSelected(nil)").unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("MOUSEWHEELDOWN")"#)
.unwrap(),
armed_command,
"armed: the spin binds"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -626,11 +825,14 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
let mut s = harness();
on_page(&mut s);
// The header row a command sits under: the last `HEADER_*` row above it.
let category = |s: &mut UiScript, name: &str| {
s.eval::<String>(&format!(
r#"for i = 1, GetNumBindings() do
local n, c = GetBinding(i)
if n == "{name}" then return c end
r#"local header
for i = 1, GetNumBindings() do
local n = GetBinding(i)
if strsub(n, 1, 6) == "HEADER" then header = n end
if n == "{name}" then return header end
end"#
))
.unwrap()
@@ -639,7 +841,7 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
let name = format!("BONUSACTIONBUTTON{i}");
assert_eq!(
category(&mut s, &name),
"BINDING_HEADER_ACTIONBAR",
"HEADER_ACTIONBAR",
"{name} files under the action bar, as 1.12 does"
);
let key = s
+230 -10
View File
@@ -236,8 +236,8 @@ fn the_stock_ui_switch_is_a_dev_builds_alone() {
assert!(layer_enabled_by(true, None) && layer_enabled_by(true, Some("0")));
}
/// Booted without the layer, as `WOW_STOCK_UI=1` does: no layer file loads, the stock files raise
/// nothing, and the host's calls into the layer are no-ops.
/// Booted without the layer, as `WOW_STOCK_UI=1` does: no layer file loads and the stock files
/// raise nothing.
#[test]
fn the_stock_ui_switch_boots_the_core_without_the_layer() {
benilla_formats::wow_data_or_skip!();
@@ -250,7 +250,7 @@ fn the_stock_ui_switch_boots_the_core_without_the_layer() {
// One global from each layer file that defines a new one.
for name in [
"BenillaScrollBar_Step",
"KeyBindings_OnHostKey",
"BenillaKeyBindings_OnKeyDown",
"BenillaOptionsFrame_SelectCategory",
"BenillaGameMenuButtonEditMode",
"BENILLA_BAG_WAS_OPEN",
@@ -263,13 +263,233 @@ fn the_stock_ui_switch_boots_the_core_without_the_layer() {
}
assert!(!s.eval::<bool>("return BenillaOptionsFrame ~= nil").unwrap());
assert!(!s.eval::<bool>("return ZZORDER_SAW_LAYER").unwrap());
// The host's calls into the layer.
for body in [
super::ERRORS_TOGGLE.to_string(),
super::ERRORS_CLEAR.to_string(),
super::host_key_capture("CTRL-J"),
] {
s.run(&body).unwrap_or_else(|e| panic!("{body}: {e}"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Types `line` into the chat box and sends it through the stock `ChatEdit_ParseText`
/// (`ChatFrame.lua:2087`), counting the `HELP_TEXT_SIMPLE` lines it prints.
fn type_line(s: &UiScript, line: &str) -> i64 {
s.run(&format!(
r#"HELPS = 0
local box = ChatFrameEditBox
box.chatFrame = box.chatFrame or DEFAULT_CHAT_FRAME
local frame = box.chatFrame
local add = frame.AddMessage
frame.AddMessage = function(f, text, r, g, b, id)
if text == HELP_TEXT_SIMPLE then HELPS = HELPS + 1 end
return add(f, text, r, g, b, id)
end
box:SetText({line:?})
ChatEdit_ParseText(box, 1)
frame.AddMessage = add"#
))
.unwrap_or_else(|e| panic!("{line}: {e}"));
s.eval("return HELPS").unwrap()
}
/// The layer's player commands are plain `SlashCmdList` rows: `/reload` runs `ReloadUI()`,
/// `/errors` (`/err`) toggles the log and `/errors clear` empties it. 1.12 has no `/convertraid`
/// (the Raid tab's button converts, `RaidFrame.lua:63-71`), so it answers `HELP_TEXT_SIMPLE`, and
/// the forwarding hook `SubmitChatInput` is gone.
#[test]
fn the_layers_commands_are_slash_rows_and_convertraid_is_unknown() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (mut s, failures) = production_load("slash", false);
assert!(failures.is_empty(), "load failures: {failures:#?}");
assert!(s.eval::<bool>("return SubmitChatInput == nil").unwrap());
s.take_session_requests();
assert_eq!(type_line(&s, "/reload"), 0);
assert_eq!(
s.take_session_requests(),
[benilla_ui::script::SessionRequest::ReloadUi],
"/reload is ReloadUI()"
);
assert!(
s.take_chat_input().is_empty(),
"nothing is handed to the host"
);
assert_eq!(type_line(&s, "/errors"), 0);
assert!(s
.eval::<bool>("return BenillaScriptLogFrame:IsVisible()")
.unwrap());
assert_eq!(type_line(&s, "/err"), 0);
assert!(!s
.eval::<bool>("return BenillaScriptLogFrame:IsVisible()")
.unwrap());
s.run("BenillaScriptLog_Record('kept')").unwrap();
assert_eq!(type_line(&s, "/errors clear "), 0);
assert_eq!(
s.eval::<i64>("return table.getn(BenillaScriptLog.rows)")
.unwrap(),
0,
"/errors clear empties the log"
);
assert_eq!(type_line(&s, "/convertraid"), 1, "the unknown-command line");
assert!(s.take_chat_input().is_empty());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// In a player build the dev instruments are no `SlashCmdList` row, so a typed one answers
/// `HELP_TEXT_SIMPLE` once and hands the host nothing to re-run; in a dev build each is a host row
/// that hands the host its line.
#[test]
fn the_dev_commands_are_host_rows_in_a_dev_build_alone() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let lines = [
"/chattest",
"/shot",
"/liquid",
"/castvis",
"/partytest ping",
"/reaction",
"/react Probe",
];
let (mut s, failures) = production_load("devcmds-player", false);
assert!(failures.is_empty(), "load failures: {failures:#?}");
crate::ui_chat::commands::register_dev_commands(&s, false);
for line in lines {
assert_eq!(
type_line(&s, line),
1,
"{line}: the unknown-command line, once"
);
assert!(
s.take_chat_input().is_empty(),
"{line}: nothing queued to loop"
);
let cmd = line[1..].split(' ').next().unwrap();
assert!(!s.has_slash_command(cmd), "{cmd} is no SlashCmdList row");
}
drop(s);
let (mut s, failures) = production_load("devcmds-dev", false);
assert!(failures.is_empty(), "load failures: {failures:#?}");
crate::ui_chat::commands::register_dev_commands(&s, true);
for line in lines {
assert_eq!(type_line(&s, line), 0, "{line}");
let queued = s.take_chat_input();
assert_eq!(queued.len(), 1, "{line}: {queued:?}");
// An alias queues under the command's first one, as the row's handler knows only that.
let want = line.replacen("/react ", "/reaction ", 1);
assert_eq!(queued[0].trim(), want, "{line}");
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The `/errors` log collects off the Lua error handler, ahead of the stock `_ERRORMESSAGE`
/// (`BasicControls.xml:16`), which still shows the `ScriptErrors` dialog: an addon's runtime error,
/// an error in one of the layer's own handlers, and one raised in the stock load before the layer
/// loaded (the engine hands caught errors to the handler after the load).
#[test]
fn the_error_log_collects_off_the_error_handler() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let before = r#"
local pre = CreateFrame("Button", "ZZPreLayer")
pre:SetScript("OnClick", function() error("before the layer") end)
pre:Click()
"#;
let (mut s, failures) = production_load_with("errlog", false, before, |root| {
let dir = root.join("ZZErr");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ZZErr.toc"), "## Interface: 11200\nerr.lua\n").unwrap();
std::fs::write(
dir.join("err.lua"),
"ZZErrButton = CreateFrame('Button', 'ZZErrButton')\n\
ZZErrButton:SetScript('OnClick', function() error('an addon boom') end)\n",
)
.unwrap();
});
assert!(
failures.iter().all(|f| f.contains("before the layer")),
"load failures: {failures:#?}"
);
s.run("ZZErrButton:Click()").unwrap();
// A layer handler raising: the window's OnShow calls this.
s.run("BenillaOptionsFrame_UpdateScale = function() error('a layer boom') end")
.unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.dispatch_script_errors_to_handler();
let rows: Vec<String> = s
.eval(
"local out = {} \
for _, row in ipairs(BenillaScriptLog.rows) do table.insert(out, row.message) end \
return out",
)
.unwrap();
for want in ["before the layer", "an addon boom", "a layer boom"] {
assert!(
rows.iter().any(|r| r.contains(want)),
"{want:?} is in the log: {rows:#?}"
);
}
assert!(
s.eval::<bool>("return ScriptErrors:IsVisible()").unwrap(),
"the stock handler still runs after the log"
);
let shown: String = s.eval("return ScriptErrors_Message:GetText()").unwrap();
assert!(
shown.contains("before the layer"),
"the first error shows: {shown}"
);
// A repeat is one row with a count.
s.run("ZZErrButton:Click() ZZErrButton:Click()").unwrap();
s.dispatch_script_errors_to_handler();
assert_eq!(
s.eval::<i64>(
"for _, row in ipairs(BenillaScriptLog.rows) do \
if strfind(row.message, 'an addon boom', 1, 1) then return row.count end \
end"
)
.unwrap(),
3
);
}
/// Every global the layer defines takes the `Benilla` prefix (a slash alias its `SLASH_BENILLA_`
/// form, as `ChatEdit_ParseText` reads it), so no layer name meets a stock or an addon one; a stock
/// global the layer redefines keeps its name and is not a new definition here.
#[test]
fn every_global_the_layer_defines_takes_the_benilla_prefix() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (s, failures) = production_load("prefix", false);
assert!(failures.is_empty(), "load failures: {failures:#?}");
let log: Vec<String> = s.eval("return DEFINE_LOG").unwrap();
let start = defined_at(&log, "BenillaScrollBar_Step");
let end = defined_at(&log, "ZZORDER_SAW_LAYER");
let dispatch = |n: &str| {
n == "this"
|| n == "event"
|| n.strip_prefix("arg")
.is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
};
let unprefixed: Vec<&String> = log[start..end]
.iter()
.filter(|n| {
!(n.starts_with("Benilla")
|| n.starts_with("BENILLA_")
|| n.starts_with("SLASH_BENILLA_"))
&& !dispatch(n)
})
.collect();
assert!(
unprefixed.is_empty(),
"layer globals without the Benilla prefix: {unprefixed:?}"
);
}
+14 -16
View File
@@ -367,6 +367,7 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// No sound during the load edge, as in the reference's `UI_Init` (`0x48fbfa` → `0x49016d`).
silenced_ui_load(&mut script, |script| {
let _ = load_ingame_ui(script, identity.as_ref(), &roster, version_check);
crate::ui_chat::commands::register_dev_commands(script, crate::run_mode::dev_affordances());
// The new Minimap's zoom indices from the persisted CVars, as the reference's minimap reset
// copies each CVar into its live index; `Minimap:SetZoom` owns them from here.
script.set_minimap_zoom(zoom.0, zoom.1);
@@ -385,25 +386,22 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
},
);
});
// Deviation: failed addons are reported in chat, because otherwise an addon that does not
// load says nothing on screen. Counted from the deduplicated diagnostics log as `/errors`
// lists them, after the whole load edge (handlers included); the VM is fresh, so every `Load`
// row is this load's.
let failed = script
.diagnostics()
.iter()
.filter(|d| d.kind == benilla_ui::script::diagnostics::DiagnosticKind::Load)
.count();
if failed > 0 {
if let Some(mut chat) = world.get_resource_mut::<crate::ui_chat::ChatLog>() {
// Deviation: each failed addon file is reported in chat, one line apiece, because otherwise
// an addon that does not load says nothing on screen: a failure that never raised reaches no
// error handler, so the `/errors` log cannot hold it. Read from the engine's deduplicated
// record after the whole load edge (handlers included); the VM is fresh, so every `Load` row
// is this load's.
if let Some(mut chat) = world.get_resource_mut::<crate::ui_chat::ChatLog>() {
for row in script
.diagnostics()
.iter()
.filter(|d| d.kind == benilla_ui::script::diagnostics::DiagnosticKind::Load)
{
let head = row.message.lines().next().unwrap_or_default();
// Queued: the chat feed drains `ChatLog` once the UI is up.
chat.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::System,
format!(
"{failed} addon load {} — type /errors to see {}.",
if failed == 1 { "failure" } else { "failures" },
if failed == 1 { "it" } else { "them" }
),
format!("Addon load failure: {head}"),
));
}
}
@@ -26,19 +26,6 @@ pub(super) fn layer_enabled_by(dev_build: bool, stock_ui: Option<&str>) -> bool
!(dev_build && stock_ui == Some("1"))
}
/// `/errors`: toggles the layer's script error log; a no-op when the run boots without the layer.
pub(crate) const ERRORS_TOGGLE: &str =
"if BenillaScriptLog_Toggle then BenillaScriptLog_Toggle() end";
/// `/errors clear`: empties the layer's script error log, or does nothing without the layer.
pub(crate) const ERRORS_CLEAR: &str = "if BenillaScriptLog_Clear then BenillaScriptLog_Clear() end";
/// Hands a chord the layer's Keybindings page is capturing (`BenillaBindCapture`), a bind string
/// such as `CTRL-J`, to its handler; a no-op without the layer.
pub(crate) fn host_key_capture(chord: &str) -> String {
format!("if KeyBindings_OnHostKey then KeyBindings_OnHostKey(\"{chord}\") end")
}
/// Every entry in load order: the core's rows, none without an install, then the layer's.
#[cfg(test)]
pub(super) fn manifest_files() -> Vec<String> {
-1
View File
@@ -34,7 +34,6 @@ pub(crate) use session::VmMemo;
// Not test-only: the addon harness loads the whole shipped interface under each addon.
pub(crate) use manifest::load_default_ui;
pub(crate) use manifest::load_ingame_ui;
pub(crate) use manifest::{host_key_capture, ERRORS_CLEAR, ERRORS_TOGGLE};
/// Whether the pointer is over any UI (the egui dev overlay or a player-UI frame), combined by
/// [`arbitrate_pointer_over_ui`]; gameplay reads it, so it is not the dev plugin's.
@@ -173,7 +173,7 @@ fn the_selected_row_wears_the_gold_wash_and_hover_runs_blue() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
// Scale 1, so rects and the pointer share coordinates; at 1024x768 the fit clamp stays above 1.
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("BENILLA_ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
assert_eq!(
@@ -275,7 +275,7 @@ fn the_selected_row_wears_the_gold_wash_and_hover_runs_blue() {
fn the_ground_dim_draws_over_the_tile_and_clear_of_the_rope() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("BENILLA_ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.resolve();
@@ -514,7 +514,7 @@ fn a_track_press_seats_the_thumb_and_keeps_dragging() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.set_cvar_host("MasterVolume", "0.1");
s.run("ERA_WINDOW_SCALE = 1").unwrap(); // pointer and rects share coordinates
s.run("BENILLA_ERA_WINDOW_SCALE = 1").unwrap(); // pointer and rects share coordinates
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
@@ -1839,7 +1839,7 @@ fn defaults_resets_the_controls_page_to_registered_defaults() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Run a few frames so the page body's fit converges: `OptionsScroll_Fit` reads the previous
/// Run a few frames so the page body's fit converges: `BenillaOptionsScroll_Fit` reads the previous
/// frame's rects.
fn settle(s: &mut UiScript) {
for _ in 0..4 {
@@ -1942,11 +1942,14 @@ fn a_broad_search_scrolls_the_page_instead_of_overflowing_it() {
clip.bottom
);
// Widget coordinates here, not the extract's pixels: the window carries `ERA_WINDOW_SCALE`.
// Widget coordinates here, not the extract's pixels: the window carries `BENILLA_ERA_WINDOW_SCALE`.
let sf_bottom: f32 = s
.eval("return BenillaOptionsFrameContainerScroll:GetBottom()")
.unwrap();
let tail = |s: &UiScript| -> f32 { s.eval("return OptionsScroll_ContentBottom()").unwrap() };
let tail = |s: &UiScript| -> f32 {
s.eval("return BenillaOptionsScroll_ContentBottom()")
.unwrap()
};
assert!(
tail(&s) < sf_bottom,
"the tail starts below the fold ({} vs {sf_bottom})",
@@ -2068,7 +2071,7 @@ fn the_page_scroll_bar_wears_the_trough_with_its_arrows_in_the_sockets() {
// ── The row tooltips ─────────────────────────────────────────────────────────────
/// Hover a row's label half, 60 units in at mid-height. Callers pin `ERA_WINDOW_SCALE = 1` first,
/// Hover a row's label half, 60 units in at mid-height. Callers pin `BENILLA_ERA_WINDOW_SCALE = 1` first,
/// so the row's rect and the pointer share coordinates.
fn hover_label(s: &mut UiScript, frame: &str) {
s.resolve();
@@ -2112,7 +2115,7 @@ fn a_hovered_row_raises_its_1_12_description_on_the_era_seat() {
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
s.run("OPTION_TOOLTIP_GAMEFIELD_DESELECT = \"Checking this will prevent the deselection.\"")
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("BENILLA_ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let row = "BenillaOptionsFrameContainerBodyControlsRowStickyTarget";
@@ -2181,7 +2184,7 @@ fn a_row_with_no_1_12_string_raises_no_plate() {
let mut s = harness_on(audio_harness());
s.run("OPTION_TOOLTIP_GAMEFIELD_DESELECT = \"Sticky's own description.\"")
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("BENILLA_ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
hover_label(
&mut s,
@@ -2201,10 +2204,10 @@ fn a_row_with_no_1_12_string_raises_no_plate() {
);
// Sticky's plate up, then straight into the mute row with no OnLeave in between.
s.run("OptionsRow_Hover(BenillaOptionsFrameContainerBodyControlsRowStickyTarget, 1)")
s.run("BenillaOptionsRow_Hover(BenillaOptionsFrameContainerBodyControlsRowStickyTarget, 1)")
.unwrap();
assert!(s.eval::<bool>("return GameTooltip:IsVisible()").unwrap());
s.run("OptionsRow_Hover(BenillaOptionsFrameContainerBodyControlsRowAutoLoot, 1)")
s.run("BenillaOptionsRow_Hover(BenillaOptionsFrameContainerBodyControlsRowAutoLoot, 1)")
.unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
@@ -2232,7 +2235,7 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
let listing: String = s
.eval(
"local out = {} \
for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
for page, rows in pairs(BENILLA_OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"BenillaOptionsFrameContainerBody\" .. page .. rkey) \
table.insert(out, page .. rkey .. \"=\" .. (row.tip or \"\")) \
@@ -2320,7 +2323,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
let mut s = harness_on(audio_harness());
// Stand-in strings for every key the rows name.
s.run(
"for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
"for page, rows in pairs(BENILLA_OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"BenillaOptionsFrameContainerBody\" .. page .. rkey) \
if row.tip then setglobal(row.tip, \"described: \" .. rkey) end \
@@ -2328,7 +2331,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
end",
)
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("BENILLA_ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let mut raised = 0;
@@ -2346,7 +2349,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
.unwrap();
let rows: String = s
.eval(&format!(
"return table.concat(OPTIONS_PAGE_ROWS.{page}, \",\")"
"return table.concat(BENILLA_OPTIONS_PAGE_ROWS.{page}, \",\")"
))
.unwrap();
for rkey in rows.split(',') {
@@ -2387,7 +2390,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
}
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
/// CVar, and runs the family's apply, `CombatText_UpdateOrLoad`.
/// CVar, and runs the family's apply, `BenillaCombatText_UpdateOrLoad`.
#[test]
fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
benilla_formats::wow_data_or_skip!();
@@ -3236,7 +3239,7 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let keys: Vec<String> = s
.eval::<String>("return table.concat(OPTIONS_CATEGORY_KEYS, \",\")")
.eval::<String>("return table.concat(BENILLA_OPTIONS_CATEGORY_KEYS, \",\")")
.unwrap()
.split(',')
.map(str::to_string)
@@ -3245,8 +3248,8 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
for key in &keys {
let has_rows = s
.eval::<bool>(&format!(
"return OPTIONS_PAGE_ROWS[\"{key}\"] ~= nil and \
getn(OPTIONS_PAGE_ROWS[\"{key}\"]) > 0"
"return BENILLA_OPTIONS_PAGE_ROWS[\"{key}\"] ~= nil and \
getn(BENILLA_OPTIONS_PAGE_ROWS[\"{key}\"]) > 0"
))
.unwrap();
assert!(
@@ -3966,7 +3969,7 @@ fn the_two_option_tabs_fit_their_labels_at_the_drawn_scale() {
assert_eq!(
num(&mut s, "BenillaOptionsFrame:GetScale()"),
0.78,
"ERA_WINDOW_SCALE"
"BENILLA_ERA_WINDOW_SCALE"
);
for tab in ["BenillaOptionsFrameGameTab", "BenillaOptionsFrameAddOnsTab"] {
@@ -4156,7 +4159,7 @@ fn every_registered_reference_cvar_has_a_row_on_our_own_window() {
let ours: std::collections::HashSet<String> = s
.eval::<Vec<String>>(
"local out = {} \
for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
for page, rows in pairs(BENILLA_OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"BenillaOptionsFrameContainerBody\" .. page .. rkey) \
if row.cvar then table.insert(out, row.cvar) end \
@@ -4396,7 +4399,7 @@ fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style()
.unwrap()
};
assert!(speed_thumb(&mut s));
s.run("SetCVar(\"cameraSmoothStyle\", \"0\") OptionsPage_Refresh(\"Controls\")")
s.run("SetCVar(\"cameraSmoothStyle\", \"0\") BenillaOptionsPage_Refresh(\"Controls\")")
.unwrap();
assert!(
!speed_thumb(&mut s),
@@ -4410,7 +4413,7 @@ fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style()
:IsMouseEnabled()"
)
.unwrap());
s.run("SetCVar(\"cameraSmoothStyle\", \"1\") OptionsPage_Refresh(\"Controls\")")
s.run("SetCVar(\"cameraSmoothStyle\", \"1\") BenillaOptionsPage_Refresh(\"Controls\")")
.unwrap();
assert!(speed_thumb(&mut s));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1964,3 +1964,75 @@ fn the_layer_does_not_grow() {
"assets/ui holds the layer's manifest and its files, nothing else"
);
}
/// The layer speaks the 1.12 dialect: a handler body is the chunk itself (`0x704c70`), so it reads
/// its frame from `this`, never `self`; the loader reads no `function=`; the strata table
/// (`0x8119f8`) has no `BLIZZARD`; `SetDrawLayer` (`0x79a780`) takes a layer name alone; and a
/// button's state textures take a texture, never a colour (`0x781970`).
#[test]
fn the_layer_speaks_the_1_12_dialect() {
fn handlers(el: &benilla_ui::framexml::Element, out: &mut Vec<(String, String)>) {
if el.tag == "Scripts" {
for h in &el.children {
out.push((h.tag.clone(), h.body.clone()));
assert!(
h.attr("function").is_none(),
"<{} function=…> is no 1.12 form",
h.tag
);
}
}
for c in &el.children {
handlers(c, out);
}
}
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let files = super::addons::Addon::layer().toc.files;
assert!(files.len() >= 8, "the layer's toc lists {files:?}");
let word = |text: &str, w: &str| {
text.match_indices(w).any(|(i, _)| {
let before = text[..i].chars().next_back();
let after = text[i + w.len()..].chars().next();
!before.is_some_and(|c| c.is_alphanumeric() || c == '_')
&& !after.is_some_and(|c| c.is_alphanumeric() || c == '_')
})
};
for file in files {
let text = std::fs::read_to_string(dir.join(&file)).expect("read");
let doc = benilla_ui::framexml::parse(&text).expect("parses");
let mut found = Vec::new();
for item in &doc.items {
use benilla_ui::framexml::TopLevel;
if let TopLevel::Template(el) | TopLevel::Instance(el) = item {
handlers(el, &mut found);
}
}
for (tag, body) in found {
assert!(!word(&body, "self"), "{file}: <{tag}> reads `self`: {body}");
}
assert!(!text.contains("BLIZZARD"), "{file}: BLIZZARD strata");
for line in text.lines().filter(|l| l.contains("SetDrawLayer(")) {
assert_eq!(
line.matches(',').count(),
0,
"{file}: SetDrawLayer takes the layer alone: {line}"
);
}
for verb in [
"SetNormalTexture(",
"SetPushedTexture(",
"SetHighlightTexture(",
"SetDisabledTexture(",
"SetCheckedTexture(",
"SetDisabledCheckedTexture(",
] {
for line in text.lines().filter(|l| l.contains(verb)) {
let args = line.split(verb).nth(1).unwrap_or_default();
assert!(
!args.trim_start().starts_with(|c: char| c.is_ascii_digit()),
"{file}: {verb} takes a texture, never a colour: {line}"
);
}
}
}
}
@@ -865,8 +865,9 @@ fn the_login_one_shots_wait_for_the_in_game_ui() {
let _ = std::fs::remove_dir_all(&tmp);
}
/// An addon that fails to load without raising (a `.toc` naming a file the package lacks) is kept
/// in the log, readable from Lua by the error window, and announced in chat.
/// An addon that fails to load without raising (a `.toc` naming a file the package lacks) reaches
/// no Lua error handler, so the `/errors` log (which collects off the handler) does not hold it:
/// the engine's record keeps it, and a chat line names it.
#[test]
fn an_addon_that_fails_to_load_without_raising_is_readable_in_the_error_log() {
benilla_formats::wow_data_or_skip!();
@@ -914,57 +915,20 @@ fn an_addon_that_fails_to_load_without_raising_is_readable_in_the_error_log() {
row.message
);
// 2. Readable from Lua through the window's own reads.
let count: i64 = script
.eval("local shown = BenillaGetNumScriptErrors() return shown")
.expect("BenillaGetNumScriptErrors is installed");
assert!(count >= 1, "the window's own read sees it");
let seen: String = script
.eval(
"local text = '' \
for i = 1, BenillaGetNumScriptErrors() do \
local seq, kind, message = BenillaGetScriptErrorInfo(i) \
if kind == 'load' then text = message end \
end \
return text",
)
.expect("BenillaGetScriptErrorInfo is installed");
assert!(
seen.contains("AaMissing"),
"the window walks the log and finds it: {seen:?}"
);
assert!(
script
.eval::<bool>("return BenillaScriptLogFrame ~= nil")
.expect("eval"),
"ScriptLogFrame.xml loaded and built the window"
);
// The repaint runs over a real row, so a nil global anywhere on its path raises here.
// 2. Not in the `/errors` log: it never reached the error handler.
script
.eval::<()>("BenillaScriptLog_Update() return nil")
.expect("the window repaints over a real log without raising");
// Some row shows it, not necessarily row 1: the log also holds the warnings a world entry
// raises before any addon loads.
let row_labels: Vec<String> = (1..=13)
.filter_map(|i| {
script
.eval::<Option<String>>(&format!("return BenillaScriptLogRow{i}Label:GetText()"))
.expect("eval")
})
.collect();
.expect("the window repaints without raising");
assert!(
row_labels.iter().any(|l| l.contains("AaMissing")),
"a row shows the failure, trimmed to the row's width: {row_labels:?}"
);
let summary: String = script
.eval::<Option<String>>("return BenillaScriptLogSummary:GetText()")
.expect("eval")
.unwrap_or_default();
assert!(
summary.contains("problem"),
"the summary line counted them: {summary:?}"
!script
.eval::<bool>(
"for _, row in ipairs(BenillaScriptLog.rows) do \
if strfind(row.message, 'AaMissing', 1, 1) then return true end \
end \
return false"
)
.expect("eval"),
"a failure no handler saw is not in the handler-fed log"
);
// 3. No dialog, since nothing raised: the reference answers an absent or unparseable file
@@ -977,12 +941,14 @@ fn an_addon_that_fails_to_load_without_raising_is_readable_in_the_error_log() {
through `_ERRORMESSAGE` and through every addon handler that replaces it"
);
// 4. Deviation: a chat line tells the player to look, where the reference stays silent,
// because an unannounced log goes unread.
assert_eq!(
world.resource::<crate::ui_chat::ChatLog>().pending_len(),
1,
"world entry queued the 'N addon load failures — type /errors' line"
// 4. Deviation: a chat line names the failure, where the reference stays silent, because
// otherwise it reaches no screen.
let lines = world.resource::<crate::ui_chat::ChatLog>().pending_lines();
assert!(
lines
.iter()
.any(|l| l.starts_with("Addon load failure: ") && l.contains("AaMissing")),
"world entry queued the failure's line: {lines:?}"
);
drop(world);
+1 -1
View File
@@ -816,7 +816,7 @@ mod ppem_tests {
assert_eq!(e.ppem(12.0), 12, "an exact size is itself");
assert_eq!(e.ppem(12.48), 12, "…and a fractional one rounds, not snaps");
assert_eq!(e.ppem(12.5), 13);
// `ERA_WINDOW_SCALE` (0.78), which the Options window and the Game Menu wear.
// `BENILLA_ERA_WINDOW_SCALE` (0.78), which the Options window and the Game Menu wear.
assert_eq!(e.ppem(16.0 * 0.78), 12);
e.dpi = 2.0;
assert_eq!(e.ppem(12.0), 24);
+1 -1
View File
@@ -407,7 +407,7 @@ mod measure_fits_render {
"Main Menu",
];
/// `ERA_WINDOW_SCALE` of benilla's game menu and options window: fonts land on no whole size.
/// `BENILLA_ERA_WINDOW_SCALE` of benilla's game menu and options window: fonts land on no whole size.
const ERA: f32 = 0.78;
fn spec(h: f32) -> FontSpec<'static> {
+21 -40
View File
@@ -1,4 +1,4 @@
use mlua::{Function, ObjectLike, Table, Value};
use mlua::{Function, ObjectLike, Table};
use crate::framexml::Element;
@@ -28,7 +28,7 @@ impl Loader<'_> {
// function: `SetScript` (`0x7025c0`) tests only NULL and the first byte
// (`0x7025f8`), and nothing trims the body (`0x6f29d0`). The stock blanks
// (`BuffFrame.xml:101`) are whitespace, so their `GetScript` answers a function.
let cleared = handler.body.is_empty() && handler.attr("function").is_none();
let cleared = handler.body.is_empty();
let func = if cleared {
None
} else {
@@ -77,11 +77,9 @@ impl Loader<'_> {
onload
}
/// Compile a handler body, or resolve its `function=` global: not 1.12, whose loader never
/// reads the attribute. A 1.12 body takes no arguments and reads its frame from `this`; the
/// `self` parameter is not 1.12 either (our `assets/ui` bodies use it) and falls back to `this`
/// for a hook that calls a captured handler bare. The prologue shares the body's first line,
/// so line numbers match the reference, which loads the raw body as the chunk (`0x704c70`).
/// Compile a handler body as the chunk itself, as the reference loads it (`0x704c70`): it takes
/// no arguments and reads its frame from `this`, so a `self` in it is an ordinary global. The
/// loader reads no other attribute of the element (no `function=`).
pub(super) fn compile_handler(
&mut self,
handler: &Element,
@@ -91,41 +89,24 @@ impl Loader<'_> {
) -> Option<Function> {
// The raw body, never trimmed: a whitespace body is a real empty chunk (`apply_scripts`).
let body = handler.body.as_str();
if !body.is_empty() {
let src = format!(
"return function(self, ...) if self == nil then self = this end {body}\nend"
);
match self
.lua()
.load(&src)
.set_name(format!("{owner}:{name}"))
.set_mode(mlua::ChunkMode::Text)
.eval::<Function>()
{
Ok(f) => return Some(f),
Err(e) => {
self.report
.errors
.push(format!("{dbg}: compiling <{name}>: {e}"));
return None;
}
if body.is_empty() {
return None;
}
match self
.lua()
.load(body)
.set_name(format!("{owner}:{name}"))
.set_mode(mlua::ChunkMode::Text)
.into_function()
{
Ok(f) => Some(f),
Err(e) => {
self.report
.errors
.push(format!("{dbg}: compiling <{name}>: {e}"));
None
}
}
if let Some(global) = handler.attr("function") {
match self.lua().globals().get::<Value>(global) {
Ok(Value::Function(f)) => return Some(f),
_ => {
self.warn_once(
&format!("fn:{global}"),
format!(
"{dbg}: <{name} function=\"{global}\">: no such global function (yet)"
),
);
return None;
}
}
}
None
}
/// Fire a captured `OnLoad` under the event path's convention, `this` set and restored
+36 -13
View File
@@ -311,7 +311,7 @@ mod loader_tests {
<Frames>
<Frame name="$parentChild">
<Scripts>
<OnLoad>table.insert(loadorder, "child"); ChildLoaded = self:GetName()</OnLoad>
<OnLoad>table.insert(loadorder, "child"); ChildLoaded = this:GetName()</OnLoad>
</Scripts>
</Frame>
</Frames>
@@ -330,7 +330,7 @@ mod loader_tests {
assert!(s.eval::<bool>("return MyFrameChild ~= nil").unwrap());
assert!(s.eval::<bool>("return MyFrameTex ~= nil").unwrap());
// The child's handler uses `self`, the parent's `this`; both work.
// Both handlers read their frame from `this`.
assert_eq!(
s.eval::<String>("return ChildLoaded").unwrap(),
"MyFrameChild"
@@ -2409,16 +2409,17 @@ mod chunk_name_tests {
);
}
/// The 1.12 hook idiom: an addon captures a handler with `GetScript` and calls it with no
/// arguments, the frame passed only as `this`; `Loader::compile_handler` falls back to `this`.
/// A handler body is the chunk itself (`0x704c70`): it takes no arguments and reads its frame
/// from `this`, so `self` in it is an ordinary global, and the 1.12 hook idiom (an addon
/// captures a handler with `GetScript` and calls it bare) reaches the same frame.
#[test]
fn a_script_captured_and_called_with_no_arguments_still_sees_its_frame() {
fn a_handler_body_is_the_chunk_and_self_is_a_global() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Frame name="Hooked" hidden="true">
<Scripts>
<OnShow>SEEN = self:GetName()</OnShow>
<OnShow>SEEN = this:GetName() SELF_SEEN = self</OnShow>
</Scripts>
</Frame>
</Ui>"#,
@@ -2426,11 +2427,15 @@ mod chunk_name_tests {
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
// The engine's call passes `self` and sets `this`.
s.run("SEEN = nil Hooked:Show()").unwrap();
s.run("self = 'the global' SEEN = nil Hooked:Show()")
.unwrap();
assert_eq!(s.eval::<String>("return SEEN").unwrap(), "Hooked");
assert_eq!(
s.eval::<String>("return SELF_SEEN").unwrap(),
"the global",
"`self` is no parameter of the body"
);
// The addon's: the captured script called bare, with only `this` set.
s.run(
"SEEN = nil \
local original = Hooked:GetScript(\"OnShow\") \
@@ -2439,11 +2444,29 @@ mod chunk_name_tests {
this = nil",
)
.unwrap();
assert_eq!(
s.eval::<String>("return SEEN").unwrap(),
"Hooked",
"the reference's own no-argument contract must reach the same frame"
assert_eq!(s.eval::<String>("return SEEN").unwrap(), "Hooked");
}
/// The loader reads no `function=` attribute: a handler element with no body clears the
/// handler, whatever global the attribute names.
#[test]
fn a_function_attribute_binds_nothing() {
let s = UiScript::new().unwrap();
s.run("function NamedHandler() RAN = true end").unwrap();
let doc = parse(
r#"<Ui>
<Frame name="Named" hidden="true">
<Scripts><OnShow function="NamedHandler"/></Scripts>
</Frame>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
s.run("Named:Show()").unwrap();
assert!(s.eval::<bool>("return RAN == nil").unwrap());
assert!(s
.eval::<bool>("return Named:GetScript(\"OnShow\") == nil")
.unwrap());
}
}
+8 -8
View File
@@ -108,13 +108,13 @@ mod tests {
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true)
a:RegisterForDrag("LeftButton")
a:SetScript("OnDragStart", function(self, button) drag_starts = drag_starts + 1; drag_button = button end)
a:SetScript("OnDragStop", function(self) drag_stops = drag_stops + 1 end)
a:SetScript("OnClick", function(self) click_a = click_a + 1 end)
a:SetScript("OnDragStart", function() local self, button = this, arg1 drag_starts = drag_starts + 1; drag_button = button end)
a:SetScript("OnDragStop", function() local self = this drag_stops = drag_stops + 1 end)
a:SetScript("OnClick", function() local self = this click_a = click_a + 1 end)
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 400, 0); b:SetWidth(400); b:SetHeight(600); b:EnableMouse(true)
b:SetScript("OnReceiveDrag", function(self) receives = receives + 1 end)
b:SetScript("OnClick", function(self) click_b = click_b + 1 end)
b:SetScript("OnReceiveDrag", function() local self = this receives = receives + 1 end)
b:SetScript("OnClick", function() local self = this click_b = click_b + 1 end)
"#,
)
.unwrap();
@@ -170,7 +170,7 @@ mod tests {
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true)
a:RegisterForDrag("LeftButton")
a:SetScript("OnClick", function(self) clicks = clicks + 1 end)
a:SetScript("OnClick", function() local self = this clicks = clicks + 1 end)
"#,
)
.unwrap();
@@ -192,7 +192,7 @@ mod tests {
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true)
a:RegisterForDrag("LeftButton")
a:SetScript("OnClick", function(self) clicks = clicks + 1 end)
a:SetScript("OnClick", function() local self = this clicks = clicks + 1 end)
"#,
)
.unwrap();
@@ -271,7 +271,7 @@ mod tests {
heard, name, quality = 0, nil, nil
local f = CreateFrame("Frame", "Listener")
f:RegisterEvent("DELETE_ITEM_CONFIRM")
f:SetScript("OnEvent", function(self, event, n, q) heard = heard + 1; name = n; quality = q end)
f:SetScript("OnEvent", function() local self, event, n, q = this, event, arg1, arg2 heard = heard + 1; name = n; quality = q end)
"#,
)
.unwrap();
+8 -81
View File
@@ -1,7 +1,7 @@
//! The script error log: this session's script errors, addon load failures and warnings, kept
//! for the player to read. The reference has no such log: its `_ERRORMESSAGE` shows only the first
//! message of a burst, and a load failure that never raises reaches no screen. Dispatch and
//! `_ERRORMESSAGE` are unchanged by it.
//! The engine's record of this session's script errors, addon load failures and warnings, for the
//! host (the world-entry load-failure lines, the addon harness) and dev instruments; no Lua global
//! reads it. The layer's `/errors` window keeps its own log off the Lua error handler, as a 1.12
//! addon does. Dispatch and `_ERRORMESSAGE` are unchanged by it.
//!
//! A repeat bumps its row's count and keeps its place, rows are oldest first, and past
//! [`DIAGNOSTIC_LOG_CAP`] distinct messages the oldest is evicted; `seq` is never reused, so an
@@ -10,7 +10,7 @@
use std::collections::VecDeque;
use mlua::{IntoLua, Lua, MultiValue, Value};
use mlua::Lua;
use super::Model;
@@ -91,19 +91,16 @@ impl DiagnosticLog {
}
/// Retained rows.
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.rows.len()
}
/// Distinct failures recorded this session, evicted and cleared rows included.
/// Distinct failures recorded this session, evicted rows included.
#[cfg(test)]
pub(crate) fn total(&self) -> u64 {
self.seq
}
/// Forget the rows but not the numbering, so no `#N` ever names two failures.
pub(crate) fn clear(&mut self) {
self.rows.clear();
}
}
impl super::UiScript {
@@ -112,17 +109,6 @@ impl super::UiScript {
self.model_ref().diagnostics.rows().cloned().collect()
}
/// `(retained rows, distinct failures ever recorded)`.
pub fn diagnostic_counts(&self) -> (usize, u64) {
let m = self.model_ref();
(m.diagnostics.len(), m.diagnostics.total())
}
/// Forget the retained rows; the numbering carries on.
pub fn clear_diagnostics(&self) {
self.model_mut().diagnostics.clear();
}
/// Retain an addon load failure that never raised (a missing file, an unparseable document, a
/// dependency cycle or missing dependency, a broken `Bindings.xml`). It is not dispatched to
/// `geterrorhandler()`: the reference logs such a failure and shows nothing. The caller still
@@ -159,50 +145,6 @@ pub(crate) fn record_warning(lua: &Lua, msg: &str) {
.record(DiagnosticKind::Warning, msg);
}
/// Register the error-log reads `BenillaScriptLogFrame` polls, `Benilla`-prefixed as they are not
/// 1.12 API (`tests::reference_surface` enforces the prefix).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// BenillaGetNumScriptErrors() → retained, totalEverRecorded
lua.globals().set(
"BenillaGetNumScriptErrors",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok((model.diagnostics.len(), model.diagnostics.total()))
})?,
)?;
// BenillaGetScriptErrorInfo(index) → seq, kind, message, count; 1-based, oldest first, and
// nothing for an out-of-range index, so a row gone mid-repaint reads as gone.
lua.globals().set(
"BenillaGetScriptErrorInfo",
lua.create_function(|lua, index: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(row) = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|i| model.diagnostics.rows().nth(i))
else {
return Ok(MultiValue::new());
};
Ok(MultiValue::from_vec(vec![
Value::Integer(row.seq as i64),
lua.create_string(row.kind.tag())?.into_lua(lua)?,
lua.create_string(&row.message)?.into_lua(lua)?,
Value::Integer(i64::from(row.count)),
]))
})?,
)?;
lua.globals().set(
"BenillaClearScriptErrors",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.diagnostics
.clear();
Ok(())
})?,
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -308,19 +250,4 @@ mod tests {
);
assert_eq!(log.total() as usize, DIAGNOSTIC_LOG_CAP + 10);
}
#[test]
fn clear_forgets_rows_but_not_the_numbering() {
let mut log = DiagnosticLog::default();
log.record(DiagnosticKind::Error, "a");
log.record(DiagnosticKind::Error, "b");
log.clear();
assert_eq!(log.len(), 0);
log.record(DiagnosticKind::Error, "c");
assert_eq!(
log.rows().next().unwrap().seq,
3,
"a number never names two different failures"
);
}
}
+4 -11
View File
@@ -6,7 +6,7 @@
use std::borrow::Cow;
use mlua::{Function, Lua, MultiValue, Table, Value};
use mlua::{Function, Lua, Table, Value};
use super::{Model, ScriptValue, REG_SCRIPTS};
use crate::script::object::frame_wrapper;
@@ -230,8 +230,8 @@ fn arg_name(i: usize) -> Cow<'static, str> {
}
/// The calling convention, shared by [`fire`] and the loader's `OnLoad`: sets `this`, `event` and
/// `arg1..argN`, passes `(self[, event], extra...)`, and restores the globals after the call, even
/// on error, so nested fires are safe.
/// `arg1..argN` and calls the handler with no arguments, as `0x705199` calls `lua_pcall` with
/// zero; the globals are restored after the call, even on error, so nested fires are safe.
pub(crate) fn invoke_with_globals(
lua: &Lua,
wrapper: Table,
@@ -257,15 +257,8 @@ pub(crate) fn invoke_with_globals(
g.set(arg_name(i + 1).as_ref(), v.clone())?;
}
let mut modern: Vec<Value> = Vec::with_capacity(2 + n);
modern.push(Value::Table(wrapper));
if let Some(ev) = event_name {
modern.push(Value::String(lua.create_string(ev)?));
}
modern.extend(extra.iter().cloned());
// A protected call; the globals are restored before its outcome returns.
let outcome = func.call::<()>(MultiValue::from_vec(modern));
let outcome = func.call::<()>(());
g.set("this", saved_this)?;
g.set("event", saved_event)?;
+165 -98
View File
@@ -1,7 +1,8 @@
//! The key-binding table behind `GetBinding`, `SetBinding`, `LoadBindings` and their kin: per
//! command, a category header and its chords, spelled `[ALT-][CTRL-][SHIFT-]<TOKEN>` and matched by
//! string equality as the reference does. Host commands carry their 1.12 defaults; an addon's
//! `Bindings.xml` adds ordinary rows that also carry a Lua body.
//! command, its chords, spelled `[ALT-][CTRL-][SHIFT-]<TOKEN>` and matched by string equality as
//! the reference does, and the one flat list `GetBinding` walks, whose section headers are rows of
//! their own. Host commands carry their 1.12 defaults; an addon's `Bindings.xml` adds ordinary rows
//! that also carry a Lua body.
use std::collections::HashMap;
@@ -11,8 +12,9 @@ use crate::bindings_xml::AddonBinding;
use super::Model;
/// One host-registered command: the 1.12 name, its category header's global string
/// (`BINDING_HEADER_MOVEMENT`), whether it also runs on release (`runOnUp`, read only by
/// One host-registered command: the 1.12 name, the header global string of the section it is filed
/// under (`BINDING_HEADER_MOVEMENT`; a change from the previous command opens a `HEADER_*` row),
/// whether it also runs on release (`runOnUp`, read only by
/// `RunCommand` at `0x4b7bf1`, never a bindability rule), and the 1.12 default chords.
#[derive(Clone, Copy, Debug)]
pub struct KeybindCommand {
@@ -127,7 +129,6 @@ fn is_valid_binding_key(key: &str) -> bool {
#[derive(Clone, Debug)]
struct Entry {
name: String,
category: String,
run_on_up: bool,
defaults: [Option<String>; 2],
/// The live chords in bind order; `GetBindingKey` and the window show the first two.
@@ -138,10 +139,25 @@ struct Entry {
body: Option<String>,
}
/// One row of the list `GetNumBindings`/`GetBinding` walk, in load order.
#[derive(Clone, Debug)]
enum Row {
/// A section header, `HEADER_<header>`: the reference stores `sprintf("HEADER_%s", header)`
/// (`0x846f58`) as a row of its own with a display ordinal and no script (`0x4b72f5`-`0x4b72fa`).
Header(String),
/// A command, an index into [`KeybindState::entries`].
Command(usize),
}
/// The table, the stored sets and the queued host requests; lives in [`Model`].
#[derive(Default)]
pub(crate) struct KeybindState {
entries: Vec<Entry>,
/// The display list, headers and commands in load order.
rows: Vec<Row>,
/// The section the last host command was filed under, so the next one opens a header only on
/// a change.
host_section: Option<String>,
by_name: HashMap<String, usize>,
/// Stored sets in entry order: `[0]` account (set 1), `[1]` character (set 2, if any).
stored: [Option<Vec<Vec<String>>>; 2],
@@ -152,15 +168,39 @@ pub(crate) struct KeybindState {
/// Bumped by every change to the live table: the app's signal to re-derive dispatch.
generation: u64,
requests: Vec<KeybindRequest>,
/// Set by `BenillaBindCapture`: the host swallows raw input and hands the page the chord.
capture_armed: bool,
}
impl KeybindState {
/// The rows `GetNumBindings`/`GetBinding` enumerate: all but `hidden="true"`, since the stock
/// window skips only `HEADER` rows (`Blizzard_BindingUI.lua:87`).
fn visible(&self) -> impl Iterator<Item = &Entry> {
self.entries.iter().filter(|e| !e.hidden)
/// The rows `GetNumBindings`/`GetBinding` enumerate: every header and every command but a
/// `hidden="true"` one, which the reference numbers from a second counter, negated
/// (`0x4b73da`-`0x4b73e5`), so `GetBinding`'s ordinal walk (`0x4b7c80`) never reaches it and
/// `GetNumBindings` (`0x4b7f40`) counts only the first.
fn visible(&self) -> impl Iterator<Item = &Row> {
self.rows.iter().filter(|r| match r {
Row::Header(_) => true,
Row::Command(i) => !self.entries[*i].hidden,
})
}
/// Open a section: a `HEADER_<header>` row, unless one of that name exists, which the reference
/// refuses (`0x4b71c9`-`0x4b7258`) and loads the binding under the section already open.
fn open_header(&mut self, header: &str) {
let name = format!("HEADER_{header}");
if !self
.rows
.iter()
.any(|r| matches!(r, Row::Header(h) if h.eq_ignore_ascii_case(&name)))
{
self.rows.push(Row::Header(name));
}
}
/// Append a command and its row.
fn push_entry(&mut self, entry: Entry) {
let idx = self.entries.len();
self.by_name.insert(entry.name.to_ascii_uppercase(), idx);
self.entries.push(entry);
self.rows.push(Row::Command(idx));
}
/// `SetBinding(key[, command])`: unbind `key` everywhere, then append it to the command's
@@ -255,26 +295,29 @@ impl KeybindState {
}
/// Append one addon's parsed `Bindings.xml`, skipping a name already registered, so a host
/// command keeps it and no handed-out index moves. A `header` opens a section
/// (`BINDING_HEADER_<header>`) until the next one, as in the reference's flat list.
/// Deviation: rows before a file's first header take the addon's name as their section, not
/// the previous file's, because under a foreign header the player could not find them.
/// command keeps it and no handed-out index moves. A `header` adds its `HEADER_<header>` row
/// before the binding's own, as in the reference's flat list.
/// Deviation: a file whose first binding has no header opens a `HEADER_<addon>` row, where the
/// reference files those rows under the previous file's last header, because under a foreign
/// header the player could not find them.
fn register_addon(&mut self, addon: &str, bindings: &[AddonBinding]) {
let mut section = addon.to_string();
let mut opened = false;
for b in bindings {
// A foreign `platform` skips the whole node, header included (`0x4b70c3`-`0x4b70e5`).
if b.platform.as_deref().is_some_and(|p| p != THIS_PLATFORM) {
continue;
}
// Before the duplicate skip: a duplicate's header still opens its section.
if let Some(h) = &b.header {
section = format!("BINDING_HEADER_{h}");
}
// A name already defined skips the node before its header is read (`0x4b70f1`-`0x4b717e`).
let key = b.name.to_ascii_uppercase();
if self.by_name.contains_key(&key) {
continue;
}
let idx = self.entries.len();
match &b.header {
Some(h) => self.open_header(h),
None if !opened => self.open_header(addon),
None => {}
}
opened = true;
// The stored chord: the current set first, then the account set, as `load` falls back.
let stored = {
let cur = if self.current_set == 2 { 1 } else { 0 };
@@ -283,10 +326,8 @@ impl KeybindState {
.or_else(|| self.stored_by_name[0].get(&key))
.cloned()
};
self.by_name.insert(key, idx);
self.entries.push(Entry {
self.push_entry(Entry {
name: b.name.clone(),
category: section.clone(),
run_on_up: b.run_on_up,
// 1.12's `<Binding>` carries no default chord.
defaults: [None, None],
@@ -317,11 +358,17 @@ impl super::UiScript {
continue;
}
let defaults = [c.default1.map(str::to_owned), c.default2.map(str::to_owned)];
let idx = model.keybinds.entries.len();
model.keybinds.by_name.insert(key, idx);
model.keybinds.entries.push(Entry {
let kb = &mut model.keybinds;
if kb.host_section.as_deref() != Some(c.category) {
kb.host_section = Some(c.category.to_owned());
let header = c
.category
.strip_prefix("BINDING_HEADER_")
.unwrap_or(c.category);
kb.open_header(header);
}
kb.push_entry(Entry {
name: c.name.to_owned(),
category: c.category.to_owned(),
run_on_up: c.run_on_up,
keys: defaults.iter().flatten().cloned().collect(),
defaults,
@@ -425,11 +472,6 @@ impl super::UiScript {
pub fn character_bindings_exist(&self) -> bool {
self.model_mut().keybinds.stored[1].is_some()
}
/// While true, the host swallows raw input and calls `KeyBindings_OnHostKey("<chord>")`.
pub fn bind_capture_armed(&self) -> bool {
self.model_mut().keybinds.capture_armed
}
}
/// Register one addon's `Bindings.xml` from a bare `&Lua`: `LoadAddOn` runs inside a Lua binding
@@ -441,9 +483,10 @@ pub(crate) fn register_addon_bindings(lua: &Lua, addon: &str, bindings: &[AddonB
.register_addon(addon, bindings);
}
/// The binding globals. `GetBinding` returns command, category, key1, key2, which is not 1.12's
/// shape: 1.12 returns command, key1, key2, with each header a `HEADER_*` row
/// (`Blizzard_BindingUI.lua:84`). Only `GetNumBindings` and `GetBinding` skip hidden rows.
/// The binding globals. `GetBinding(i)` answers the row's name and then every key bound to it
/// (`0x4b7f60`: `1 + matches` values), so a `HEADER_*` row answers its name alone, which the stock
/// window reads as three names (`Blizzard_BindingUI.lua:84`, `:87`). Only `GetNumBindings` and
/// `GetBinding` skip hidden rows.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"GetNumBindings",
@@ -456,17 +499,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"GetBinding",
lua.create_function(|lua, i: usize| {
let model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(e) = i
.checked_sub(1)
.and_then(|i| model.keybinds.visible().nth(i))
else {
let kb = &model.keybinds;
let Some(row) = i.checked_sub(1).and_then(|i| kb.visible().nth(i)) else {
return Ok(MultiValue::new());
};
let mut out = vec![
Value::String(lua.create_string(&e.name)?),
Value::String(lua.create_string(&e.category)?),
];
for k in e.keys.iter().take(2) {
let (name, keys): (&str, &[String]) = match row {
Row::Header(h) => (h, &[]),
Row::Command(c) => (&kb.entries[*c].name, &kb.entries[*c].keys),
};
let mut out = vec![Value::String(lua.create_string(name)?)];
for k in keys {
out.push(Value::String(lua.create_string(k)?));
}
Ok(MultiValue::from_iter(out))
@@ -545,21 +587,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(model.keybinds.current_set)
})?,
)?;
lua.globals().set(
"BenillaCharacterBindingsExist",
lua.create_function(|lua, ()| {
let model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(model.keybinds.stored[1].is_some())
})?,
)?;
lua.globals().set(
"BenillaBindCapture",
lua.create_function(|lua, armed: Option<bool>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.keybinds.capture_armed = armed.unwrap_or(false);
Ok(())
})?,
)
Ok(())
}
#[cfg(test)]
@@ -596,17 +624,40 @@ mod tests {
s
}
/// Every row `GetBinding` answers, each as its values in order.
fn rows(s: &UiScript) -> Vec<Vec<String>> {
s.eval(
"local out = {} \
for i = 1, GetNumBindings() do out[i] = { GetBinding(i) } end \
return out",
)
.unwrap()
}
fn row(values: &[&str]) -> Vec<String> {
values.iter().map(|v| v.to_string()).collect()
}
#[test]
fn the_table_reads_like_the_reference() {
fn the_list_reads_like_the_reference() {
let s = script();
assert_eq!(s.eval::<usize>("return GetNumBindings()").unwrap(), 3);
// Command, category, key1, key2 (not 1.12's three values), the defaults seeded live.
// A header row answers its name alone; a command, its name and then every key, no
// category (`0x4b7f60`).
assert_eq!(
rows(&s),
[
row(&["HEADER_MOVEMENT"]),
row(&["MOVEFORWARD", "W", "UP"]),
row(&["JUMP", "SPACE", "NUMPAD0"]),
row(&["HEADER_CAMERA"]),
row(&["CAMERAZOOMIN", "MOUSEWHEELUP"]),
]
);
// Every key, not two.
s.run(r#"SetBinding("F", "JUMP")"#).unwrap();
assert_eq!(rows(&s)[2], row(&["JUMP", "SPACE", "NUMPAD0", "F"]));
assert!(s
.eval::<bool>(
r#"local c, cat, k1, k2 = GetBinding(1)
return c == "MOVEFORWARD" and cat == "BINDING_HEADER_MOVEMENT"
and k1 == "W" and k2 == "UP""#
)
.eval::<bool>("return GetBinding(0) == nil and GetBinding(6) == nil")
.unwrap());
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("NUMPAD0")"#)
@@ -614,7 +665,7 @@ mod tests {
"JUMP"
);
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("F")"#).unwrap(),
s.eval::<String>(r#"return GetBindingAction("G")"#).unwrap(),
"",
"an unbound key reads as the empty command, like the client"
);
@@ -748,22 +799,16 @@ mod tests {
"registration must move the generation — it is the app's re-derive-dispatch signal"
);
// Three host rows and two addon rows: the addon's `MOVEFORWARD` is skipped and
// Its header row, then two commands: the addon's `MOVEFORWARD` is skipped and
// `PROBEHIDDEN` is not listed.
assert_eq!(s.eval::<usize>("return GetNumBindings()").unwrap(), 5);
assert!(s
.eval::<bool>(
r#"local c, cat, k1 = GetBinding(4)
return c == "PROBEHOLD" and cat == "BINDING_HEADER_PROBE" and k1 == nil"#
)
.unwrap());
assert!(
s.eval::<bool>(
r#"local c, cat = GetBinding(5)
return c == "PROBEEDGE" and cat == "BINDING_HEADER_PROBE""#
)
.unwrap(),
"a row with no header of its own belongs to the section the last header opened"
assert_eq!(s.eval::<usize>("return GetNumBindings()").unwrap(), 8);
assert_eq!(
rows(&s)[5..],
[
row(&["HEADER_PROBE"]),
row(&["PROBEHOLD"]),
row(&["PROBEEDGE"])
]
);
assert!(s
.eval::<bool>(
@@ -795,22 +840,49 @@ mod tests {
.eval::<bool>(r#"return SetBinding("MOUSEWHEELDOWN", "PROBEEDGE") == 1"#)
.unwrap());
// A file with no header: its rows' section is the addon's name.
// A duplicate's header is never read (`0x4b70f1`-`0x4b717e`); a header already in the
// list is refused and the row files under the open one (`0x4b71c9`-`0x4b7258`); a hidden
// row's header is a row, since the header is written before the hidden test (`0x4b73bc`).
let parsed = crate::bindings_xml::parse(
r#"<Bindings>
<Binding name="JUMP" header="DUPLICATE">Dup();</Binding>
<Binding name="PROBEAGAIN" header="PROBE">Again();</Binding>
<Binding name="PROBEHIDDENHEAD" header="QUIET" hidden="true">Quiet();</Binding>
</Bindings>"#,
)
.expect("well-formed");
s.register_addon_bindings("ProbeMore", &parsed);
assert_eq!(
rows(&s)[8..],
[row(&["PROBEAGAIN"]), row(&["HEADER_QUIET"])]
);
// A file with no header opens a row of the addon's name (the deviation on
// `register_addon`).
let parsed = crate::bindings_xml::parse(
r#"<Bindings><Binding name="LIBKEY">Lib();</Binding></Bindings>"#,
)
.expect("well-formed");
s.register_addon_bindings("ProbeLib", &parsed);
assert!(s
.eval::<bool>(
r#"local c, cat = GetBinding(6); return c == "LIBKEY" and cat == "ProbeLib""#
)
.unwrap());
assert_eq!(
rows(&s)[10..],
[row(&["HEADER_ProbeLib"]), row(&["LIBKEY"])]
);
// Addon rows only, in registration order, hidden included.
let bodies = s.addon_binding_bodies();
let names: Vec<&str> = bodies.iter().map(|b| b.name.as_str()).collect();
assert_eq!(names, ["PROBEHOLD", "PROBEEDGE", "PROBEHIDDEN", "LIBKEY"]);
assert_eq!(
names,
[
"PROBEHOLD",
"PROBEEDGE",
"PROBEHIDDEN",
"PROBEAGAIN",
"PROBEHIDDENHEAD",
"LIBKEY"
]
);
assert!(bodies[0].run_on_up && !bodies[1].run_on_up);
assert!(bodies[0].body.contains(r#"keystate == "down""#));
}
@@ -844,7 +916,7 @@ mod tests {
}
#[test]
fn host_seeding_feeds_load_and_the_capture_arm_reads_back() {
fn host_seeding_feeds_load() {
let mut s = script();
s.seed_binding_set(1, Some(vec![("JUMP".into(), vec!["F".into()])]));
s.load_binding_set(1);
@@ -857,11 +929,6 @@ mod tests {
s.eval::<String>(r#"return GetBindingAction("W")"#).unwrap(),
"MOVEFORWARD"
);
assert!(!s.bind_capture_armed());
s.run("BenillaBindCapture(true)").unwrap();
assert!(s.bind_capture_armed());
s.run("BenillaBindCapture(false)").unwrap();
assert!(!s.bind_capture_armed());
let snap = s.keybind_snapshot();
assert_eq!(snap[1].0, "JUMP");
assert_eq!(snap[1].1, vec!["F".to_string()]);
@@ -247,26 +247,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.set_named_registry_value(REG_SCROLLINGMESSAGEFRAME_METHODS, m)?;
// BenillaChatTabPressed(): the chat edit box's OnTabPressed sets a flag for the app's whisper
// cycle (`UiScript::take_chat_tab`).
lua.globals().set(
"BenillaChatTabPressed",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.chat_tab = true;
Ok(())
})?,
)?;
// SubmitChatInput(text): the chat edit box's OnEnterPressed queues the line for the app to
// parse as a chat command; an empty line is queued too, and the app takes it as a cancel.
lua.globals().set(
"SubmitChatInput",
lua.create_function(|lua, text: Option<String>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.chat_input.push(text.unwrap_or_default());
Ok(())
})?,
)?;
Ok(())
}
@@ -699,7 +679,8 @@ mod tests {
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
f:SetWidth(430)\n\
f:SetHeight(120)\n\
f:SetScript('OnHyperlinkClick', function(self, link, text, button)\n\
f:SetScript('OnHyperlinkClick', function()\n\
local link, text, button = arg1, arg2, arg3\n\
CLICKED = link .. '#' .. text .. '#' .. button\n\
end)",
)
+2 -7
View File
@@ -579,7 +579,6 @@ impl UiScript {
worldmap::install(&lua)?;
worldstate::install(&lua)?;
net_stats::install(&lua)?;
diagnostics::install(&lua)?;
let s = UiScript {
lua,
@@ -828,7 +827,8 @@ impl UiScript {
true
}
/// Drain the chat lines `SubmitChatInput` queued since the last call, for the app to parse.
/// Drain the chat lines queued since the last call (a probe's, a host `SlashCmdList` row's), for
/// the app to parse.
pub fn take_chat_input(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().chat_input)
}
@@ -838,11 +838,6 @@ impl UiScript {
self.model_mut().chat_input.push(line);
}
/// Whether Tab was pressed in the chat edit box since the last call: the whisper cycle's cue.
pub fn take_chat_tab(&mut self) -> bool {
std::mem::take(&mut self.model_mut().chat_tab)
}
/// Replace the hyperlink spans `(frame, y-up rect, link, markup)` the app rasterized this
/// frame; a release inside one fires `OnHyperlinkClick` on its frame ([`pointer`]).
pub fn set_link_spans(&mut self, spans: Vec<(FrameHandle, Rect, String, String)>) {
+1 -4
View File
@@ -143,8 +143,6 @@ pub(crate) struct Model {
pub(crate) layout_rounds: u64,
/// Per frame, rasterized links `(y-up rect, link, full |H…|h markup)` for `OnHyperlinkClick`.
pub(crate) link_spans: HashMap<FrameHandle, Vec<(Rect, String, String)>>,
/// Tab was pressed in the chat edit box (`BenillaChatTabPressed`), for the whisper cycle.
pub(crate) chat_tab: bool,
/// Region visuals (texture, colour, text) and layout (anchors, size, justify).
pub(crate) region_data: HashMap<RegionHandle, RegionData>,
/// Each frame's backdrop plate (`<Backdrop>` or `SetBackdrop`, the client's `frame+0x1ac`).
@@ -873,7 +871,7 @@ pub(crate) struct Model {
/// Reputation calls queued, applied locally first because none of the three sends is acked.
pub(crate) reputation_sends: Vec<reputation::ReputationSend>,
/// Lines the chat box submitted (`SubmitChatInput`), for the app's slash-command parser.
/// Lines for the app's slash-command parser: a probe's, or a host `SlashCmdList` row's.
pub(crate) chat_input: Vec<String>,
/// The world map's pushed catalog and feed, and its selection.
@@ -1019,7 +1017,6 @@ impl Model {
layout_rounds: 0,
resolved: HashMap::new(),
link_spans: HashMap::new(),
chat_tab: false,
region_data: HashMap::new(),
backdrops: HashMap::new(),
simple_html: simplehtml::SimpleHtmlStates::new(),
+46 -1
View File
@@ -1,6 +1,6 @@
//! `SlashCmdList` dispatch from the host, for a line that reaches it without passing the stock
//! `ChatEdit_ParseText` (`ChatFrame.lua`): the same walk, over the table the stock `ChatFrame.lua`
//! declares, where the stock commands and benilla's own are entries.
//! declares, where the stock commands, benilla's layer's and an addon's are entries.
use mlua::{Table, Value};
@@ -25,6 +25,33 @@ impl super::UiScript {
self.find_slash_handler(cmd).is_some()
}
/// Register a host command as a `SlashCmdList` row, as an addon registers one: `SLASH_<key><n>`
/// for each alias (with its `/`) and a handler that queues `"<first alias> <msg>"` on the chat
/// input the host drains ([`Self::take_chat_input`]). A no-op before `ChatFrame.lua` has
/// declared `SlashCmdList`.
pub fn register_host_slash_command(&self, key: &str, aliases: &[&str]) -> mlua::Result<()> {
let globals = self.lua.globals();
let Ok(list) = globals.get::<Table>("SlashCmdList") else {
return Ok(());
};
let Some(first) = aliases.first() else {
return Ok(());
};
for (n, alias) in aliases.iter().enumerate() {
globals.set(format!("SLASH_{key}{}", n + 1), *alias)?;
}
let first = first.to_string();
let handler = self.lua.create_function(move |lua, msg: Option<String>| {
let line = format!("{first} {}", msg.unwrap_or_default());
lua.app_data_mut::<super::Model>()
.expect("model app_data")
.chat_input
.push(line);
Ok(())
})?;
list.set(key, handler)
}
/// The reference's walk: over `SlashCmdList`'s keys, then `SLASH_<key><n>` to the first gap,
/// case-insensitive.
fn find_slash_handler(&self, cmd: &str) -> Option<mlua::Function> {
@@ -122,6 +149,24 @@ mod tests {
);
}
/// A host row is an ordinary `SlashCmdList` row that queues its line for the host.
#[test]
fn a_host_command_queues_its_line() {
let mut s = UiScript::new().unwrap();
s.register_host_slash_command("HOSTPROBE", &["/hostprobe", "/hp"])
.unwrap();
assert!(
!s.has_slash_command("hostprobe"),
"no table yet: nothing registers"
);
s.run("SlashCmdList = {}").unwrap();
s.register_host_slash_command("HOSTPROBE", &["/hostprobe", "/hp"])
.unwrap();
assert_eq!(s.eval::<String>("return SLASH_HOSTPROBE2").unwrap(), "/hp");
assert!(s.run_slash_command("hp", "a b"));
assert_eq!(s.take_chat_input(), ["/hostprobe a b"]);
}
#[test]
fn a_non_function_entry_is_not_a_command() {
let s = UiScript::new().unwrap();
+4 -4
View File
@@ -333,7 +333,7 @@ fn disabled_button_swallows_clicks_checkbutton_toggles_before_onclick() {
clicks, seen_checked = 0, nil
local cb = CreateFrame("CheckButton", "Toggler")
cb:SetPoint("BOTTOMLEFT", 0, 0); cb:SetWidth(100); cb:SetHeight(100)
cb:SetScript("OnClick", function(self, button, down)
cb:SetScript("OnClick", function() local self, button, down = this, arg1, arg2
clicks = clicks + 1
seen_checked = self:GetChecked()
end)
@@ -376,7 +376,7 @@ fn default_registration_is_left_click_only_right_click_reaches_nothing() {
clicks = 0
local btn = CreateFrame("Button", "Vendor")
btn:SetPoint("BOTTOMLEFT", 0, 0); btn:SetWidth(100); btn:SetHeight(100)
btn:SetScript("OnClick", function(self, button, down) clicks = clicks + 1 end)
btn:SetScript("OnClick", function() local self, button, down = this, arg1, arg2 clicks = clicks + 1 end)
"#,
)
.unwrap();
@@ -399,7 +399,7 @@ fn register_for_clicks_grows_right_click_and_carries_the_button_name() {
local btn = CreateFrame("Button", "Vendor")
btn:SetPoint("BOTTOMLEFT", 0, 0); btn:SetWidth(100); btn:SetHeight(100)
btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
btn:SetScript("OnClick", function(self, button, down)
btn:SetScript("OnClick", function() local self, button, down = this, arg1, arg2
clicks = clicks + 1
click_btn = button
arg1_btn = arg1 -- the 1.12 legacy-global convention, same value
@@ -431,7 +431,7 @@ fn down_registration_fires_on_press_and_toggles_checked_once() {
local cb = CreateFrame("CheckButton", "QuickSell")
cb:SetPoint("BOTTOMLEFT", 0, 0); cb:SetWidth(100); cb:SetHeight(100)
cb:RegisterForClicks("LeftButtonDown")
cb:SetScript("OnClick", function(self, button, down)
cb:SetScript("OnClick", function() local self, button, down = this, arg1, arg2
clicks = clicks + 1
seen_down = down
end)
@@ -36,7 +36,7 @@ fn a_runtime_template_brings_size_anchors_regions_and_a_fired_onload() {
</Layer>
</Layers>
<Scripts>
<OnLoad>ProbeLoadedAs = self:GetName(); ProbeLoadWidth = self:GetWidth()</OnLoad>
<OnLoad>ProbeLoadedAs = this:GetName(); ProbeLoadWidth = this:GetWidth()</OnLoad>
</Scripts>
</Frame>
</Ui>"#,
+14 -14
View File
@@ -4,20 +4,21 @@
use super::common::script;
use crate::script::*;
/// A handler gets no arguments (`0x705199` calls `lua_pcall` with zero); its inputs are the
/// `this`, `event` and `argN` globals.
#[test]
fn fire_event_both_conventions_in_one_handler() {
fn an_event_handler_gets_no_arguments_and_reads_the_globals() {
let mut s = script();
s.run(
r#"
local f = CreateFrame("Frame", "EF")
f:RegisterEvent("UNIT_HEALTH")
f:SetScript("OnEvent", function(self, event, ...)
r_this_eq_self = (this == self) -- legacy `this` global == modern `self`
r_event_global = event -- modern `event` arg
r_event_eq = (event == _G.event) -- == legacy `event` global
r_arg1_eq = (arg1 == arg[1]) -- legacy `arg1` global == the vararg table
r_arg1 = arg1
r_arg2 = arg[2]
f:SetScript("OnEvent", function(a, b, c)
r_args = (a == nil and b == nil and c == nil)
r_this = (this == EF)
r_event = event
r_arg1 = arg1
r_arg2 = arg2
end)
"#,
)
@@ -29,13 +30,12 @@ fn fire_event_both_conventions_in_one_handler() {
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(s.eval::<bool>("return r_this_eq_self").unwrap());
assert_eq!(
s.eval::<String>("return r_event_global").unwrap(),
"UNIT_HEALTH"
assert!(
s.eval::<bool>("return r_args").unwrap(),
"a declared parameter is nil: no positional argument is passed"
);
assert!(s.eval::<bool>("return r_event_eq").unwrap());
assert!(s.eval::<bool>("return r_arg1_eq").unwrap());
assert!(s.eval::<bool>("return r_this").unwrap());
assert_eq!(s.eval::<String>("return r_event").unwrap(), "UNIT_HEALTH");
assert_eq!(s.eval::<String>("return r_arg1").unwrap(), "player");
assert_eq!(s.eval::<i64>("return r_arg2").unwrap(), 42);
}
+16 -16
View File
@@ -13,10 +13,10 @@ fn enable_mouse_gates_hit_testing() {
-- transparent to hits; the enabled frame behind it (`a`) must capture.
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(800); a:SetHeight(600); a:EnableMouse(true)
a:SetScript("OnEnter", function(self) who = self:GetName() end)
a:SetScript("OnEnter", function() local self = this who = self:GetName() end)
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(800); b:SetHeight(600); b:EnableMouse(false)
b:SetScript("OnEnter", function(self) who = self:GetName() end)
b:SetScript("OnEnter", function() local self = this who = self:GetName() end)
-- 1/nil, not a boolean: this is the exact comparison shape that inverts, so the
-- test asserts it rather than leaning on truthiness.
assert(a:IsMouseEnabled() == 1 and b:IsMouseEnabled() == nil)
@@ -49,10 +49,10 @@ fn hit_order_is_strata_then_level_then_the_earlier_linked_frame() {
who = nil
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(800); a:SetHeight(600); a:EnableMouse(true)
a:SetScript("OnEnter", function(self) who = self:GetName() end)
a:SetScript("OnEnter", function() local self = this who = self:GetName() end)
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(800); b:SetHeight(600); b:EnableMouse(true)
b:SetScript("OnEnter", function(self) who = self:GetName() end)
b:SetScript("OnEnter", function() local self = this who = self:GetName() end)
"#,
)
.unwrap();
@@ -91,8 +91,8 @@ fn mouse_move_fires_enter_then_leave_across_a_boundary_with_correct_self() {
enter_self_ok, leave_self_ok = false, false
local a = CreateFrame("Frame", "A") -- left half only
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true)
a:SetScript("OnEnter", function(self) enters = enters + 1; enter_self_ok = (self == a) end)
a:SetScript("OnLeave", function(self) leaves = leaves + 1; leave_self_ok = (self == a) end)
a:SetScript("OnEnter", function() local self = this enters = enters + 1; enter_self_ok = (self == a) end)
a:SetScript("OnLeave", function() local self = this leaves = leaves + 1; leave_self_ok = (self == a) end)
"#,
)
.unwrap();
@@ -122,10 +122,10 @@ fn onclick_fires_on_press_release_same_frame_not_when_release_lands_elsewhere()
clicks_a, clicks_b, click_btn = 0, 0, nil
local a = CreateFrame("Frame", "A") -- left half
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true)
a:SetScript("OnClick", function(self, button, down) clicks_a = clicks_a + 1; click_btn = button end)
a:SetScript("OnClick", function() local self, button, down = this, arg1, arg2 clicks_a = clicks_a + 1; click_btn = button end)
local b = CreateFrame("Frame", "B") -- right half
b:SetPoint("BOTTOMLEFT", 400, 0); b:SetWidth(400); b:SetHeight(600); b:EnableMouse(true)
b:SetScript("OnClick", function(self, button, down) clicks_b = clicks_b + 1 end)
b:SetScript("OnClick", function() local self, button, down = this, arg1, arg2 clicks_b = clicks_b + 1 end)
"#,
)
.unwrap();
@@ -200,7 +200,7 @@ fn mouse_wheel_passes_delta_to_the_captured_frame() {
-- XML-load-time only), so a runtime-created frame needs this explicitly, exactly as it
-- needs EnableMouse. Mouse-enablement alone puts nothing in the wheel index.
a:EnableMouseWheel(true)
a:SetScript("OnMouseWheel", function(self, delta) wheel = delta end)
a:SetScript("OnMouseWheel", function() local self, delta = this, arg1 wheel = delta end)
"#,
)
.unwrap();
@@ -344,8 +344,8 @@ fn a_second_fast_click_fires_on_double_click_instead_of_the_second_on_click() {
clicks, doubles, dblbtn = 0, 0, nil
local b = CreateFrame("Button", "DblB")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(200); b:SetHeight(200); b:EnableMouse(true)
b:SetScript("OnClick", function(self, button) clicks = clicks + 1 end)
b:SetScript("OnDoubleClick", function(self, button) doubles = doubles + 1 dblbtn = button end)
b:SetScript("OnClick", function() local self, button = this, arg1 clicks = clicks + 1 end)
b:SetScript("OnDoubleClick", function() local self, button = this, arg1 doubles = doubles + 1 dblbtn = button end)
"#,
)
.unwrap();
@@ -483,7 +483,7 @@ fn a_multi_registered_button_pairs_a_left_click_with_a_right_one() {
local b = CreateFrame("Button", "MixDbl")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(200); b:SetHeight(200); b:EnableMouse(true)
b:RegisterForClicks("LeftButtonUp", "RightButtonUp")
b:SetScript("OnDoubleClick", function(self, button) doubles = doubles + 1 dblbtn = button end)
b:SetScript("OnDoubleClick", function() local self, button = this, arg1 doubles = doubles + 1 dblbtn = button end)
"#,
)
.unwrap();
@@ -716,8 +716,8 @@ fn two_frames() -> crate::script::UiScript {
local function mk(name, x)
local f = CreateFrame("Frame", name)
f:SetPoint("BOTTOMLEFT", x, 200); f:SetWidth(200); f:SetHeight(200); f:EnableMouse(true)
f:SetScript("OnMouseDown", function(self) table.insert(log, self:GetName()..":down") end)
f:SetScript("OnMouseUp", function(self) table.insert(log, self:GetName()..":up") end)
f:SetScript("OnMouseDown", function() local self = this table.insert(log, self:GetName()..":down") end)
f:SetScript("OnMouseUp", function() local self = this table.insert(log, self:GetName()..":up") end)
return f
end
mk("A", 100)
@@ -799,8 +799,8 @@ fn a_disabled_button_takes_the_hover_but_fires_no_enter_or_leave() {
local function mk(name, x)
local b = CreateFrame("Button", name)
b:SetPoint("BOTTOMLEFT", x, 0); b:SetWidth(100); b:SetHeight(100); b:EnableMouse(true)
b:SetScript("OnEnter", function(self) table.insert(log, self:GetName()..":enter") end)
b:SetScript("OnLeave", function(self) table.insert(log, self:GetName()..":leave") end)
b:SetScript("OnEnter", function() local self = this table.insert(log, self:GetName()..":enter") end)
b:SetScript("OnLeave", function() local self = this table.insert(log, self:GetName()..":leave") end)
return b
end
Off = mk("Off", 0)
@@ -33,15 +33,13 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
// `getfenv(0)`); ours does, as our `getglobal`/`setglobal` are written over it.
"_G",
// ── WoW API past 1.12 ──
// Only `SubmitChatInput` is called from our UI files (`ScriptLogFrame.xml`'s slash
// commands); each of the rest awaits its 1.12 equivalent, removal or a reason.
// Each awaits its 1.12 equivalent, removal or a reason; none is called from our UI files.
"CancelUnitBuff",
"GetCursorInfo",
"GetInventoryItemID",
"GetPlayerFacing",
"GetTradePartnerName",
"IsGossipOptionCoded",
"SubmitChatInput",
"UnitAura",
"UnitIsAFK",
"UnitIsDND",
@@ -1514,3 +1512,24 @@ fn the_nameplate_verbs_ignore_their_arguments_and_return_nothing() {
"zero values — observably different from nil for a caller that counts"
);
}
/// The host seams benilla's own interface used are gone, now that it speaks 1.12 alone: the key
/// capture, the error-log reads, the chat forwarders and the uncalled binding-set probe.
#[test]
fn the_layers_host_seams_are_gone() {
let s = script();
for name in [
"BenillaBindCapture",
"BenillaCharacterBindingsExist",
"BenillaGetNumScriptErrors",
"BenillaGetScriptErrorInfo",
"BenillaClearScriptErrors",
"SubmitChatInput",
"BenillaChatTabPressed",
] {
assert!(
s.eval::<bool>(&format!("return {name} == nil")).unwrap(),
"{name} is still a global"
);
}
}
@@ -288,12 +288,12 @@ fn vertical_scroll_fires_as_given_and_update_rect_fires_range_changed() {
frame:SetScrollChild(child)
seen_v = nil
frame:SetScript("OnVerticalScroll", function(self, offset)
frame:SetScript("OnVerticalScroll", function() local self, offset = this, arg1
seen_v = offset
assert(self == frame and arg1 == offset, "handler-firing conventions carry the value")
end)
seen_lo, seen_hi = nil, nil
frame:SetScript("OnScrollRangeChanged", function(self, lo, hi)
frame:SetScript("OnScrollRangeChanged", function() local self, lo, hi = this, arg1, arg2
seen_lo, seen_hi = lo, hi
end)
"#,
@@ -336,7 +336,7 @@ fn update_scroll_child_rect_sees_a_same_tick_resize_with_no_intervening_resolve(
s.run(
r#"
seen_hi = nil
SF:SetScript("OnScrollRangeChanged", function(self, lo, hi) seen_hi = hi end)
SF:SetScript("OnScrollRangeChanged", function() local self, lo, hi = this, arg1, arg2 seen_hi = hi end)
Child:SetWidth(300); Child:SetHeight(600) -- grows well past the frame — all in this ONE tick
SF:UpdateScrollChildRect()
"#,
@@ -16,7 +16,7 @@ fn a_resize_fires_on_size_changed_and_a_move_does_not() {
Panel = CreateFrame("Frame", "SizedPanel")
Panel:SetPoint("BOTTOMLEFT", 100, 100)
Panel:SetWidth(200); Panel:SetHeight(50)
Panel:SetScript("OnSizeChanged", function(self, aw, ah)
Panel:SetScript("OnSizeChanged", function() local self, aw, ah = this, arg1, arg2
fires = fires + 1 w = aw h = ah
end)
"#,
@@ -62,7 +62,7 @@ fn an_anchor_driven_resize_fires_it_too() {
Child = CreateFrame("Frame", "SizeChild", Parent)
Child:SetPoint("BOTTOMLEFT", Parent, "BOTTOMLEFT", 0, 0)
Child:SetPoint("TOPRIGHT", Parent, "TOPRIGHT", 0, 0)
Child:SetScript("OnSizeChanged", function(self, aw, ah) fires = fires + 1 w = aw end)
Child:SetScript("OnSizeChanged", function() local self, aw, ah = this, arg1, arg2 fires = fires + 1 w = aw end)
"#,
)
.unwrap();
@@ -94,7 +94,7 @@ fn a_handler_that_resizes_its_own_frame_settles_instead_of_spinning() {
Sq:SetPoint("BOTTOMLEFT", 0, 0)
Sq:SetWidth(200); Sq:SetHeight(50)
-- The idiom: "keep me square". It writes back into the very input that fired it.
Sq:SetScript("OnSizeChanged", function(self, aw, ah)
Sq:SetScript("OnSizeChanged", function() local self, aw, ah = this, arg1, arg2
fires = fires + 1
if ah ~= aw then self:SetHeight(aw) end
end)
+1 -1
View File
@@ -215,7 +215,7 @@ fn slider_setvalue_fires_onvaluechanged_only_on_change() {
local sl = CreateFrame("Slider", "SlEvt")
sl:SetMinMaxValues(0, 10)
seen = {}
sl:SetScript("OnValueChanged", function(self, value)
sl:SetScript("OnValueChanged", function() local self, value = this, arg1
table.insert(seen, value)
assert(self == sl and arg1 == value, "handler-firing conventions carry the value")
end)
@@ -50,7 +50,7 @@ fn statusbar_setvalue_fires_onvaluechanged() {
local b = CreateFrame("StatusBar", "SbEvt")
b:SetMinMaxValues(0, 10)
seen = {}
b:SetScript("OnValueChanged", function(self, value)
b:SetScript("OnValueChanged", function() local self, value = this, arg1
table.insert(seen, value)
assert(self == b and arg1 == value, "handler-firing conventions carry the value")
end)
@@ -55,7 +55,7 @@ fn bag_item_money_law_and_fallback() {
money_fired = nil
local a = CreateFrame("Button", "Slot3"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetScript("OnTooltipAddMoney", function(self) money_fired = arg1 end)
tt:SetScript("OnTooltipAddMoney", function() local self = this money_fired = arg1 end)
-- No merchant: no money handler fires.
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetBagItem(0, 1)
@@ -649,13 +649,13 @@ fn a_raise_with_a_hidden_child_keeps_the_windows_own_siblings_level() {
Close:SetPoint("BOTTOMRIGHT", Win, "BOTTOMRIGHT", -15, 15)
Close:SetWidth(100); Close:SetHeight(21)
Close:EnableMouse(true)
Close:SetScript("OnEnter", function(self) hovered = self:GetName() end)
Close:SetScript("OnClick", function(self) clicked = self:GetName() end)
Close:SetScript("OnEnter", function() local self = this hovered = self:GetName() end)
Close:SetScript("OnClick", function() local self = this clicked = self:GetName() end)
Body = CreateFrame("Frame", "Body", Win) -- level 2, covers the whole window
Body:SetAllPoints(Win)
Body:EnableMouse(true)
Body:SetScript("OnEnter", function(self) hovered = self:GetName() end)
Body:SetScript("OnEnter", function() local self = this hovered = self:GetName() end)
Body:Hide()
Win:Hide()
@@ -47,7 +47,7 @@ fn tick_runs_onupdate_only_when_effectively_visible() {
r#"
ticks, last = 0, 0
local f = CreateFrame("Frame", "UF")
f:SetScript("OnUpdate", function(self, elapsed) ticks = ticks + 1; last = elapsed end)
f:SetScript("OnUpdate", function() local self, elapsed = this, arg1 ticks = ticks + 1; last = elapsed end)
"#,
)
.unwrap();