the chat dock exists, and alt-arrow keys are declined not swallowed

- the chat dock is a list, not a hardcoded pair: DOCKED_CHAT_FRAMES, SELECTED_DOCK_FRAME and the FCF verbs — DockFrame, UnDockFrame, DockUpdate, SelectDockFrame, SaveDock, SetTabPosition, Close and the button-side trio — are transcribed from FloatingChatFrame.lua, and every "for i = 1, 2" moved to what it meant, the dock or every window; the dock seeds at load from the window record's own docked byte handed to FCF_DockFrame, whose first line IS the reference's early return — transcribing the function that answers the way the reference answers, where an empty stub "because we have no dock" would have been the bandaid; its unconditional re-lock cannot clobber an unlock you saved, because the stored look is applied last; pfUI's RefreshChat now runs end to end
- ignoreArrows is alt-arrow mode, and the arrow is DECLINED, not swallowed: the reference's edit-box handler returns unconsumed on an arrow key, so the walk carries it past the box and it runs TURNLEFT — the whole feature is steering while the chat box holds focus, where the old reading left the arrows dead; holding Alt resumes the caret; SetAltArrowKeyMode and its getter land with the reference's own quirk that a missing argument enables while nil disables (the getter answers 1 or nil, never a boolean), SetIgnoreArrows turns out not to exist in 1.12 and is unpublished, and the binding pass gets a named four-key exemption rather than a hole, because the handler consumes every other path
- pfUI's square minimap is actually square: Minimap:SetMaskTexture makes the disc's mask state instead of a load-time constant — write-only from Lua, as shipped, no invented getter — where an empty path restores the round default and a failed load keeps the current mask and warns, because either mistake would otherwise hand a mistyped path a silently square map that looks like a feature; and MiniMapBattlefieldFrame grows the Border and Shine regions beside its icon, on the reference's own layers — icon on BACKGROUND, ring on ARTWORK above it, the only order a ring around an icon can draw in — which pfUI hides unguarded to sit the icon flush

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 19:51:11 +07:00
co-authored by Claude Fable 5
parent 1d65531ef8
commit 850f7df3b0
25 changed files with 920 additions and 163 deletions
+266 -76
View File
@@ -484,7 +484,10 @@
-- The saved variable is a STRING, not a boolean — the uvar contract every options row
-- in this client shares, and what `RegisterForSave` writes out.
CHAT_LOCKED = isLocked and "1" or "0"
for i = 1, 2 do
-- EVERY window, which is what "the blanket switch" means and what the reference's own
-- l.1428-1441 loop does — not the dock's two. It read `1, 2` while those were the only
-- windows with grips and tabs to re-arm (1714).
for i = 1, NUM_CHAT_WINDOWS do
local frame = getglobal("ChatFrame" .. i)
if frame then BenillaFCF_ApplyLock(frame) end
end
@@ -537,9 +540,8 @@
-- ref FCF_OnUpdate's own `(MouseIsOver(...) or chatFrame.resizing)`. Without it the box
-- fades out under a moving cursor and takes the grip out of the hand holding it.
function BenillaFCF_Dragging()
for i = 1, 2 do
local f = getglobal("ChatFrame" .. i)
if f and (f.resizing or f.moving) then return 1 end
for _, f in ipairs(DOCKED_CHAT_FRAMES) do
if f.resizing or f.moving then return 1 end
end
return nil
end
@@ -549,7 +551,10 @@
-- Runs at load and on `UPDATE_CHAT_WINDOWS`, which is the event the host fires after it
-- restores the player's file.
function BenillaFCF_LoadWindowLooks()
for i = 1, 2 do
-- All seven, because the reference's `FloatingChatFrame_Update` is per window and
-- every one of them now has the chrome these setters paint (1712). It read `1, 2`
-- while ChatFrame3..7 had no textures to tint.
for i = 1, NUM_CHAT_WINDOWS do
local frame = getglobal("ChatFrame" .. i)
if frame then
local _, fontSize, r, g, b, a, _, locked = GetChatWindowInfo(i)
@@ -569,62 +574,232 @@
end
end
-- FCF_SelectDockFrame: show the selected frame, hide the other; selected tab full alpha,
-- unselected half (both still scaled by the reveal fade below). Stops the selected tab's
-- whisper flash (ref FCF_SelectDockFrame → UIFrameFlashRemoveFrame). The tab getglobal
-- stays guarded: both dock tabs exist (1575), but the guard also covers ChatFrame3..7,
-- which have no tabs at all and which the error above is what actually keeps out.
function BenillaFCF_SelectDock(id)
BenillaFCF.selected = id
for i = 1, 2 do
local frame = getglobal("ChatFrame" .. i)
if i == id then frame:Show() else frame:Hide() end
end
-- `SELECTED_CHAT_FRAME` tracks the SELECTION, which is what makes it different from
-- `DEFAULT_CHAT_FRAME` (the print target, seeded once and left alone). The reference
-- writes it from the tab's own OnClick before it even reaches FCF_SelectDockFrame
-- (`FloatingChatFrame.lua:1232`) and again on a drag (`FloatingChatFrame.xml:158`);
-- ours has one selection verb that every one of those paths goes through, so it is
-- written here rather than replicated at three call sites.
SELECTED_CHAT_FRAME = getglobal("ChatFrame" .. id) or SELECTED_CHAT_FRAME
if BenillaFCF.flashTab == id then
BenillaFCF.flashTab = 0
local flash = getglobal("ChatFrame" .. id .. "TabFlash")
if flash then flash:Hide() end
-- ── The DOCK (decision 1714) ────────────────────────────────────────────────────────
-- `DOCKED_CHAT_FRAMES` is the reference's own list — the docked windows in tab order —
-- and `SELECTED_DOCK_FRAME` the one of them that is shown. Everything below is
-- FloatingChatFrame.lua l.1049-1302, and the loops that used to run `for i = 1, 2` run
-- over the list instead: "the dock is windows 1 and 2" was a fact about our seeding, and
-- writing it as a literal is what made `FCF_DockFrame` impossible to answer honestly.
--
-- **The verbs exist; the GESTURE still does not.** 0288 §3 excluded drag/undock/resize as
-- chat *settings*, on the director's word, and nothing here adds a way for a player to
-- dock or undock a window — no dock regions, no tab-drag docking, no Remove row in the
-- tab menu. What changed is that an ADDON can now call the functions, which is a
-- different thing from us shipping the UI: pfUI's `RefreshChat` calls `FCF_DockFrame` on
-- every docked window and `FCF_UnDockFrame` on every undocked one, unguarded
-- (`chat.lua:374`/`:384`), and it is the top-installed addon of the expansion with
-- pfQuest, pfQuest-turtle and ShaguDPS embedding the same module. On our seeding both of
-- its calls hit the reference's own early returns and do nothing at all — which is the
-- point: a faithful no-op is what the reference gives it, and a missing global is not.
DOCKED_CHAT_FRAMES = {}
SELECTED_DOCK_FRAME = nil
-- ref l.1261-1264, verbatim. Every tab's seat, docked or not: `x` is the running dock
-- width for a docked tab and `0` for a loose one.
function FCF_SetTabPosition(chatFrame, x)
local chatTab = getglobal(chatFrame:GetName() .. "Tab")
chatTab:SetPoint("BOTTOMLEFT", chatFrame:GetName() .. "Background", "TOPLEFT", x + 2, 0)
end
-- ref l.1276-1285, verbatim: compact the list and write each window's POSITION back to the
-- engine record, which is what `GetChatWindowInfo`'s 9th return hands the next load.
function FCF_SaveDock()
local count = 1
local tempDock = DOCKED_CHAT_FRAMES
DOCKED_CHAT_FRAMES = {}
for _, value in ipairs(tempDock) do
DOCKED_CHAT_FRAMES[count] = value
SetChatWindowDocked(value:GetID(), count)
count = count + 1
end
end
-- FCF_SelectDockFrame(frame) — the reference's own name for the operation above, taking a
-- FRAME where ours takes a dock id. Both corpus callers pass DEFAULT_CHAT_FRAME:
-- ref l.1049-1140. Three of its four jobs are here; the fourth is stated, not stubbed.
--
-- if not DEFAULT_CHAT_FRAME:IsVisible() then
-- FCF_SelectDockFrame(DEFAULT_CHAT_FRAME)
-- end
-- DEFAULT_CHAT_FRAME:AddMessage(out)
-- · the non-first docked windows take DEFAULT_CHAT_FRAME's rect (which is what being
-- docked MEANS, and what ChatFrame2's XML anchors already say statically — this is
-- the same three points, re-applied whenever the list changes);
-- · show the selected window, hide the rest;
-- · lay the tabs out left to right by running dock width.
--
-- verbatim in both CustomNameplates (`CustomNameplates.lua:69`) and Roid-Macros
-- (`Utility.lua:40`) — "un-hide the chat window before printing into it". Two addons,
-- two independent files, not a replicated library.
-- NOT here: the dock REGIONS (`ChatFrame<n>TabDockRegion`) the reference hides and
-- re-anchors in the same loop. They exist only to hit-test a tab being DRAGGED onto the
-- dock, which is the gesture 0288 §3 leaves out; there is nothing to anchor and nothing
-- to hide. Also not here: the overflow pass that shrinks every tab to the average width
-- when the strip outgrows the window (l.1101-1139) — it needs
-- `PanelTemplates_TabResize(0, tab, width)`'s explicit-width form, and two tabs at their
-- measured widths cannot outgrow a 430 px window.
--
-- Ours does the dock half and NOT the rest of ref FCF_SelectDockFrame l.1204-1217: the ref
-- also assigns `SELECTED_DOCK_FRAME` (a global no corpus addon reads — 0 hits) and calls
-- `FCF_DockUpdate()` (tab geometry, which FCF_OnUpdate below re-derives every frame).
-- **The tab ALPHAS are deliberately not written here**, though the reference writes them
-- in this loop. `FCF_OnUpdate` owns them behind a latch keyed on `(reveal, selected)`
-- (1395): writing them from here would leave the latch believing it had already applied a
-- value it had not, and the next hover would fade from the wrong place. Setting
-- `BenillaFCF.selected` below is what makes the latch fire, one frame later, with the
-- whole fade state consistent.
function FCF_DockUpdate()
local dockWidth = 0
local previousDockedFrame
for index, value in ipairs(DOCKED_CHAT_FRAMES) do
if index ~= 1 then
value:ClearAllPoints()
value:SetPoint("TOPLEFT", DEFAULT_CHAT_FRAME, "TOPLEFT", 0, 0)
value:SetPoint("BOTTOMLEFT", DEFAULT_CHAT_FRAME, "BOTTOMLEFT", 0, 0)
value:SetPoint("BOTTOMRIGHT", DEFAULT_CHAT_FRAME, "BOTTOMRIGHT", 0, 0)
end
if value == SELECTED_DOCK_FRAME then value:Show() else value:Hide() end
local chatTab = getglobal(value:GetName() .. "Tab")
if chatTab then
BenillaFCF_TabResize(chatTab)
if previousDockedFrame then
chatTab:ClearAllPoints()
FCF_SetTabPosition(value, dockWidth)
end
dockWidth = dockWidth + chatTab:GetWidth()
end
previousDockedFrame = value
end
end
-- ref l.1204-1217. Takes a FRAME, where `BenillaFCF_SelectDock` below takes a dock id.
--
-- An UNDOCKED frame RAISES rather than quietly doing nothing. ChatFrame3..7 exist as real
-- frames but benilla has no undock/dock model at all (0288 §2 — window management is chat
-- settings, deliberately out), so "select ChatFrame5" has no meaning here; answering it
-- with silence would leave the caller's next AddMessage landing in a window that is still
-- hidden, which is the bug the guard was written to prevent.
-- It no longer RAISES on an undocked frame. It used to, because "select ChatFrame5" had
-- no meaning in a client with no dock model, and answering with silence would have left
-- the caller's next `AddMessage` landing in a still-hidden window. There is a dock model
-- now, and the reference's own answer is the assignment below: `FCF_DockUpdate` then
-- hides every docked window (none of them is the selection) and leaves the undocked one
-- exactly as it was. That is not a nice answer, but it is the client's, and the two corpus
-- callers — CustomNameplates `CustomNameplates.lua:69` and Roid-Macros `Utility.lua:40`,
-- both `FCF_SelectDockFrame(DEFAULT_CHAT_FRAME)` before printing — never reach it.
function FCF_SelectDockFrame(frame)
if not frame then
error("FCF_SelectDockFrame: nil frame", 2)
end
local id = frame:GetID()
if id ~= 1 and id ~= 2 then
error("FCF_SelectDockFrame: " .. (frame:GetName() or "?")
.. " is not docked — benilla docks only ChatFrame1/ChatFrame2 (decision 0288 §2)", 2)
SELECTED_DOCK_FRAME = frame
-- `SELECTED_CHAT_FRAME` tracks the SELECTION, which is what makes it different from
-- `DEFAULT_CHAT_FRAME` (the print target, seeded once and left alone). The reference
-- writes it from the tab's own OnClick before it even reaches here
-- (`FloatingChatFrame.lua:1232`) and again on a drag (`FloatingChatFrame.xml:158`);
-- ours has one selection verb every one of those paths goes through, so it is written
-- here rather than replicated at three call sites.
SELECTED_CHAT_FRAME = frame
BenillaFCF.selected = frame:GetID()
-- Stop the selected tab's whisper flash (ref UIFrameFlashRemoveFrame + Hide).
if BenillaFCF.flashTab == frame:GetID() then
BenillaFCF.flashTab = 0
local flash = getglobal(frame:GetName() .. "TabFlash")
if flash then flash:Hide() end
end
BenillaFCF_SelectDock(id)
FCF_DockUpdate()
end
-- The id-taking entry point, which is what every call site in this file uses.
function BenillaFCF_SelectDock(id)
FCF_SelectDockFrame(getglobal("ChatFrame" .. id))
end
-- ── The scroll column's SIDE (ref l.1015-1047, verbatim) ────────────────────────────
-- The column hangs off the window's left edge until the window is close enough to the
-- screen's left that it would hang off-screen, at which point it flips to the right. Only
-- `BottomButton` is re-anchored: `DownButton` sits on top of it and `UpButton` on top of
-- that, so the one point moves all three (the same chain the template declares).
--
-- The template's authored anchor IS the "left" case at the reference's own numbers
-- (`BOTTOMLEFT` of the frame's `BOTTOMLEFT`, −32/−4), so the first `FCF_SetButtonSide`
-- re-applies the point it already has and only the `buttonSide` field actually changes.
--
-- pfUI calls `FCF_UpdateButtonSide` directly on every member of `DOCKED_CHAT_FRAMES`
-- (`chat.lua:449`) after re-parenting the windows into its own panels, which is exactly
-- what this is for: it has just moved them, so the side has to be re-derived.
function FCF_UpdateButtonSide(chatFrame)
local leftDist = chatFrame:GetLeft()
local rightDist = GetScreenWidth() - chatFrame:GetRight()
local changed = nil
if (leftDist > 0 and leftDist <= rightDist) or rightDist < 0 then
if chatFrame.buttonSide ~= "left" then
FCF_SetButtonSide(chatFrame, "left")
changed = 1
end
else
if chatFrame.buttonSide ~= "right" or leftDist < 0 then
FCF_SetButtonSide(chatFrame, "right")
changed = 1
end
end
return changed
end
function FCF_SetButtonSide(chatFrame, buttonSide)
if chatFrame.buttonSide == buttonSide then return end
local bottom = getglobal(chatFrame:GetName() .. "BottomButton")
if buttonSide == "left" then
bottom:SetPoint("BOTTOMLEFT", chatFrame, "BOTTOMLEFT", -32, -4)
elseif buttonSide == "right" then
bottom:SetPoint("BOTTOMLEFT", chatFrame, "BOTTOMRIGHT", 0, -4)
end
chatFrame.buttonSide = buttonSide
end
function FCF_GetButtonSide(chatFrame)
return chatFrame.buttonSide
end
-- ref l.1148-1175, complete. The early return is the whole of what pfUI reaches: it calls
-- this on windows whose `isDocked` is already set.
function FCF_DockFrame(frame, index)
if frame.isDocked then return end
frame.isDocked = 1
if not index then
index = table.getn(DOCKED_CHAT_FRAMES) + 1
end
table.insert(DOCKED_CHAT_FRAMES, index, frame)
FCF_SaveDock()
FCF_SelectDockFrame(frame)
-- ref l.1167-1171: the dock's base window derives its own side; every other member
-- copies it, because they share its rect and a per-window derivation would race the
-- re-anchor above.
if frame == DEFAULT_CHAT_FRAME then
FCF_UpdateButtonSide(frame)
else
FCF_SetButtonSide(frame, FCF_GetButtonSide(DEFAULT_CHAT_FRAME))
end
FCF_SetLocked(frame, 1)
end
-- ref l.1177-1202. Minus the dock-region reset (l.1195-1197), for the reason FCF_DockUpdate
-- gives. The tab going to full alpha is the reference's own line and is kept: a loose tab
-- is not part of the dock's fade, so it does not follow the reveal.
function FCF_UnDockFrame(frame)
if frame == DEFAULT_CHAT_FRAME or not frame.isDocked then return end
SetChatWindowDocked(frame:GetID(), nil)
for index, value in ipairs(DOCKED_CHAT_FRAMES) do
if value == frame then
table.remove(DOCKED_CHAT_FRAMES, index)
break
end
end
frame.isDocked = nil
local chatTab = getglobal(frame:GetName() .. "Tab")
if chatTab then chatTab:SetAlpha(1.0) end
FCF_SelectDockFrame(DOCKED_CHAT_FRAMES[1])
end
-- ref l.1287-1302, verbatim. `HideUIPanel` is the reference's own call and is right here:
-- a chat window holds no panel slot, and ours falls through to `frame:Hide()` for exactly
-- that case (UiPanels.xml, "seated nowhere: a plain toplevel, or an addon's frame we never
-- placed").
function FCF_Close(frame)
if not frame then
frame = FCF_GetCurrentChatFrame()
end
if not frame:IsShown() then
FCF_DockUpdate()
return
end
if frame == DEFAULT_CHAT_FRAME then return end
HideUIPanel(frame)
local chatTab = getglobal(frame:GetName() .. "Tab")
if chatTab then chatTab:Hide() end
FCF_UnDockFrame(frame)
end
function BenillaFCF_TabClick(id, button)
@@ -921,10 +1096,14 @@
-- 36 us/frame, the largest single handler in the client (1395 measured it). The
-- guard restores the reference's own behaviour rather than inventing one.
if s.reveal ~= s.appliedReveal or s.selected ~= s.appliedSelected then
for i = 1, 2 do
-- Over the DOCK, not over `1, 2`: the fade belongs to the windows sharing one
-- rect and one tab strip, and which windows those are is `DOCKED_CHAT_FRAMES`'s
-- answer now that `FCF_DockFrame` can change it (1714).
for _, frame in ipairs(DOCKED_CHAT_FRAMES) do
local i = frame:GetID()
local tab = getglobal("ChatFrame" .. i .. "Tab")
if tab then
tab:SetAlpha(((i == s.selected) and 1.0 or 0.5) * s.reveal)
tab:SetAlpha(((frame == SELECTED_DOCK_FRAME) and 1.0 or 0.5) * s.reveal)
end
local a = windowAlpha(i, s.reveal)
for _, t in ipairs(BenillaFCF_Textures(i)) do
@@ -943,8 +1122,8 @@
-- stops for good.
if not s.tabsSettled then
local settled = true
for i = 1, 2 do
local tab = getglobal("ChatFrame" .. i .. "Tab")
for _, frame in ipairs(DOCKED_CHAT_FRAMES) do
local tab = getglobal(frame:GetName() .. "Tab")
if tab and not BenillaFCF_TabResize(tab) then settled = false end
end
s.tabsSettled = settled
@@ -1553,24 +1732,17 @@
this:RegisterEvent("EXECUTE_CHAT_LINE")
this:RegisterEvent("UPDATE_CHAT_WINDOWS")
end
-- `isDocked` — the reference's own flag, and it is LOAD-BEARING now rather than an
-- advertisement. **The gate below is the chat-cache's own `DOCKED` byte** (1712):
-- the shipped file marks windows 1 and 2 `DOCKED 1` and windows 3..10 `DOCKED 0`,
-- so the two that reach the assignment are exactly the two that share one rect
-- (window 2's anchors are window 1's corners) and one tab strip. It was
-- unconditional while this template had two instances; it now has seven.
-- `isDocked` is NOT set here, and that is the reference's own shape: the flag is
-- `FCF_DockFrame`'s to write (FloatingChatFrame.lua:1153), and the chat-cache
-- load is what calls it — this file's trailing seeding loop, which reads
-- `GetChatWindowInfo`'s `docked` position exactly as `FloatingChatFrame_Update`
-- (l.34-70) does.
--
-- This file used to withhold the flag on the grounds that it "belongs to a dock
-- MODEL (dock/undock, FCF_DockUpdate's re-anchoring) that 0288 §2 holds out", i.e.
-- setting it would advertise verbs we do not have. What changed is that the
-- reference's move and resize gates READ it — `chatFrame.isDocked and chatFrame ~=
-- DEFAULT_CHAT_FRAME` is the clause that keeps the Combat Log from being resized
-- out of the dock it is anchored into — so withholding it now means the gate that
-- protects window 2 silently never fires. The flag is a fact about our two docked
-- windows; the verbs remain out.
if this:GetID() == 1 or this:GetID() == 2 then
this.isDocked = 1
end
-- It briefly lived here (1712), gated to ids 1 and 2, while there was no dock
-- model to own it. There is one now (1714), and leaving the assignment here would
-- break it outright: `FCF_DockFrame` early-returns on an already-docked frame, so
-- a pre-set flag would keep both windows out of `DOCKED_CHAT_FRAMES` — docked
-- according to the flag, absent from the list every dock verb walks.
</OnLoad>
<OnEvent>
if event == "EXECUTE_CHAT_LINE" then
@@ -2086,12 +2258,17 @@
strata, hidden until ENTER opens it (ui_chat::input). The three-piece
UI-ChatInputBorder art; the header ("Say:" until the P5 edit machine drives it) at
LEFT+13; typed text starts past it (static insets, P5 makes them live). ─────────── -->
<!-- The ref template ships ignoreArrows="true": plain arrows fall through to CHARACTER
MOVEMENT while typing (Ctrl bypasses), because 1.12 lets you keep arrow-steering with
the box open. benilla has no typing-while-moving arrow scheme, so the attr would leave
the keys dead; the director's call is the modern law — plain arrows move the caret,
Up/Down recall history (decision 0301). The engine still honors the attr faithfully
for any box that sets it. -->
<!-- The ref template ships ignoreArrows="true": the four arrows are NOT consumed by the box
unless **ALT** is held, so they fall through to their bindings and you keep arrow-steering
with the box open. (This comment said "Ctrl bypasses" — a wow-re note had `0x41f8f0(2)`
labelled Ctrl; it is Alt, and the §5 `ignorearrows-alt-arrow-gate.md` corrected the note.
The binding on that very bit is named `SetAltArrowKeyMode`.)
benilla deliberately does NOT set the attribute here — the director's call is the modern
law: plain arrows move the caret, Up/Down recall history (decision 0301). What changed is
only that the engine now honours the attribute FAITHFULLY for a box that does set it: the
key is declined rather than swallowed-and-ignored, which is the difference between
arrow-steering working and the keys being dead. -->
<!-- historyLines: the ref template ships 32; 64 at the director's call ("at least 50",
2026-07-26) — the recall walk is the modern law already (decision 0301). -->
<EditBox name="ChatFrameEditBox" frameStrata="DIALOG" hidden="true" historyLines="64" letters="255">
@@ -2204,6 +2381,19 @@
end
end
-- ── Seed the dock (ref `FloatingChatFrame_Update` l.34-70, the `docked` arm) ─────────
-- `GetChatWindowInfo`'s 9th return is the window's stored dock POSITION, and the
-- reference hands it straight to `FCF_DockFrame` as the insertion index. Windows 1 and 2
-- answer 1 and 2 out of the shipped chat-cache; 3..7 answer nil and stay loose. This runs
-- before the selection below because `FCF_SelectDockFrame` shows the selected member of
-- `DOCKED_CHAT_FRAMES` and hides the rest — with an empty list it would hide nothing and
-- show nothing.
for i = 1, NUM_CHAT_WINDOWS do
local _, _, _, _, _, _, _, _, docked = GetChatWindowInfo(i)
if docked then
FCF_DockFrame(getglobal("ChatFrame" .. i), docked)
end
end
BenillaFCF_SelectDock(1)
BenillaFCF_LoadWindowLooks()
]]></Script>
@@ -437,8 +437,21 @@
<Anchors>
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="13" y="-13"/></Offset></Anchor>
</Anchors>
<!-- **All THREE of the reference's regions, on the reference's layers**
(ref-Minimap.xml l.311-349). This block used to carry only the icon,
which was the same half-step the note above warns against: "an addon
that moves a frame expects its named children to exist" is right, and
then one of the three was declared. pfUI's `modules/minimap.lua:80` is
`MiniMapBattlefieldBorder:Hide()`, unguarded — it hides the ring so the
icon sits flush on its square minimap — and pfQuest, pfQuest-turtle and
ShaguDPS all embed that module.
The LAYERS are the reference's too, and they are not decoration: the
icon is BACKGROUND and the border ARTWORK above it, which is the order
a ring around an icon has to draw in. Ours had the icon on ARTWORK, so
adding the border there would have raced it. -->
<Layers>
<Layer level="ARTWORK">
<Layer level="BACKGROUND">
<Texture name="MiniMapBattlefieldIcon" file="Interface\BattlefieldFrame\UI-Battlefield-Icon">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
@@ -446,6 +459,25 @@
</Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<Texture name="MiniMapBattlefieldBorder" file="Interface\Minimap\MiniMap-TrackingBorder">
<Size><AbsDimension x="52" y="52"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
</Layer>
<Layer level="BORDER">
<!-- The queue-ready pulse. Nothing here drives it (benilla has no
battleground queue), and it is declared for the same reason its
two siblings are: `BattlegroundShine` is a global an addon can
read, and its absence is a nil rather than a missing effect. -->
<Texture name="BattlegroundShine" file="Interface\ComboFrame\ComboPoint" alphaMode="ADD">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER"><Offset><AbsDimension x="3" y="3"/></Offset></Anchor>
</Anchors>
<TexCoords left="0.5625" right="1" top="0" bottom="1"/>
</Texture>
</Layer>
</Layers>
</Button>
+65 -5
View File
@@ -471,7 +471,7 @@ fn latch_and_dispatch(
// releases every direction bit, `0x514490`): Held latches clear once on the rising edge;
// EdgeUpDown latches stay armed — their release half still fires (the reference delivers
// the up of a pressed binding regardless).
let typing = capture.0;
let typing = capture.typing;
if typing && !state.was_typing {
state.latched.retain(|&(_, b)| match b {
Bound::Spec(c) => !matches!(SPECS[c.0 as usize].kind, Kind::Held),
@@ -488,7 +488,19 @@ fn latch_and_dispatch(
let key = chord::normalize_key(ev.key_code);
match ev.state {
ButtonState::Pressed => {
if armed || typing || sup || ev.repeat {
// The alt-arrow exemption: a focused box in alt-arrow mode declines the four
// arrows, so their bindings DO fire even while typing — which is the whole
// point of the flag (turn the camera with the chat box open). Every other key
// a focused box still swallows. See `UiKeyboardCapture::arrows_fall_through`.
let arrow_exempt = capture.arrows_fall_through
&& matches!(
ev.key_code,
KeyCode::ArrowLeft
| KeyCode::ArrowRight
| KeyCode::ArrowUp
| KeyCode::ArrowDown
);
if armed || (typing && !arrow_exempt) || sup || ev.repeat {
continue;
}
if state.latched.iter().any(|&(k, _)| k == BindKey::Key(key)) {
@@ -965,6 +977,54 @@ mod tests {
/// same pair wearing one chunk, so it must follow the same rule — dropped on the focus edge,
/// it would leave whatever its down half started running forever, with no key left to press
/// to stop it.
/// **The alt-arrow exemption: you can turn while the chat box has focus.**
///
/// A focused EditBox swallows every key — that is the reference's own handler returning 1 on
/// every path (`0x77b35e`) and our `UiKeyboardCapture::typing` gate. The four arrow codes are
/// the single exception: with the box in alt-arrow mode (`ignoreArrows` in XML,
/// `SetAltArrowKeyMode` in Lua) and ALT not held, the handler returns 0 at `0x77b1c4`, the
/// strata walk carries the key down to `CGWorldFrame`, and `ExecuteBinding` runs `TURNLEFT`.
/// The reference's own chat box ships the flag, so this is the default experience.
///
/// benilla read the flag as "consumed but inert, unless Ctrl" until the §5
/// (`ignorearrows-alt-arrow-gate.md`) corrected both halves — the modifier is ALT, and the key
/// is not consumed at all. Under the old reading, holding LEFT with the chat box open did
/// nothing whatever.
#[test]
fn a_flagged_editbox_lets_the_arrow_keys_through_to_their_bindings() {
let mut app = harness();
// Typing with no exemption: the arrow is swallowed, exactly like every other key.
app.world_mut().resource_mut::<UiKeyboardCapture>().typing = true;
press_key(&mut app, KeyCode::ArrowLeft);
app.update();
assert!(
!state(&app).pressed(cmd::TURN_LEFT),
"an unflagged focused box eats the arrow"
);
release_key(&mut app, KeyCode::ArrowLeft);
app.update();
// Same key, same focus, exemption armed: the binding fires.
app.world_mut()
.resource_mut::<UiKeyboardCapture>()
.arrows_fall_through = true;
press_key(&mut app, KeyCode::ArrowLeft);
app.update();
assert!(
state(&app).pressed(cmd::TURN_LEFT),
"a flagged box declines the arrow, so TURNLEFT runs"
);
// And the exemption is FOUR KEYS, not a hole in the typing gate: W is still swallowed.
press_key(&mut app, KeyCode::KeyW);
app.update();
assert!(
!state(&app).pressed(cmd::MOVE_FORWARD),
"only the arrows are exempt — every other key a focused box still eats"
);
}
#[test]
fn a_run_on_up_addon_latch_survives_the_typing_edge_and_still_releases() {
let mut script = UiScript::new().expect("VM");
@@ -983,7 +1043,7 @@ mod tests {
assert_eq!(lua_count(&app, "PROBE_DOWN"), 1);
// A box takes focus: movement stops, the addon's latch stays.
app.world_mut().resource_mut::<UiKeyboardCapture>().0 = true;
app.world_mut().resource_mut::<UiKeyboardCapture>().typing = true;
app.update();
assert!(!state(&app).pressed(cmd::MOVE_FORWARD));
assert_eq!(
@@ -1342,7 +1402,7 @@ mod tests {
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
// A box takes focus: movement stops (the reference's focus handler releases the
// direction bits), and new presses do nothing.
app.world_mut().resource_mut::<UiKeyboardCapture>().0 = true;
app.world_mut().resource_mut::<UiKeyboardCapture>().typing = true;
app.update();
assert!(
!state(&app).pressed(cmd::MOVE_FORWARD),
@@ -1356,7 +1416,7 @@ mod tests {
);
// Focus drops; keys work again.
release_key(&mut app, KeyCode::KeyX);
app.world_mut().resource_mut::<UiKeyboardCapture>().0 = false;
app.world_mut().resource_mut::<UiKeyboardCapture>().typing = false;
app.update();
press_key(&mut app, KeyCode::KeyX);
app.update();
+42
View File
@@ -984,6 +984,45 @@ fn feed_minimap_inside(
}
}
/// Swap the disc's mask when an addon has asked for a different one — `Minimap:SetMaskTexture`.
///
/// The renderer already masks the map through its own `UiQuadMask`; this only decides which
/// texture that is, so the whole feature is a load and a handle swap. It is what makes pfUI's
/// square minimap — the single most recognisable thing it does to the default UI — actually
/// square, and it is a real 1.12 method rather than a pfUI invention.
///
/// Memoised on the path, so the load happens on the edge and never per frame; a VM restart resets
/// the memo and re-reads, because the fresh VM's widget starts at the engine default again. A path
/// that fails to load leaves the current mask standing and says so once — a missing file must not
/// silently unmask the map into a square, which is what dropping the handle would do.
fn feed_minimap_mask(
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
assets: Option<ResMut<MinimapAssets>>,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut last: Local<crate::ui_script::VmMemo<Option<String>>>,
) {
let (Some(script), Some(mut assets), Some(mut world_assets)) = (script, assets, world_assets)
else {
return;
};
let want = script.minimap_mask_texture();
let last = last.get(&script);
if *last == want {
return;
}
*last = want.clone();
let path = want
.as_deref()
.unwrap_or(benilla_ui::widget::MINIMAP_DEFAULT_MASK);
match world_assets.mask_texture(path, &mut images) {
Some(handle) => assets.mask = Some(handle),
None => {
warn!("minimap: SetMaskTexture(\"{path}\") — no such texture; keeping the current mask")
}
}
}
/// Push the player's world facing onto the Minimap's engine-owned player-arrow `Model` — the
/// client's `CMinimap::SetPlayerFacing 0x4eb8e0`, which writes it verbatim into the arrow's
/// `[+0x39c]`, the field `Model:GetFacing()` reads.
@@ -1070,6 +1109,9 @@ impl Plugin for MinimapPlugin {
// Before the script tick, so an addon's OnUpdate steers off this frame's
// heading rather than last frame's.
feed_minimap_player_facing.before(crate::ui_script::UiInput),
// Before the emit that reads `MinimapAssets::mask`, so a mask set this frame
// is the one this frame draws with.
feed_minimap_mask.before(UiQuadAppend),
// Before the script tick, and after the containment verdict it reads: the
// `MINIMAP_PING` event and `Minimap:GetPingPosition()`'s value land in the
// same tick, on a ping the renderer already drew at the end of last frame.
+1 -1
View File
@@ -572,7 +572,7 @@ fn control(
// "no keys held" — so the avatar isn't also driven while typing (a `.tele` command). Mouse still
// works. The gate is `UiKeyboardCapture`, which the focused chat EditBox drives; the free-fly
// chord below is deliberately outside it, like every dev chord ([`modkeys::dev_chord`]).
let typing = ui_capture.0;
let typing = ui_capture.typing;
// The rebindable inputs all read `binds` (decision 0997): the dispatch already enforced the
// typing gate and 0585's exact-modifier law when it latched, so this module carries neither
// anymore. Nothing here reads a bare key any more (decision 1043) — the free-fly toggle is on
+1 -1
View File
@@ -382,7 +382,7 @@ pub(super) fn chat_edit_live(
let Some(script) = script else {
return;
};
if !ui_capture.0 {
if !ui_capture.typing {
return; // box not focused — nothing to live-parse
}
let text: String = script
+64 -12
View File
@@ -105,14 +105,17 @@ fn render(looks: &[ChatWindowLook]) -> String {
let mut out = String::from(HEADER);
for (i, l) in looks.iter().enumerate() {
out.push_str(&format!(
"WINDOW {} SIZE {} COLOR {} {} {} {} LOCKED {}\n",
"WINDOW {} SIZE {} COLOR {} {} {} {} LOCKED {} DOCKED {}\n",
i + 1,
l.font_size,
l.r,
l.g,
l.b,
l.a,
i32::from(l.locked)
i32::from(l.locked),
// The reference's own spelling: the dock POSITION, and `0` for a window that is not
// docked (the shipped rows for windows 3..10 are `DOCKED 0`).
l.docked.unwrap_or(0)
));
}
out
@@ -144,7 +147,12 @@ fn parse(text: &str) -> Vec<(usize, ChatWindowLook)> {
if index == 0 {
continue;
}
let mut look = ChatWindowLook::default();
// Seeded from the STOCK row for this window, not one flat default: `DOCKED` is the one
// field whose shipped value differs per window (1 and 2 are the dock), so a file written
// by a build that knew nothing about the key must leave the dock where it shipped rather
// than silently undocking both windows — the same leniency `LOCKED` gets below, applied
// where a flat default could not express it.
let mut look = ChatWindowLook::stock(index - 1);
let byte = |s: Option<&str>| -> u8 { s.and_then(|v| v.parse::<u8>().ok()).unwrap_or(0) };
while let Some(key) = it.next() {
if key.eq_ignore_ascii_case("SIZE") {
@@ -165,6 +173,12 @@ fn parse(text: &str) -> Vec<(usize, ChatWindowLook)> {
// read, and the lenient half of it: a file from a build that wrote nothing here
// keeps the stock `LOCKED 1` default rather than silently unlocking the window.
look.locked = it.next().is_none_or(|v| v.trim() != "0");
} else if key.eq_ignore_ascii_case("DOCKED") {
// The dock POSITION, 1-based; `0` (and anything unparseable) is "not docked".
look.docked = it
.next()
.and_then(|v| v.trim().parse::<u8>().ok())
.filter(|p| *p > 0);
}
// Anything else is a key from a build that knows more than this one — skip it and the
// value it would have consumed cannot be told from the next key, so skip only the key.
@@ -298,7 +312,10 @@ pub(super) fn plugin(app: &mut App) {
mod tests {
use super::*;
fn look(r: u8, g: u8, b: u8, a: u8, font_size: i32) -> ChatWindowLook {
/// A look for WINDOW `n` (1-based). The window number is a parameter because `DOCKED` is the
/// one field whose stock value differs per window — the dock is windows 1 and 2 — so an
/// expectation that does not say which window it is cannot be right for all of them.
fn look(n: usize, r: u8, g: u8, b: u8, a: u8, font_size: i32) -> ChatWindowLook {
ChatWindowLook {
r,
g,
@@ -306,6 +323,7 @@ mod tests {
a,
font_size,
locked: true,
docked: ChatWindowLook::stock(n - 1).docked,
}
}
@@ -314,8 +332,8 @@ mod tests {
#[test]
fn the_file_round_trips() {
let looks = vec![
look(0, 0, 0, 64, 14),
look(255, 128, 0, 255, 0),
look(1, 0, 0, 0, 64, 14),
look(2, 255, 128, 0, 255, 0),
ChatWindowLook::default(),
];
let parsed = parse(&render(&looks));
@@ -346,9 +364,9 @@ mod tests {
assert_eq!(
got,
vec![
(0, look(10, 20, 30, 40, 16)),
(1, look(1, 2, 3, 4, 0)),
(2, look(0, 0, 0, 0, 12)),
(0, look(1, 10, 20, 30, 40, 16)),
(1, look(2, 1, 2, 3, 4, 0)),
(2, look(3, 0, 0, 0, 0, 12)),
]
);
}
@@ -360,21 +378,55 @@ mod tests {
fn the_lock_round_trips_and_an_absent_key_stays_locked() {
let unlocked = ChatWindowLook {
locked: false,
..look(0, 0, 0, 64, 14)
..look(1, 0, 0, 0, 64, 14)
};
assert!(render(&[unlocked]).contains("LOCKED 0"));
assert_eq!(parse(&render(&[unlocked])), vec![(0, unlocked)]);
assert_eq!(
parse("WINDOW 1 SIZE 14 COLOR 0 0 0 64\n"),
vec![(0, look(0, 0, 0, 64, 14))],
vec![(0, look(1, 0, 0, 0, 64, 14))],
"no LOCKED key = the stock LOCKED 1"
);
}
/// **`DOCKED` round-trips, and an absent key keeps the window's own stock position** — the
/// leniency `LOCKED` gets, in the one field where a single flat default could not express it.
/// A file written by a build that knew nothing about the key must leave the dock where it
/// shipped; seeding every window from one default would have undocked ChatFrame1 and
/// ChatFrame2 on the next login of every existing player (1714).
#[test]
fn dock_positions_round_trip_and_an_absent_key_keeps_the_stock() {
let undocked = ChatWindowLook {
docked: None,
..look(1, 0, 0, 0, 64, 14)
};
assert!(render(&[undocked]).contains("DOCKED 0"));
assert_eq!(parse(&render(&[undocked])), vec![(0, undocked)]);
// No DOCKED key at all: window 1 keeps position 1, window 2 keeps 2, window 3 stays out.
let got = parse(
"WINDOW 1 SIZE 0
WINDOW 2 SIZE 0
WINDOW 3 SIZE 0
",
);
assert_eq!(
got.iter().map(|(_, l)| l.docked).collect::<Vec<_>>(),
vec![Some(1), Some(2), None]
);
// And a moved window round-trips at its new position.
let moved = ChatWindowLook {
docked: Some(3),
..look(1, 0, 0, 0, 0, 0)
};
assert_eq!(parse(&render(&[moved]))[0].1.docked, Some(3));
}
/// Junk costs the line it is on and nothing more.
#[test]
fn junk_costs_only_its_own_line() {
let got = parse("WINDOW\nnot a window line\nWINDOW 0 SIZE 1\nWINDOW 2 SIZE 18\n");
assert_eq!(got, vec![(1, look(0, 0, 0, 0, 18))]);
assert_eq!(got, vec![(1, look(2, 0, 0, 0, 0, 18))]);
}
}
+25 -1
View File
@@ -1948,10 +1948,34 @@ fn the_combat_log_window_has_the_docks_rect() {
"ChatFrame2:{get}() must equal ChatFrame1's — a docked window shares the dock's rect"
);
}
// **Three points, and which three is the assertion.** The XML authors two (TOPLEFT and
// BOTTOMRIGHT onto ChatFrame1) and `FCF_DockUpdate` then clears them and applies the
// reference's own three for a non-first dock member — TOPLEFT / BOTTOMLEFT / BOTTOMRIGHT,
// FloatingChatFrame.lua l.1059-1063. So `3` here means the dock seeding ran and this window is
// in `DOCKED_CHAT_FRAMES`; `2` would mean it never did and the rect is standing on the XML
// alone. Either way one alone cannot size the frame, which is what 1575 shipped.
assert_eq!(
s.eval::<i64>("return ChatFrame2:GetNumPoints()").unwrap(),
3,
"FCF_DockUpdate's three points — a 2 means the dock never seeded this window"
);
assert_eq!(
s.eval::<i64>("return table.getn(DOCKED_CHAT_FRAMES)")
.unwrap(),
2,
"both authored anchors must survive — one alone cannot size the frame"
"the dock is ChatFrame1 + ChatFrame2, seeded from GetChatWindowInfo's stored positions"
);
assert_eq!(
s.eval::<String>("return SELECTED_DOCK_FRAME:GetName()")
.unwrap(),
"ChatFrame1"
);
assert!(
s.eval::<bool>(
"return ChatFrame1.isDocked and ChatFrame2.isDocked and not ChatFrame3.isDocked"
)
.unwrap(),
"FCF_DockFrame set the flag on the dock's two and nothing else"
);
}
@@ -415,6 +415,7 @@ fn the_restore_event_repaints_the_window_from_the_store() {
a: 255,
font_size: 18,
locked: true,
docked: Some(1),
},
)]);
s.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
+31 -5
View File
@@ -654,10 +654,17 @@ fn a_line_added_to_chat_frame3_lands_in_chat_frame3_only() {
/// `FCF_SelectDockFrame(frame)` — the corpus idiom (CustomNameplates.lua:69, Roid-Macros
/// Utility.lua:40) is "un-hide the default chat frame before printing into it". It takes a FRAME,
/// where ours takes a dock id; and it RAISES on a window benilla cannot dock rather than returning
/// quietly and leaving the caller's next AddMessage in a window that is still hidden.
/// where `BenillaFCF_SelectDock` takes a dock id.
///
/// **It used to RAISE on an undocked window and no longer does** (1714). The raise was right for a
/// client whose dock was the literal `for i = 1, 2`: "select ChatFrame5" had no meaning, and
/// answering with silence would have left the caller's next `AddMessage` in a still-hidden window.
/// With a real `DOCKED_CHAT_FRAMES` the reference's own answer applies — the assignment, then
/// `FCF_DockUpdate` hiding every docked window (none of them is the selection) and leaving the
/// undocked one exactly as it was. Not a nice answer, but the client's; and neither corpus caller
/// reaches it, since both pass `DEFAULT_CHAT_FRAME`.
#[test]
fn fcf_select_dock_frame_selects_by_frame_and_raises_for_an_undocked_one() {
fn fcf_select_dock_frame_selects_by_frame_and_leaves_an_undocked_one_alone() {
let s = chat_frame();
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
let (one, two): (bool, bool) = (
@@ -673,9 +680,28 @@ fn fcf_select_dock_frame_selects_by_frame_and_raises_for_an_undocked_one() {
"the corpus guard brings the default frame back"
);
// An undocked window: no raise, and the reference's consequence — the dock's members all
// hide, because none of them is the selection, and ChatFrame5 is untouched (still hidden,
// since nothing showed it).
let shown5_before: bool = s
.eval("return ChatFrame5:IsShown() and true or false")
.unwrap();
s.run("FCF_SelectDockFrame(ChatFrame5)").unwrap();
assert_eq!(
s.eval::<String>("return SELECTED_DOCK_FRAME:GetName()")
.unwrap(),
"ChatFrame5"
);
assert!(
s.run("FCF_SelectDockFrame(ChatFrame5)").is_err(),
"an undocked window raises — benilla has no dock model for it (0288 §2)"
!s.eval::<bool>("return ChatFrame1:IsShown()").unwrap()
&& !s.eval::<bool>("return ChatFrame2:IsShown()").unwrap(),
"FCF_DockUpdate hides every docked window when the selection is not one of them"
);
assert_eq!(
s.eval::<bool>("return ChatFrame5:IsShown() and true or false")
.unwrap(),
shown5_before,
"an undocked window is not in the loop, so nothing shows or hides it"
);
}
+37 -9
View File
@@ -108,12 +108,14 @@ pub(super) fn feed_ui_input(
);
click_consumed.0 = false;
let Some(mut script) = script else {
capture.0 = false;
capture.typing = false;
capture.arrows_fall_through = false;
payload_held.0 = false;
return;
};
let Ok((window, raw_handle)) = window.single() else {
capture.0 = false;
capture.typing = false;
capture.arrows_fall_through = false;
payload_held.0 = false;
return;
};
@@ -221,7 +223,18 @@ pub(super) fn feed_ui_input(
// ── Keyboard capture gate ── read AFTER the mouse feed (a LeftButton click may have just focused a
// box) but BEFORE feeding keys (an Escape that clears focus is still "captured" this frame, so the
// world doesn't also act on it). Gameplay/dev readers run after `UiInput` and see this value.
capture.0 = script.has_keyboard_focus();
capture.typing = script.has_keyboard_focus();
// ── The alt-arrow exemption (wow-re `ignorearrows-alt-arrow-gate.md`, §5 VERIFIED) ─────────
// A focused EditBox in alt-arrow mode (`ignoreArrows` in XML, `SetAltArrowKeyMode` in Lua)
// does NOT consume LEFT/UP/RIGHT/DOWN unless ALT is held: the reference's handler returns 0
// at `0x77b1c4` and the strata walk carries the key down to `CGWorldFrame`, which runs its
// binding. That is what lets you turn while the chat box has focus — and the reference's own
// chat box ships the flag, so this is the default experience, not an edge case.
//
// Both terms are whole-frame (one focused box; ALT off the same modifier mirror), so the
// exemption is too. Only the four arrows may use it: a focused box swallows every other key
// on every other path (`0x77b35e` returns 1).
capture.arrows_fall_through = !alt && script.editbox_alt_arrow_mode();
// ── Keyboard → VM ── the three box-event keys route to `key_input` by name (and never also
// as text, so Enter isn't a stray newline); every *editing* key goes through the per-OS
@@ -273,7 +286,7 @@ pub(super) fn feed_ui_input(
};
if let Some(name) = frame_named {
if script.frame_key_input(name) {
capture.0 = true; // consumed by a frame: its binding must not also fire
capture.typing = true; // consumed by a frame: its binding must not also fire
continue;
}
}
@@ -297,7 +310,7 @@ pub(super) fn feed_ui_input(
else if named.is_none() {
if let Some(token) = crate::bindings::chord::key_token(ev.key_code) {
if script.frame_key_input(token) {
capture.0 = true;
capture.typing = true;
}
}
}
@@ -309,16 +322,31 @@ pub(super) fn feed_ui_input(
// reads the capture gate written above, so a key a focused box consumed this frame
// never also fires its binding — the real client's ESC precedence, table-wide.
// Consumption suppresses the binding — a keyboard FRAME that took this key counts
// exactly as a focused box does (decision 1319; `capture.0` above already covers the
// exactly as a focused box does (decision 1319; `capture.typing` above already covers the
// box case, and this adds the frame one).
if script.key_input(name) {
capture.0 = true;
capture.typing = true;
}
} else if let Some(chord) = chord {
// The gate, on the KEY rather than the action: a gated arrow is not handed to the box
// at all, which is the reference's `return 0`. It must not be turned into an
// `EditAction` first — `Move { unit: Edge }` reaches the box from HOME/END as well as
// from an arrow, and only the arrow is declined.
let gated_arrow = capture.arrows_fall_through
&& matches!(
ev.key_code,
KeyCode::ArrowLeft
| KeyCode::ArrowRight
| KeyCode::ArrowUp
| KeyCode::ArrowDown
);
match chord {
keymap::Chord::Edit(action) => {
keymap::Chord::Edit(action) if !gated_arrow => {
script.editbox_action(action);
}
// Declined: the key falls through to its binding, which `bindings.rs` allows
// because `arrows_fall_through` exempts exactly these four from the typing gate.
keymap::Chord::Edit(_) => {}
// The clipboard trio needs the OS pasteboard, so it resolves here against the held
// [`HostClipboard`]: copy/cut pull the selection out of the box (RF-0082 §4
// `0x77e1d0` — selection required; a password box yields its mask run, never the
@@ -349,7 +377,7 @@ pub(super) fn feed_ui_input(
// row IS a binding (the action buttons), so a digit typed into a keyboard frame —
// the stack-split spinner — must not also fire action button 3 (decision 1319).
if script.char_input(text) {
capture.0 = true;
capture.typing = true;
}
}
}
@@ -478,7 +478,8 @@ pub(crate) fn end_ui_session(world: &mut World) {
hover.0 = None;
}
if let Some(mut keys) = world.get_resource_mut::<UiKeyboardCapture>() {
keys.0 = false;
keys.typing = false;
keys.arrows_fall_through = false;
}
if let Some(mut held) = world.get_resource_mut::<CursorPayloadHeld>() {
*held = CursorPayloadHeld::default();
+15 -1
View File
@@ -184,7 +184,21 @@ pub(crate) struct PlayerUiHover(pub(crate) Option<u32>);
/// §1). One mechanism, written once here; every reader is ordered after `UiInput` so it sees this
/// frame's value, not last frame's.
#[derive(Resource, Default)]
pub(crate) struct UiKeyboardCapture(pub(crate) bool);
pub(crate) struct UiKeyboardCapture {
/// True while a focused EditBox is eating every key.
pub(crate) typing: bool,
/// **The four arrow keys are exempt this frame** — the focused box is in alt-arrow mode
/// (`ignoreArrows` / `SetAltArrowKeyMode`) and ALT is not held, so the reference's own key
/// handler declines LEFT/UP/RIGHT/DOWN at `0x77b1c4` and the strata walk carries them down to
/// `CGWorldFrame`, which runs their bindings. That is what lets you turn while the chat box
/// has focus (wow-re `ignorearrows-alt-arrow-gate.md`, §5 VERIFIED).
///
/// It is a whole-frame flag rather than a per-key one because both of its terms are:
/// there is at most one focused box, and ALT is read off the same modifier mirror. Only the
/// four arrows may use it — every other key a focused box still swallows, since the
/// reference's handler returns 1 on every other path (`0x77b35e`).
pub(crate) arrows_fall_through: bool,
}
/// Set each frame by [`feed_ui_input`]: true for a LEFT press this frame that hit no frame but was
/// consumed by the UI — the world-drop of a held cursor payload over EMPTY world (0216 §3,
+3 -1
View File
@@ -355,7 +355,9 @@ impl Loader<'_> {
("numeric", "SetNumeric"),
("password", "SetPassword"),
("multiLine", "SetMultiLine"),
("ignoreArrows", "SetIgnoreArrows"),
// The XML spelling of the alt-arrow flag; the Lua spelling is `SetAltArrowKeyMode`,
// and there is one flag behind both (wow-re `ignorearrows-alt-arrow-gate.md`).
("ignoreArrows", "SetAltArrowKeyMode"),
] {
if let Some(on) = el.attr_bool_opt(attr) {
self.call(wrapper, method, on, dbg);
+63 -1
View File
@@ -177,6 +177,15 @@ pub struct ChatWindowLook {
/// persisted state nothing could change, which is the honest-tree rule (1134 §4) at the
/// persistence layer, and the reason 1589 §6 left it out.
pub locked: bool,
/// The cache's `DOCKED` — the window's **position in the dock**, 1-based, or `None` for a
/// window that is not docked. Not a flag: window 1 answers `1` and window 2 `2`, which is the
/// index `FCF_DockFrame(frame, docked)` takes (this module's §3 note), and the order the tabs
/// sit in.
///
/// It joined this struct with the dock verbs (1714), by the same rule `locked` did: until
/// `FCF_DockFrame`/`FCF_UnDockFrame` existed there was nothing that could move it, so it was a
/// per-window constant read straight out of [`WINDOW_STATE`]. There is now, so it is state.
pub docked: Option<u8>,
}
impl Default for ChatWindowLook {
@@ -194,7 +203,25 @@ impl ChatWindowLook {
a: 0,
font_size: 0,
locked: true,
// The stock row's `DOCKED` differs per window, so the array literal that uses this const
// seeds each slot from [`WINDOW_STATE`] afterwards. `None` here is only the value a
// slot holds before that seeding, and the value a *hand-built* look starts at.
docked: None,
};
/// The stock row for window `index` (0-based) — [`Self::DEFAULT`] with that window's own
/// `DOCKED` position from [`WINDOW_STATE`]. The seed for the model's array and for the
/// settings parser's per-line default, so a file that never mentions `DOCKED` keeps the
/// shipped dock rather than silently undocking both windows.
pub fn stock(index: usize) -> Self {
Self {
docked: WINDOW_STATE
.get(index)
.and_then(|(_, d)| *d)
.and_then(|d| u8::try_from(d).ok()),
..Self::DEFAULT
}
}
}
/// `x` in the reference's `0..1` float domain → the byte its `__ftol(x · 255.0)` stores, clamped
@@ -282,7 +309,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.app_data_ref::<Model>()
.expect("model app_data")
.chat_window_looks[i];
let (shown, docked) = WINDOW_STATE[i];
let (shown, _) = WINDOW_STATE[i];
// `docked` is the LIVE position now, not the table's — `FCF_DockFrame`/
// `FCF_UnDockFrame` move it through `SetChatWindowDocked` below.
let docked = look.docked.map(i64::from);
let num = |v: Option<i64>| match v {
Some(n) => Value::Integer(n),
None => Value::Nil,
@@ -386,6 +416,37 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
// SetChatWindowDocked(id, position) — the dock half of the record, written by `FCF_SaveDock`
// (FloatingChatFrame.lua l.1276-1285) once per docked window on every dock change, and by
// `FCF_UnDockFrame` (l.1182) with `nil` to clear it.
//
// **The argument is a POSITION, not a flag** — this module's §3 trap, and the reason this
// takes a number rather than the `bool` `SetChatWindowLocked` takes: `FCF_SaveDock` passes the
// running `count`, so window 1 stores `1` and window 2 stores `2`, and that is what
// `GetChatWindowInfo`'s 9th return hands back to `FCF_DockFrame(frame, docked)` as its index.
// A `nil` clears it (undocked); anything `<= 0` is treated as a clear too, since a dock
// position is 1-based and the reference has no zeroth slot.
//
// **Not persisted yet, and that is a stated gap.** The `chat-cache` writer
// (`benilla_app`'s `ui_chat::settings`) renders `DOCKED` alongside `LOCKED`, but an absent key
// must keep the *per-window* stock rather than one flat default — see
// [`ChatWindowLook::stock`], which is what the parser seeds from.
lua.globals().set(
"SetChatWindowDocked",
lua.create_function(|lua, (id, position): (i64, Option<i64>)| {
let i = window_index(id)?;
let position = position
.filter(|p| *p > 0)
.and_then(|p| u8::try_from(p).ok());
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let look = &mut model.chat_window_looks[i];
if position != look.docked {
look.docked = position;
model.chat_window_changes.insert(i);
}
Ok(())
})?,
)?;
// SetChatWindowSize(id, fontSize) — 0x4a1470.
//
// FrameXML's caller is `FCF_SetChatWindowFontSize` (l.752-763), which does the visible half
@@ -679,6 +740,7 @@ mod tests {
a: 4,
font_size: 14,
locked: true,
docked: Some(1),
},
)]);
assert!(
+10 -9
View File
@@ -137,13 +137,11 @@ pub(super) fn action(lua: &Lua, a: EditAction) -> bool {
};
match a {
EditAction::Move { unit, back, extend } => match unit {
// ignoreArrows: consumed but inert (guard `0x77b18e`) — arrows are the only chord
// source of Char moves, and the ref's Ctrl bypass maps to Word, which passes.
EditUnit::Char => {
if !with_eb(lua, h, |eb| eb.ignore_arrows).unwrap_or(false) {
move_horizontal(lua, h, !back, extend);
}
}
// No alt-arrow test here: the gate is on the KEY and lives in the host, which declines
// the four arrow codes before the chord is ever built (`editbox_alt_arrow_mode`).
// Anything reaching this arm was Alt-held or was not an arrow, and the reference moves
// the caret for both.
EditUnit::Char => move_horizontal(lua, h, !back, extend),
// Ctrl/Option picks the word-granular cursor helper (RF-0082 §4: "char- vs
// word-granular by the Ctrl check").
EditUnit::Word => interact::move_word(lua, h, !back, extend),
@@ -159,8 +157,11 @@ pub(super) fn action(lua: &Lua, a: EditAction) -> bool {
// restored past the newest. Single-line only (benilla's multiLine box has no vertical
// caret nav — survey gap — a multiLine box consumes the step inert). The recall chords
// are the host keymap's plain Up/Down (rf82's history controller is untraced; decision
// 0301) — `ignoreArrows` does not gate them: the ref chat box ships BOTH ignoreArrows and
// historyLines, so arrows-as-history is exactly what that combination leaves.
// 0301). The alt-arrow gate DOES cover them, upstream: UP and DOWN are two of the four
// codes it declines, so on a flagged box — which the reference's own chat box is —
// history recall is **Alt**+Up/Down and a plain Up/Down turns the camera. This file used
// to say the opposite ("`ignoreArrows` does not gate them"), which followed from reading
// the flag as consume-but-inert; the §5 corrected both halves.
EditAction::HistoryPrev | EditAction::HistoryNext => {
if !with_eb(lua, h, |eb| eb.multi_line).unwrap_or(false) {
history_step_key(lua, h, a == EditAction::HistoryPrev);
@@ -187,9 +187,47 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// The config flags. SetAutoFocus/SetNumeric/SetPassword/SetMultiLine mirror the live API;
// SetIgnoreArrows has no client method (the flag is XML-only) — a benilla convenience so the
// loader can drive every flag through one method surface.
// The config flags. All four mirror the live API. The fifth flag — bit4, the XML's
// `ignoreArrows` — is NOT here: its Lua surface is `SetAltArrowKeyMode`/`GetAltArrowKeyMode`
// below, which take a different argument coercion and answer a number rather than a boolean,
// so it cannot share this loop. benilla used to register a `SetIgnoreArrows` "convenience"
// for the loader to drive; 5875 has no such method (the 48-entry table
// `[0x87bb68, 0x87bce8)` carries no entry whose name contains "Ignore"), so publishing it was
// exactly the error decision 1189 names — and it stood in for the two real names, which were
// missing. The loader drives the XML attribute through `SetAltArrowKeyMode` now.
// ── SetAltArrowKeyMode / GetAltArrowKeyMode (`0x7996e0` / `0x799790`) ────────────────────
//
// **The setter's argument is `GetBoolOrDefault(L, 2, default = 1)`** (`0x6f1c10`), not Lua
// truthiness and not a plain numeric coercion — `0x7996e0` pushes the default `1` before the
// call, so **an absent argument ENABLES** the mode. `nil` disables; a number goes through
// `__ftol` so `0` and `0.5` are false and `-1` is true; `""` matches nothing in the
// off/disabled/on/enabled chain and falls to the default, so it ENABLES; `"0"` disables.
// [`crate::script::binding_abi::bool_or_default`] already models every arm.
//
// **The getter answers the NUMBER 1 or nil**, never a boolean (`0x799815`: the set arm pushes
// the double 1.0 via `0x6f3810`, the clear arm pushes nil via `0x6f37f0`) — the same idiom as
// `IsShiftKeyDown`. An addon writing `if box:GetAltArrowKeyMode() then` reads either the same;
// one writing `== 1` only reads the number.
m.set(
"SetAltArrowKeyMode",
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
// `MultiValue`, not `Value`, because the reference DISTINGUISHES absent from nil here
// and mlua's `Value` cannot: `0x7996e0` pushes the default `1` before the call, so
// `SetAltArrowKeyMode()` enables while `SetAltArrowKeyMode(nil)` disables.
let args: Vec<Value> = args.into_iter().collect();
let on = crate::script::binding_abi::bool_or_default(args.first(), true);
with_editbox(lua, &this, |eb| eb.alt_arrow_key_mode = on)?;
Ok(())
})?,
)?;
m.set(
"GetAltArrowKeyMode",
lua.create_function(|lua, this: Table| {
let on = with_editbox(lua, &this, |eb| eb.alt_arrow_key_mode)?;
Ok(if on { Value::Integer(1) } else { Value::Nil })
})?,
)?;
for (name, set) in flag_setters() {
let refresh_justify = name == "SetMultiLine";
m.set(
@@ -307,12 +345,11 @@ fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
type FlagSetter = (&'static str, fn(&mut EditBoxState, bool));
/// The config-flag setters, in one place so the loader and the Lua surface share the list.
fn flag_setters() -> [FlagSetter; 5] {
fn flag_setters() -> [FlagSetter; 4] {
[
("SetAutoFocus", |eb, on| eb.auto_focus = on),
("SetNumeric", |eb, on| eb.numeric = on),
("SetPassword", |eb, on| eb.password = on),
("SetMultiLine", |eb, on| eb.multi_line = on),
("SetIgnoreArrows", |eb, on| eb.ignore_arrows = on),
]
}
+83 -17
View File
@@ -468,20 +468,83 @@ fn multiline_enter_inserts_a_newline_without_onspacepressed() {
assert_eq!(s.eval::<i64>("return spaces").unwrap(), 0);
}
/// **Alt-arrow mode: the two real verbs, and the flag they share with the XML attribute.**
///
/// The §5 (`ignorearrows-alt-arrow-gate.md`) settled four things this pins:
///
/// · 5875 has **no `SetIgnoreArrows`** — the 48-entry EditBox method table
/// `[0x87bb68, 0x87bce8)` carries `SetAltArrowKeyMode`/`GetAltArrowKeyMode` at 46/47 and no
/// entry whose name contains "Ignore". benilla published the invented name for two rounds and
/// was missing both real ones.
/// · The XML attribute `ignoreArrows` and the Lua verbs drive **one** flag (`[E+0x318] & 0x10`).
/// · The setter's argument is `GetBoolOrDefault(L, 2, default = 1)` (`0x6f1c10`), not Lua
/// truthiness — an **absent** argument ENABLES, `""` ENABLES, `0` and `"0"` disable.
/// · The getter answers the **number 1 or nil**, never a boolean.
///
/// And the negative that matters: the engine core no longer swallows anything. The gate is on the
/// KEY and lives in the host (`UiKeyboardCapture::arrows_fall_through`), so an `EditAction` that
/// arrives here moves the caret whatever the flag says — it only ever arrives when ALT was held or
/// the key was not an arrow.
#[test]
fn ignore_arrows_swallows_char_moves_but_not_word_or_edge() {
fn alt_arrow_key_mode_is_the_flag_and_the_engine_core_no_longer_swallows_moves() {
let mut s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetIgnoreArrows(true)
E:SetText("abc")
E:SetFocus()
"#,
)
.unwrap();
// ignoreArrows: a Char move (a plain arrow) is consumed but inert; the cursor stays put — a
// subsequent BACKSPACE removes the char before end-of-text ('c'), proving it didn't move.
// The invented name is gone.
assert!(
s.eval::<bool>("return E.SetIgnoreArrows == nil").unwrap(),
"5875 has no SetIgnoreArrows — publishing it was decision 1189's error"
);
// The getter: a number or nil, never a boolean.
assert_eq!(
s.eval::<Option<i64>>("return E:GetAltArrowKeyMode()")
.unwrap(),
None
);
s.run("E:SetAltArrowKeyMode(1)").unwrap();
assert_eq!(
s.eval::<Option<i64>>("return E:GetAltArrowKeyMode()")
.unwrap(),
Some(1)
);
assert!(
s.eval::<bool>("return type(E:GetAltArrowKeyMode()) == 'number'")
.unwrap(),
"the set arm pushes the double 1.0 (0x6f3810), not a boolean"
);
// `GetBoolOrDefault(default = 1)`, arm by arm — three of these are backwards under Lua
// truthiness, which is why the coercion has its own helper.
for (arg, want) in [
("", Some(1)), // ABSENT -> the default 1 -> enabled
("nil", None), // nil -> 0
("0", None), // __ftol(0) -> false
("-1", Some(1)), // __ftol(-1) != 0 -> true
(r#""0""#, None), // the string "0" -> false
(r#""""#, Some(1)), // "" matches no arm -> the default -> enabled
] {
s.run("E:SetAltArrowKeyMode(nil)").unwrap();
s.run(&format!("E:SetAltArrowKeyMode({arg})")).unwrap();
assert_eq!(
s.eval::<Option<i64>>("return E:GetAltArrowKeyMode()")
.unwrap(),
want,
"SetAltArrowKeyMode({arg})"
);
}
// The engine core does NOT swallow a Char move any more — the gate is on the key, upstream.
// `SetText` leaves the caret at the end, so one back-Char move puts it between 'b' and 'c'.
s.run(r#"E:SetAltArrowKeyMode(1) E:SetText("abc")"#)
.unwrap();
assert!(s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
@@ -491,21 +554,24 @@ fn ignore_arrows_swallows_char_moves_but_not_word_or_edge() {
unit: EditUnit::Char,
back: true
}));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "ab");
// The ref's Ctrl bypass (guard 0x77b18e) maps to Word — which passes the gate; Edge too.
s.editbox_action(EditAction::Move {
unit: EditUnit::Word,
back: true,
extend: false,
});
assert!(s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: false
}));
assert_eq!(
s.eval::<String>("return E:GetText()").unwrap(),
"b",
"the word move must have reached text start"
"ac",
"the caret moved, so BACKSPACE took 'b' — a flagged box that is handed the action acts on it"
);
}
/// The XML attribute is the same flag under its other name (`ignoreArrows` occurs once in the
/// image, at `0x879b78`, with one xref: the attribute push at `0x77a13a`).
#[test]
fn the_ignore_arrows_xml_attribute_is_alt_arrow_key_mode() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap();
s.run("E:SetAltArrowKeyMode(1)").unwrap();
assert_eq!(
s.eval::<Option<i64>>("return E:GetAltArrowKeyMode()")
.unwrap(),
Some(1)
);
}
+14
View File
@@ -134,6 +134,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
m.set(
// SetMaskTexture(path) — the disc's mask art. A real 1.12 method (the name is in the 5875
// image) with no getter beside it, so this is write-only from Lua, exactly as there.
//
// An absent or empty path restores the engine default rather than leaving the map
// unmasked: `SetMaskTexture` is how a UI replaces the circle, never how it removes one,
// and a nil-means-square reading would hand every mistyped path a square minimap with no
// error. (pfUI passes a real path; this is about the failure mode, not about pfUI.)
"SetMaskTexture",
lua.create_function(|lua, (this, path): (Table, Option<String>)| {
let path = path.filter(|p| !p.is_empty());
with_minimap(lua, &this, |m| m.mask_texture = path)
})?,
)?;
m.set(
"GetZoomLevels",
lua.create_function(|lua, this: Table| {
+40
View File
@@ -829,6 +829,23 @@ impl UiScript {
self.model_mut().minimap_ping_request.take()
}
/// The mask art the Minimap widget asks its renderer for — `Minimap:SetMaskTexture`'s value,
/// or `None` while no widget has set one (the app then keeps
/// [`crate::widget::MINIMAP_DEFAULT_MASK`]).
///
/// The **first** Minimap in the registry answers, not every one: the mask is a property of the
/// map the app draws, and the app draws one. (Everything else in `MinimapState` is per widget
/// because the Lua API reads it back per widget; this has no getter in 1.12 to read back.)
pub fn minimap_mask_texture(&self) -> Option<String> {
let model = self.model_ref();
model.arena.minimap_kinds().iter().find_map(|&h| {
match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Minimap(m)) => m.mask_texture.clone(),
_ => None,
}
})
}
/// Monotonic count of Minimap widgets ever created in this VM — the O(1) signal that a new
/// one exists and needs the containment state pushed.
pub fn minimap_widgets_created(&self) -> u64 {
@@ -1216,6 +1233,29 @@ impl UiScript {
editbox::action(&self.lua, action)
}
/// Is the focused EditBox in **alt-arrow mode** — the flag the XML spells `ignoreArrows` and
/// the Lua surface spells `SetAltArrowKeyMode` (`[editbox+0x318] & 0x10`)?
///
/// The host asks this to decide whether an arrow key reaches the box at all. With the flag set
/// and ALT not held, the reference's key handler returns 0 at `0x77b1c4` for the four arrow
/// codes — **not consumed** — and the strata walk carries down to `CGWorldFrame`, which runs
/// the key's binding. That is what lets you turn while the chat box has focus, and it is why
/// the gate is on the key rather than on the [`EditAction`]: `Move { unit: Edge }` reaches the
/// box from HOME/END as well as from an arrow, and only the arrow is gated.
///
/// `false` when nothing is focused, which is the same answer as an unflagged box: neither
/// declines the key.
pub fn editbox_alt_arrow_mode(&self) -> bool {
let model = self.model_ref();
model.focused_editbox.is_some_and(|h| {
model.arena.frame(h).is_some_and(|f| {
f.effective_visible
&& matches!(&f.kind_state,
crate::widget::KindState::EditBox(eb) if eb.alt_arrow_key_mode)
})
})
}
/// Whether an EditBox currently holds keyboard focus (and is effectively visible) — the app gates
/// world/player key input on this, matching the client's `DAT_00cf4dc8 != 0` test (RF-0082 §1).
pub fn has_keyboard_focus(&self) -> bool {
+3 -2
View File
@@ -1494,8 +1494,9 @@ impl Model {
petition_requests: Vec::new(),
tell_requests: Vec::new(),
open_chat_requests: Vec::new(),
chat_window_looks: [chat_window::ChatWindowLook::DEFAULT;
chat_window::NUM_CHAT_WINDOWS],
// Seeded per window, because the stock `DOCKED` position is not the same for all
// seven (`ChatWindowLook::stock`): 1 and 2 are the dock, 3..7 are undocked.
chat_window_looks: std::array::from_fn(chat_window::ChatWindowLook::stock),
chat_window_changes: HashSet::new(),
user_placed_changed: false,
default_language: None,
@@ -260,3 +260,36 @@ fn a_minimap_is_born_with_nine_model_children_and_the_ninth_is_the_player_arrow(
.unwrap()
.is_none_or(|p| p.is_empty()));
}
/// **`Minimap:SetMaskTexture` is state, and an empty path restores the default rather than
/// unmasking the map.**
///
/// A real 1.12 method (the name is in the 5875 image) with no getter beside it, so the readback
/// here is through the arena — the same shape `simplehtml`'s and `modelframe`'s tests use for a
/// write-only verb. pfUI's `modules/minimap.lua:27` is the caller that matters: swapping this art
/// is how its square minimap becomes square.
#[test]
fn set_mask_texture_is_state_and_empty_restores_the_default() {
let s = script();
s.run(r#"m = CreateFrame("Minimap", "TestMinimap")"#)
.unwrap();
assert_eq!(s.minimap_mask_texture(), None, "fresh = the engine default");
s.run(r#"m:SetMaskTexture("Interface\\AddOns\\pfUI\\img\\minimap")"#)
.unwrap();
assert_eq!(
s.minimap_mask_texture().as_deref(),
Some("Interface\\AddOns\\pfUI\\img\\minimap")
);
// Empty and nil both mean "back to the engine's circle" — never "no mask at all". A
// nil-means-unmasked reading would hand every mistyped path a silently square minimap.
s.run(r#"m:SetMaskTexture("")"#).unwrap();
assert_eq!(s.minimap_mask_texture(), None);
s.run(r#"m:SetMaskTexture("Interface\\Foo") m:SetMaskTexture(nil)"#)
.unwrap();
assert_eq!(s.minimap_mask_texture(), None);
// There is no getter in 1.12, and we do not invent one (decision 1189).
assert!(s.eval::<bool>("return m.GetMaskTexture == nil").unwrap());
}
+28 -12
View File
@@ -19,7 +19,7 @@ pub enum EditUnit {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EditAction {
/// Move the caret one `unit` back/forward; `extend` drags the selection from its fixed
/// anchor (the Shift family). `Char` moves honor `ignoreArrows` (consumed but inert — the
/// anchor (the Shift family). (The alt-arrow gate is upstream, on the key — the
/// ref guard `0x77b18e`; arrows are the only chord source of `Char` moves).
Move {
unit: EditUnit,
@@ -92,9 +92,26 @@ pub struct EditBoxState {
/// `password` (bit3): the display string is one `'*'` per *character* (the mask, `E+0x334`); the
/// real text is untouched.
pub password: bool,
/// `ignoreArrows` (bit4): LEFT/RIGHT (and UP/DOWN) are consumed but do nothing unless Ctrl is
/// held (RF-0082 §4, guard `0x77b18e`).
pub ignore_arrows: bool,
/// **Alt-arrow mode** (bit4) — the XML attribute spells it `ignoreArrows`, the Lua surface
/// spells it `SetAltArrowKeyMode`/`GetAltArrowKeyMode` (`0x7996e0`/`0x799790`), and there is
/// exactly one flag behind both names. `SetIgnoreArrows` does **not** exist in 5875: the
/// 48-entry EditBox method table `[0x87bb68, 0x87bce8)` carries the Alt pair at 46/47 and no
/// entry whose name contains "Ignore" (wow-re `ignorearrows-alt-arrow-gate.md`, §5 VERIFIED).
///
/// **What it does, and it is not what "ignore" suggests.** With the flag set, the four arrow
/// keys (`0x204` LEFT / `0x205` UP / `0x206` RIGHT / `0x207` DOWN) are **not consumed by the
/// box at all** unless ALT is held — the guard `0x77b18e` returns 0, the strata walk carries
/// on down to `CGWorldFrame`, and the key reaches its binding (`TURNLEFT`, `MOVEFORWARD`).
/// That is the whole point: it is what lets you turn while the chat box has focus. With Alt
/// held the guard falls through to ordinary handling and the caret moves.
///
/// A focused box otherwise consumes **everything** (the handler's shared tail `0x77b35e`
/// returns 1), so these four keys are the only ones it ever lets past.
///
/// benilla read this as "consumed but inert, unless Ctrl" for two rounds — the modifier was
/// wrong (a wow-re note said `0x41f8f0(2)` was Ctrl; it is ALT, and five other notes already
/// said so) and so was the consumption. Both corrected by the §5.
pub alt_arrow_key_mode: bool,
/// `maxLetters` (`E+0x340`, 0 = unlimited): after each insert, trim from the end while the
/// *letter* (char) count exceeds this (RF-0082 §3).
pub max_letters: usize,
@@ -254,7 +271,7 @@ impl Default for EditBoxState {
multi_line: false,
numeric: false,
password: false,
ignore_arrows: false,
alt_arrow_key_mode: false,
max_letters: 0,
max_bytes: None,
text_region: None,
@@ -590,13 +607,12 @@ impl EditBoxState {
match action {
EditAction::Move { unit, back, extend } => {
match unit {
// `ignoreArrows`: consumed but inert (guard `0x77b18e`). Arrows are the only
// chord source of a Char move, and the ref's Ctrl bypass maps to Word.
EditUnit::Char => {
if !self.ignore_arrows {
self.move_by_char(!back, extend);
}
}
// No flag test here any more. The alt-arrow gate is on the KEY, not on the
// action — with the flag set the box never sees the arrow at all, because the
// host declines it before the chord is dispatched (`UiScript::
// editbox_alt_arrow_fallthrough`). Anything that reaches this far was either
// Alt-held or not an arrow, and both move the caret normally.
EditUnit::Char => self.move_by_char(!back, extend),
EditUnit::Word => self.move_by_word(!back, extend),
EditUnit::Edge => self.move_to_edge(!back, extend),
}
+15
View File
@@ -392,6 +392,15 @@ pub struct MinimapState {
/// Is the player inside a WMO interior (the client's `0xceaa60`)? Selects which index the Lua
/// zoom API reads and writes. Pushed down by the app, which owns the WMO containment test.
pub inside: bool,
/// The mask art the disc is cut to — `Minimap:SetMaskTexture(path)`, a real 1.12 method (the
/// name is in the 5875 image; there is no `GetMaskTexture` beside it, so this is write-only
/// from Lua). `None` = the engine default, [`MINIMAP_DEFAULT_MASK`].
///
/// State-only here, pixels app-side, like the zoom index (0203): the app already masks the
/// disc through its own `UiQuadMask`, and this only decides which texture it loads. It is what
/// makes a **square minimap** possible, which is the single most recognisable thing pfUI does
/// to the default UI (`modules/minimap.lua:27`).
pub mask_texture: Option<String>,
/// The **player-arrow** `Model` — the client's `[Minimap+0x338]`, ninth and last of the nine
/// engine children the ctor builds ([`MINIMAP_ENGINE_CHILDREN`]). An explicit slot because that
/// is the shape the client keeps: `CMinimap::SetPlayerFacing 0x4eb8e0` reaches the arrow through
@@ -415,6 +424,7 @@ impl Default for MinimapState {
zoom: MINIMAP_DEFAULT_ZOOM,
inside_zoom: MINIMAP_DEFAULT_ZOOM,
inside: false,
mask_texture: None,
// Filled by the arena the moment the widget is inserted — the nine are ctor-time, so a
// Minimap is never observably without them (`WidgetArena::create`).
player_arrow: None,
@@ -445,6 +455,11 @@ impl MinimapState {
/// The client's minimap zoom-level count (`get_zoom_levels` `0x6da9a0` returns the constant 6).
pub const MINIMAP_ZOOM_LEVELS: u8 = 6;
/// The engine's own minimap mask — the circle `Minimap:SetMaskTexture` replaces. benilla's
/// renderer has loaded this path since the minimap existed; naming it here is what lets the Lua
/// setter override it.
pub const MINIMAP_DEFAULT_MASK: &str = "Textures\\MinimapMask";
/// **Nine `Model` children, built by the `CMinimap` ctor before anything else sees the widget** —
/// wow-re `ui/scratch/widget-list-bindings.md` §5 / `ui/ui.md`, VERIFIED. The ctor `0x4edbc0`
/// (TU `Ui\MinimapFrame.cpp`) allocates `CSimpleModel` (`0x76c8e0`) nine times with the Minimap as
+3 -3
View File
@@ -177,9 +177,9 @@ pub use kinds::{
EditAction, EditBoxState, EditOutcome, EditUnit, FrameKind, InsertMode, KindState,
MessageFrameState, MessageLine, MinimapState, ModelState, RegionKind, ScrollFrameState,
ScrollingMessageState, SliderState, StatusBarState, TooltipState, COOLDOWN_FLASH_SECS,
MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM,
MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS,
TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL,
MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP,
TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────