Files
benilla-octo/crates/benilla-app/assets/ui/UnitPopup.xml
T
SamandClaude Fable 5 eeb607e5e6 the raid tab lands, and sounds take the reference's voice buses
- the social window's fourth tab, Raid: the eight-subgroup grid over the roster, drag a member between subgroups, Convert To Raid, promote / demote assistant, uninvite, Ready Check with its popup, and the Raid Info flyout on the new `CMSG_REQUEST_RAID_INFO` / `SMSG_RAID_INSTANCE_INFO` wire (byte-golden against vmangos, an empty answer decoding as an empty list); `raid1`..`raid40` unit tokens, and `/partytest raid` stages a roster for a solo look
- an imbue pressed with an empty weapon hand says "Your weapon hand is empty" instead of raising a cursor — the attribute bit has two readers in the reference and the second is the error (corrects the day's first cut)
- sounds route onto the reference's own voice buses with its per-bus caps (the 0x87ce60 table, pinned as bytes): weapon impacts and clangs share four voices, footsteps six, the attacker's grunt one, the victim's injury two — so a pack swinging together makes about a dozen voices where it made dozens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 18:22:17 +07:00

869 lines
44 KiB
XML

<!--
The unit right-click popups (decision 0434 phase 5) — benilla's own implementation of the
engine the 1.12 FrameXML `UnitPopup.lua` SPECIFIES. That file is the specification this one
matches, not the source of this text. What is faithful is everything observable: the row
catalogue and every row's facts (label, interact-distance class, colours, atlas cells), the
membership and ORDER of every menu, which rows a menu hides and when, which rows grey out and
when, and what each row does when it is clicked. How that is written is ours — a row
catalogue walked by a builder, and three keyed tables (hide / grey / click) where the
reference runs one long `elseif value == "…"` ladder each. The globals the reference
publishes (`UnitPopupButtons`, `UnitPopupMenus`, `UnitPopupShown`, `UnitPopup_ShowMenu`, …)
keep their names and shapes exactly, because addons index, call and replace them by name.
The menus ride the dropdown kit's MENU displayMode + level-2 submenus (UIDropDownMenu.xml).
The owner frames (TargetFrameDropDown, PlayerFrameDropDown, PartyMemberFrameNDropDown) live
with their frames in UnitFrames.xml / PartyFrame.xml — this file is the shared engine, like
the ref's load split. The OnUpdate driver frame at the bottom stands in for the ref's
UIParent.xml OnUpdate hook (the DeathFrame hidden-driver pattern; benilla's UIParent carries
no OnUpdate).
DEVIATIONS, deliberate (each waits for its arc):
- NO ADDED ROWS. A "PVP" row briefly sat at the head of SELF (decision 0646 §3); decision 0652
took it back out. 1.12.1 has no PvP toggle in any menu — `TogglePVP()`'s single caller in the
whole shipped UI is `SlashCmdList["PVP"]`, and `UnitPopup.lua` contains the string "PVP"
exactly zero times — so the row was a deviation that made this menu read wrong against the
reference. `/pvp` is the toggle the client has.
- DEFERRED ENTRIES: rows whose backing system doesn't exist yet are HIDDEN outright (the
BENILLA_UNITPOPUP_DEFERRED set below) rather than shown-dead: TARGET (the friends
list), RESET_INSTANCES (instances). TRADE came out
with decision 0592 P1, DUEL with 0633, INSPECT with 0631, GUILD_PROMOTE/GUILD_LEAVE with
the guild arc (1257 — their hide predicates and click rows are below), FOLLOW with 0890's
follow-up — and
that last entry's stated reason ("its CMSG family is its own slice") was simply WRONG: 0890
verified auto-follow sends nothing at all, it is pure client-side movement, so the row was
waiting on a wire slice that does not exist. It was the motion law it really needed.
Everything a menu SHOWS works. The PET menu opened with decision 1066: its four rows and the
one predicate that forks them are below, its opener is UnitFrames.xml's PetFrameDropDown, and
the two dialogs behind Abandon and Rename are registered in PetActionBar.xml (with the pet
arc, like every other dialog in this codebase — this file must stay loadable without the
popup engine). The RAID_* rank rows opened with decision 1549: their hide-always predicate is
gone, they carry the reference's own per-row rules and click handlers, and their opener is
RaidFrame.xml's row right-click.
- Lua 5.1: the ref's 5.0 `for i, v in t do` iteration is ipairs/pairs here; strsub is
string.sub. `TEXT` is kept because the reference publishes it as the localization hook every
displayed string passes through (it is the identity, and BasicControls.xml defines the real
one) — row labels are declared through it here for the same reason.
- The newbie tooltips on rows are OUT with the kit's (default-off cvar look).
- CheckInteractDistance now really gates (decision 0631): the binding landed with the inspect
row, off wow-re's §5-VERIFIED `check_interact_dist2` table {10², 11.1111², 10², 30²}. So every
SHOWN row with a non-zero `dist` grays out of range now — INSPECT (dist 1, 10 yd), TRADE
(dist 2, 11.11 yd) and DUEL (dist 3, 10 yd) — where TRADE and DUEL shipped ungated only
because the binding was nil when they landed. The nil-guard in the enable pass below is
kept as-is.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- The localization identity every displayed string passes through (BasicControls.xml
-- defines the real one; this stands in when it is not loaded).
if ( not TEXT ) then
TEXT = function(s) return s end
end
UNITPOPUP_TIMEOUT = 5;
-- ── The row catalogue ──────────────────────────────────────────────────────────────
-- `UnitPopupButtons` is the flat key → row map the reference publishes: addons index it,
-- and overwrite entries in it, by name. Each row is declared once, from its key, the
-- label to draw, and its interact-distance class — 0 for no range check at all, 1..4 for
-- the client's own CheckInteractDistance classes, which the enable pass consults. Extra
-- presentation attributes (a leading spacer, a submenu arrow, a check box, a colour, an
-- icon and its atlas cell) ride in the fourth field and are copied through untouched.
UnitPopupButtons = { };
local function declareRow(key, label, dist, attributes)
local row = attributes or { };
row.text = TEXT(label);
row.dist = dist or 0;
UnitPopupButtons[key] = row;
end
-- { key, label, interact-distance class, extra attributes }
local ROWS = {
{ "CANCEL", CANCEL, 0, { space = 1 } },
{ "TRADE", TRADE, 2 },
{ "INSPECT", INSPECT, 1 },
{ "TARGET", TARGET },
{ "DUEL", DUEL, 3, { space = 1 } },
{ "WHISPER", WHISPER },
{ "INVITE", PARTY_INVITE },
{ "UNINVITE", PARTY_UNINVITE },
{ "PROMOTE", PARTY_PROMOTE },
{ "GUILD_PROMOTE", GUILD_PROMOTE },
{ "GUILD_LEAVE", GUILD_LEAVE },
{ "LEAVE", PARTY_LEAVE },
{ "FOLLOW", FOLLOW, 4 },
-- The pet verbs. Only the last four are reachable — no 1.12 menu holds the six
-- stance/command rows above them (the reference declares them and builds no menu
-- from them either), so nothing here can click them. They stay declared because the
-- catalogue is what addons index.
{ "PET_PASSIVE", PET_PASSIVE },
{ "PET_DEFENSIVE", PET_DEFENSIVE },
{ "PET_AGGRESSIVE", PET_AGGRESSIVE },
{ "PET_WAIT", PET_WAIT },
{ "PET_FOLLOW", PET_FOLLOW },
{ "PET_ATTACK", PET_ATTACK },
{ "PET_DISMISS", PET_DISMISS },
{ "PET_ABANDON", PET_ABANDON },
{ "PET_PAPERDOLL", PET_PAPERDOLL },
{ "PET_RENAME", PET_RENAME },
-- Loot: the parent row opens a submenu, the five below it are that submenu.
{ "LOOT_METHOD", LOOT_METHOD, 0, { nested = 1 } },
{ "FREE_FOR_ALL", LOOT_FREE_FOR_ALL },
{ "ROUND_ROBIN", LOOT_ROUND_ROBIN },
{ "MASTER_LOOTER", LOOT_MASTER_LOOTER },
{ "GROUP_LOOT", LOOT_GROUP_LOOT },
{ "NEED_BEFORE_GREED", LOOT_NEED_BEFORE_GREED },
{ "RESET_INSTANCES", RESET_INSTANCES },
-- The quality floor, likewise — and each threshold row wears its own quality colour.
{ "LOOT_THRESHOLD", LOOT_THRESHOLD, 0, { nested = 1 } },
{ "LOOT_PROMOTE", LOOT_PROMOTE },
{ "ITEM_QUALITY2_DESC", ITEM_QUALITY2_DESC, 0, { color = ITEM_QUALITY_COLORS[2] } },
{ "ITEM_QUALITY3_DESC", ITEM_QUALITY3_DESC, 0, { color = ITEM_QUALITY_COLORS[3] } },
{ "ITEM_QUALITY4_DESC", ITEM_QUALITY4_DESC, 0, { color = ITEM_QUALITY_COLORS[4] } },
-- The raid rank rows. Their labels are the plain verbs, not RAID_-prefixed strings.
{ "RAID_LEADER", NEW_LEADER },
{ "RAID_PROMOTE", PROMOTE },
{ "RAID_DEMOTE", DEMOTE },
{ "RAID_REMOVE", REMOVE },
-- The marks' parent row; the eight marks themselves are declared below, and clearing
-- the mark is a row of its own.
{ "RAID_TARGET_ICON", RAID_TARGET_ICON, 0, { nested = 1 } },
{ "RAID_TARGET_NONE", NONE, 0, { checkable = 1 } },
};
for _, spec in ipairs(ROWS) do
declareRow(spec[1], spec[2], spec[3], spec[4]);
end
-- The eight marks ride ONE atlas: four quarter-cells across, two down, filled left to
-- right and top to bottom in mark order. So a mark's cell follows from its number, and
-- the only facts worth writing per mark are its name and its colour.
local RAID_MARK_TEXTURE = "Interface\\TargetingFrame\\UI-RaidTargetingIcons";
local RAID_MARK_CELL = 0.25;
local RAID_MARKS = {
{ RAID_TARGET_1, 1.0, 0.92, 0 },
{ RAID_TARGET_2, 0.98, 0.57, 0 },
{ RAID_TARGET_3, 0.83, 0.22, 0.9 },
{ RAID_TARGET_4, 0.04, 0.95, 0 },
{ RAID_TARGET_5, 0.7, 0.82, 0.875 },
{ RAID_TARGET_6, 0, 0.71, 1 },
{ RAID_TARGET_7, 1.0, 0.24, 0.168 },
{ RAID_TARGET_8, 0.98, 0.98, 0.98 },
};
for mark, spec in ipairs(RAID_MARKS) do
local cell = mark - 1;
local atlasRow = math.floor(cell / 4);
local atlasColumn = cell - atlasRow * 4;
declareRow("RAID_TARGET_" .. mark, spec[1], 0, {
checkable = 1,
color = { r = spec[2], g = spec[3], b = spec[4] },
icon = RAID_MARK_TEXTURE,
tCoordLeft = atlasColumn * RAID_MARK_CELL,
tCoordRight = (atlasColumn + 1) * RAID_MARK_CELL,
tCoordTop = atlasRow * RAID_MARK_CELL,
tCoordBottom = (atlasRow + 1) * RAID_MARK_CELL,
});
end
-- ── The menus ──────────────────────────────────────────────────────────────────────
-- Which rows a menu holds, and in what order. This IS the menu's observable shape, so
-- both the membership and the order are the reference's exactly. The last two are the
-- level-2 submenus, keyed by the row that opens them.
UnitPopupMenus = {
SELF = { "LOOT_METHOD", "LOOT_THRESHOLD", "LOOT_PROMOTE", "LEAVE", "RESET_INSTANCES", "RAID_TARGET_ICON", "CANCEL" },
PET = { "PET_PAPERDOLL", "PET_RENAME", "PET_ABANDON", "PET_DISMISS", "CANCEL" },
PARTY = { "WHISPER", "PROMOTE", "LOOT_PROMOTE", "UNINVITE", "INSPECT", "TRADE", "FOLLOW", "DUEL", "RAID_TARGET_ICON", "CANCEL" },
PLAYER = { "WHISPER", "INSPECT", "INVITE", "TRADE", "FOLLOW", "DUEL", "RAID_TARGET_ICON", "CANCEL" },
RAID = { "RAID_LEADER", "RAID_PROMOTE", "RAID_DEMOTE", "RAID_REMOVE", "CANCEL" },
FRIEND = { "WHISPER", "INVITE", "TARGET", "GUILD_PROMOTE", "GUILD_LEAVE", "CANCEL" },
RAID_TARGET_ICON = { "RAID_TARGET_1", "RAID_TARGET_2", "RAID_TARGET_3", "RAID_TARGET_4", "RAID_TARGET_5", "RAID_TARGET_6", "RAID_TARGET_7", "RAID_TARGET_8", "RAID_TARGET_NONE" },
LOOT_METHOD = { "FREE_FOR_ALL", "ROUND_ROBIN", "MASTER_LOOTER", "GROUP_LOOT", "NEED_BEFORE_GREED", "CANCEL" },
LOOT_THRESHOLD = { "ITEM_QUALITY2_DESC", "ITEM_QUALITY3_DESC", "ITEM_QUALITY4_DESC", "CANCEL" },
};
-- Row N of the menu being opened is shown (1) or hidden (0). This is only a seed — the
-- hide pass rewrites every entry it walks, and grows the table for a menu longer than
-- the reference's nine (PARTY's ten rows do exactly that).
UnitPopupShown = { 1, 1, 1, 1, 1, 1, 1, 1, 1 };
-- The row that PICKS each loot method → the token the API speaks. Used twice: to gate the
-- five submenu rows, and to send the choice.
local LOOT_METHOD_TOKENS = {
FREE_FOR_ALL = "freeforall",
ROUND_ROBIN = "roundrobin",
MASTER_LOOTER = "master",
GROUP_LOOT = "group",
NEED_BEFORE_GREED = "needbeforegreed",
};
-- The label the parent Loot Method row wears, keyed by the token GetLootMethod() answers
-- with. (The reference's newbie tooltips are out with the kit's.)
UnitLootMethod = {
freeforall = { text = LOOT_FREE_FOR_ALL },
roundrobin = { text = LOOT_ROUND_ROBIN },
master = { text = LOOT_MASTER_LOOTER },
group = { text = LOOT_GROUP_LOOT },
needbeforegreed = { text = LOOT_NEED_BEFORE_GREED },
};
-- The dropdowns whose open menu this engine owns — the enable pass ignores anybody
-- else's. (FriendsDropDown is ItemRef.xml's stand-in and drives its menu directly.)
UnitPopupFrames = {
"PlayerFrameDropDown",
"TargetFrameDropDown",
"PetFrameDropDown",
"PartyMemberFrame1DropDown",
"PartyMemberFrame2DropDown",
"PartyMemberFrame3DropDown",
"PartyMemberFrame4DropDown",
};
-- The DEFERRED set (header): entries hidden until their backing arc lands.
BENILLA_UNITPOPUP_DEFERRED = {
TARGET = 1,
RESET_INSTANCES = 1,
};
-- ── Shared helpers ─────────────────────────────────────────────────────────────────
-- The marks and the row that opens them share one key prefix, and three passes ask the
-- same question, so it gets a name. RAID_TARGET_ICON answers YES on purpose: the parent
-- row is leader business exactly like the marks under it.
local function isRaidMarkRow(key)
return string.sub(key, 1, 12) == "RAID_TARGET_";
end
-- The party facts the hide and enable passes both ask about, read once per pass instead
-- of once per row. `canCoop` needs the menu's unit, so it is only meaningful when one is
-- passed (the enable pass never asks).
local function partyState(menu)
local party = {
inParty = 0,
isLeader = 0,
isAssistant = 0,
canCoop = 0,
};
if ( (GetNumPartyMembers() > 0) or (GetNumRaidMembers() > 0) ) then
party.inParty = 1;
end
if ( party.inParty == 1 and IsPartyLeader() ) then
party.isLeader = 1;
end
if ( IsRaidOfficer() ) then
party.isAssistant = 1;
end
if ( menu and menu.unit and UnitCanCooperate("player", menu.unit) ) then
party.canCoop = 1;
end
return party;
end
-- Record which menu a level is showing, so the enable pass can find it again.
local function rememberOpenMenu(level, menu)
OPEN_DROPDOWNMENUS[level] = { which = menu.which, unit = menu.unit };
end
-- The dropdown kit wants a text colour as three separate fields, and a row that has no
-- colour must leave all three alone.
local function applyTextColor(info, color)
if ( color ) then
info.textR, info.textG, info.textB = color.r, color.g, color.b;
end
end
-- One catalogue row → one dropdown info table. Both menu levels want the same
-- presentation off a row, so it is read in one place; each caller adds what only its own
-- level decides (the owner, the checked state, the submenu arrow). Returns the row
-- alongside, since callers go on to ask it questions.
local function rowInfo(key)
local row = UnitPopupButtons[key];
local info = {
text = row.text,
value = key,
func = UnitPopup_OnClick,
icon = row.icon,
tCoordLeft = row.tCoordLeft,
tCoordRight = row.tCoordRight,
tCoordTop = row.tCoordTop,
tCoordBottom = row.tCoordBottom,
};
applyTextColor(info, row.color);
return info, row;
end
-- ── Which rows a menu shows at all ─────────────────────────────────────────────────
-- Every row that can be hidden owns one predicate answering "hide this?" from the menu
-- being opened plus the party facts. A row with no predicate is always shown. The
-- conditions are the reference's, row for row; only the ladder is gone.
local HIDE_ROW = { };
-- You cannot trade with, duel, inspect or follow someone you cannot cooperate with — an
-- enemy-faction player is already attackable, so none of the four has anything to offer.
-- (Ref UnitPopup.lua l.285/304-307/311-319. INSPECT and FOLLOW were MISSING until
-- decision 1033: without them both stayed shown for *every* unit — including one that
-- does not exist — so PARTY's shown-count never fell to zero, ShowMenu's "nothing but
-- CANCEL, don't show the menu" early-out never fired at load, and each party dropdown's
-- OnLoad-time initialize ran on to its closing PlaySound.)
local function hideWithoutCooperation(menu, party)
return party.canCoop == 0;
end
HIDE_ROW["TRADE"] = hideWithoutCooperation;
HIDE_ROW["DUEL"] = hideWithoutCooperation;
HIDE_ROW["INSPECT"] = hideWithoutCooperation;
HIDE_ROW["FOLLOW"] = hideWithoutCooperation;
-- Nothing to say to yourself, or to someone you cannot cooperate with.
HIDE_ROW["WHISPER"] = function(menu, party)
return (menu.unit and party.canCoop == 0) or menu.name == UnitName("player");
end
-- The units an invite would be pointless against: the party we already have, and us.
local INVITE_POINTLESS_AGAINST = { "party1", "party2", "party3", "party4", "player" };
HIDE_ROW["INVITE"] = function(menu, party)
if ( menu.unit ) then
return party.canCoop == 0;
end
-- Addressed by NAME (a chat name, a /who row): no unit to test, so compare names.
for _, unit in ipairs(INVITE_POINTLESS_AGAINST) do
if ( menu.name == UnitName(unit) ) then
return true;
end
end
end
-- Party bookkeeping rows: some need a party at all, some need to be leading it.
local function hideOutsideAParty(menu, party)
return party.inParty == 0;
end
HIDE_ROW["LEAVE"] = hideOutsideAParty;
HIDE_ROW["LOOT_METHOD"] = hideOutsideAParty;
HIDE_ROW["LOOT_THRESHOLD"] = hideOutsideAParty;
local function hideUnlessLeading(menu, party)
return party.inParty == 0 or party.isLeader == 0;
end
HIDE_ROW["PROMOTE"] = hideUnlessLeading;
HIDE_ROW["UNINVITE"] = hideUnlessLeading;
-- The five loot-method rows: in a party always, and either we set the method or this row
-- IS the method already in force — so a member still gets to SEE which one is on.
for rowKey, method in pairs(LOOT_METHOD_TOKENS) do
HIDE_ROW[rowKey] = function(menu, party)
return party.inParty == 0 or (party.isLeader == 0 and GetLootMethod() ~= method);
end
end
-- Handing master loot over: only a leader under master loot, and never to whoever
-- already holds it (party member or, at index 0, us).
HIDE_ROW["LOOT_PROMOTE"] = function(menu, party)
local lootMethod, lootMaster = GetLootMethod();
return party.inParty == 0 or party.isLeader == 0 or lootMethod ~= "master"
or (lootMaster and menu.unit == "party" .. lootMaster)
or (menu.unit == "player" and lootMaster and lootMaster == 0);
end
-- The pet menu forks on ONE predicate, and Dismiss reads it INVERTED (decision 1066,
-- ref UnitPopup.lua l.402-417): a hunter's pet can be abandoned, so it offers
-- paperdoll/rename/abandon and hides dismiss; a warlock's demon cannot, so it offers only
-- dismiss. Rename wants both bits, because its own is one-shot — the server clears it
-- after the first rename.
local function hideUnlessAbandonable()
return not PetCanBeAbandoned();
end
HIDE_ROW["PET_PAPERDOLL"] = hideUnlessAbandonable;
HIDE_ROW["PET_ABANDON"] = hideUnlessAbandonable;
HIDE_ROW["PET_RENAME"] = function()
return not PetCanBeAbandoned() or not PetCanBeRenamed();
end
HIDE_ROW["PET_DISMISS"] = function()
return PetCanBeAbandoned();
end
-- ── The four raid rank rows (decision 1549, ref UnitPopup.lua l.382-401) ──────────────
--
-- Every one of them re-reads the CLICKED ROW's rank through `GetRaidRosterInfo(userData)`
-- — which is why the RAID menu is the only one in this file opened with a fourth
-- argument, and why that argument is the raid ROW INDEX rather than a name. The rules,
-- ref-verbatim:
--
-- New Leader leader only, and not on the leader themself (rank 2)
-- Promote leader only, and only on a plain member (rank 0)
-- Demote leader only, and only on an assistant (rank 1)
-- Remove leader OR assistant, and never on the leader
--
-- The first three also require `menu.name`: a row whose name has not resolved cannot be
-- addressed by the by-name bindings behind them. Remove does not, because it acts on the
-- index directly.
local function raidRank(menu)
local _, rank = GetRaidRosterInfo(menu.userData);
return rank;
end
-- `party.isLeader` is 0/1, not a boolean, and **0 is truthy in Lua** — so these compare
-- against 0 rather than saying `not party.isLeader`, exactly as every other predicate in
-- this file does and exactly as the reference's own `(isLeader == 0)` does.
HIDE_ROW["RAID_LEADER"] = function(menu, party)
return party.isLeader == 0 or raidRank(menu) == 2 or not menu.name;
end
HIDE_ROW["RAID_PROMOTE"] = function(menu, party)
return party.isLeader == 0 or raidRank(menu) ~= 0 or not menu.name;
end
HIDE_ROW["RAID_DEMOTE"] = function(menu, party)
return party.isLeader == 0 or raidRank(menu) ~= 1 or not menu.name;
end
HIDE_ROW["RAID_REMOVE"] = function(menu, party)
return (party.isLeader == 0 and party.isAssistant == 0) or raidRank(menu) == 2;
end
-- The two GUILD rows, ref UnitPopup.lua l.323-330 (decision 1257's guild arc un-defers
-- them). Both gate on `GuildFrame:IsVisible()`, which is the whole point of them: these
-- rows exist for right-clicking a GUILD ROSTER row, not a unit, so they are hidden
-- everywhere else — a friend-list or /who row opens the same FRIEND menu and must not
-- offer them.
-- · GUILD_PROMOTE — hand over the guild. Guild master only, and never on yourself.
-- · GUILD_LEAVE — leave the guild. Only ever offered on YOURSELF.
-- `GuildFrame and` is a nil-guard, not a behaviour change: FriendsFrame.xml loads after
-- this file in the manifest, so in a real session the frame always exists and the
-- condition is exactly the reference's. It is here because this file must stay usable
-- when loaded on its own (the same reason FriendsFrame.xml guards `UpdateMicroButtons`).
HIDE_ROW["GUILD_PROMOTE"] = function(menu)
return not IsGuildLeader() or menu.name == UnitName("player")
or not (GuildFrame and GuildFrame:IsVisible());
end
HIDE_ROW["GUILD_LEAVE"] = function(menu)
return menu.name ~= UnitName("player")
or not (GuildFrame and GuildFrame:IsVisible());
end
-- Marking is leader/assistant business, and a mark on an enemy-faction player nobody in
-- the group can cooperate with is pointless — except on our OWN menu, which marks us.
local function hideRaidMarkRow(menu, party)
if ( party.inParty == 0 or (party.isLeader == 0 and party.isAssistant == 0) ) then
return true;
end
if ( menu.which ~= "SELF" and UnitExists("target") and not UnitInParty("target") ) then
return UnitIsPlayer("target")
and not UnitCanCooperate("player", "target")
and not UnitIsUnit("target", "player");
end
end
-- A row's own predicate, or the shared one every raid-mark row uses (the reference keys
-- those by PREFIX, so an addon's own mark row is covered too).
local function hidePredicate(key)
local hide = HIDE_ROW[key];
if ( hide ) then
return hide;
end
if ( isRaidMarkRow(key) ) then
return hideRaidMarkRow;
end
end
function UnitPopup_ShowMenu(dropdownMenu, which, unit, name, userData)
-- Wake the enable-pass driver: it hides itself the moment no unit menu of ours is
-- open (UnitPopup_OnUpdate's early-out), and this function is the one opener of
-- every unit menu — the six dropdowns' initialize functions all land here.
BenillaUnitPopupDriver:Show();
dropdownMenu.which = which;
dropdownMenu.unit = unit;
if ( unit and not name ) then
name = UnitName(unit);
end
dropdownMenu.name = name;
dropdownMenu.userData = userData;
-- No realm half: benilla has no cross-realm names yet (the reference reads one out of
-- UnitName's second return and appends it to whispers and targets).
dropdownMenu.server = nil;
UnitPopup_HideButtons();
-- Nothing but Cancel left? Then there is no menu worth opening.
local count = 0;
for index, value in ipairs(UnitPopupMenus[which]) do
if ( UnitPopupShown[index] == 1 and value ~= "CANCEL" ) then
count = count + 1;
end
end
if ( count < 1 ) then
return;
end
-- The two nested rows wear the live setting as their own label, and open only for
-- whoever can change it — a member still gets to read them.
local lootMethod = GetLootMethod();
dropdownMenu.selectedLootMethod = UnitLootMethod[lootMethod].text;
dropdownMenu.selectedLootThreshold = getglobal("ITEM_QUALITY" .. GetLootThreshold() .. "_DESC");
local methodRow, thresholdRow = UnitPopupButtons["LOOT_METHOD"], UnitPopupButtons["LOOT_THRESHOLD"];
methodRow.text = dropdownMenu.selectedLootMethod;
thresholdRow.text = dropdownMenu.selectedLootThreshold;
local leads = ((GetNumPartyMembers() > 0) or (GetNumRaidMembers() > 0)) and IsPartyLeader();
methodRow.nested = leads and 1 or nil;
thresholdRow.nested = leads and 1 or nil;
-- A level-2 open is a submenu of the row that owns it: the value the kit hands back
-- names both the menu and the owner. Every row in it is shown — the hide pass ran
-- against the parent menu.
if ( UIDROPDOWNMENU_MENU_LEVEL == 2 ) then
local submenu = UIDROPDOWNMENU_MENU_VALUE;
dropdownMenu.which = submenu;
rememberOpenMenu(UIDROPDOWNMENU_MENU_LEVEL, dropdownMenu);
for index, value in ipairs(UnitPopupMenus[submenu]) do
local info = rowInfo(value);
info.owner = submenu;
-- The live loot method and threshold tick themselves; a mark row ticks when
-- the unit already wears it — a mark's POSITION in the icon menu is its
-- number, which is why the row index is what gets compared.
if ( info.text == dropdownMenu.selectedLootMethod
or info.text == dropdownMenu.selectedLootThreshold ) then
info.checked = 1;
elseif ( isRaidMarkRow(value) and GetRaidTargetIndex(unit) == index ) then
info.checked = 1;
end
UIDropDownMenu_AddButton(info, UIDROPDOWNMENU_MENU_LEVEL);
end
return;
end
-- The unit's name titles the menu.
if ( unit or name ) then
UIDropDownMenu_AddButton({
text = name or TEXT(UNKNOWN),
isTitle = 1,
notCheckable = 1,
});
end
rememberOpenMenu(UIDROPDOWNMENU_MENU_LEVEL, dropdownMenu);
for index, value in ipairs(UnitPopupMenus[which]) do
if ( UnitPopupShown[index] == 1 ) then
local info, row = rowInfo(value);
info.owner = which;
if ( not row.checkable ) then
info.notCheckable = 1;
end
if ( row.nested ) then
info.hasArrow = 1;
end
-- The threshold row wears the live threshold's own quality colour.
if ( value == "LOOT_THRESHOLD" ) then
applyTextColor(info, ITEM_QUALITY_COLORS[GetLootThreshold()]);
end
if ( isRaidMarkRow(value) and GetRaidTargetIndex("target") == index ) then
info.checked = 1;
end
UIDropDownMenu_AddButton(info);
end
end
PlaySound("igMainMenuOpen");
end
function UnitPopup_HideButtons()
local menu = getglobal(UIDROPDOWNMENU_INIT_MENU);
local party = partyState(menu);
for index, value in ipairs(UnitPopupMenus[menu.which]) do
local hide = hidePredicate(value);
-- The DEFERRED set is asked first, so nothing can resurrect one of its rows.
if ( BENILLA_UNITPOPUP_DEFERRED[value] or (hide and hide(menu, party)) ) then
UnitPopupShown[index] = 0;
else
UnitPopupShown[index] = 1;
end
end
end
-- ── Which shown rows are clickable right now ───────────────────────────────────────
-- The enable pass runs every frame over the open menus, so a row that can go grey owns a
-- predicate too: "grey this out?". Range is handled for every row at once, off the row's
-- own interact-distance class.
local GREY_ROW = { };
GREY_ROW["LEAVE"] = function(menu, party)
return party.inParty == 0;
end
-- In a group, only the leader or an assistant may bring someone else in.
GREY_ROW["INVITE"] = function(menu, party)
return party.inParty == 1 and party.isLeader == 0 and party.isAssistant == 0;
end
local function greyUnlessLeading(menu, party)
return party.inParty == 0 or party.isLeader == 0;
end
GREY_ROW["UNINVITE"] = greyUnlessLeading;
GREY_ROW["PROMOTE"] = greyUnlessLeading;
GREY_ROW["WHISPER"] = function(menu)
return menu.unit and not UnitIsConnected(menu.unit);
end
-- Ref UnitPopup.lua l.502-505: greyed while either party is dead or a ghost, or while we
-- lack full control (feared/charmed) — `HasFullControl` is nil-guarded like
-- CheckInteractDistance is below, because benilla models no loss of control yet, so the
-- clause is inert rather than an error. The row also carries dist = 3, so the shared
-- range leg gates it too.
GREY_ROW["DUEL"] = function(menu)
return UnitIsDeadOrGhost("player")
or (HasFullControl and not HasFullControl())
or UnitIsDeadOrGhost(menu.unit);
end
-- Only a leader under master loot can hand the job on, and never to its current holder.
GREY_ROW["LOOT_PROMOTE"] = function(menu, party)
local lootMethod, lootMaster = GetLootMethod();
if ( party.inParty == 0 or party.isLeader == 0 or lootMethod ~= "master" ) then
return true;
end
local master = "player";
if ( lootMaster and lootMaster ~= 0 ) then
master = "party" .. lootMaster;
end
return menu.unit and UnitIsUnit(menu.unit, master);
end
-- Out of interact range first (dist 0 means the row has no range at all), then whatever
-- the row itself says.
local function rowEnabled(menu, key, party)
local row = UnitPopupButtons[key];
if ( row.dist > 0 and CheckInteractDistance ) then
if ( not CheckInteractDistance(menu.unit, row.dist) ) then
return false;
end
end
local grey = GREY_ROW[key];
return not (grey and grey(menu, party));
end
-- Somebody else's dropdown is not ours to enable and disable.
local function ownsOpenMenu()
for _, frame in ipairs(UnitPopupFrames) do
if ( UIDROPDOWNMENU_OPEN_MENU == frame ) then
return true;
end
end
end
-- The per-frame enable/disable pass over every open unit-popup menu (driven by the
-- BenillaUnitPopupDriver below — the ref hooks it off UIParent's OnUpdate).
function UnitPopup_OnUpdate(elapsed)
if ( not DropDownList1:IsVisible() or not ownsOpenMenu() ) then
-- Nothing of ours to scan: put the driver back to sleep until the next
-- UnitPopup_ShowMenu (visible frames are the only ones ticked — tick.rs — so
-- this stops the per-frame pass entirely; decision 1396's class).
BenillaUnitPopupDriver:Hide();
return;
end
local party = partyState();
for level, menu in pairs(OPEN_DROPDOWNMENUS) do
if ( menu ) then
-- The kit numbers buttons by SHOWN position, so only shown rows advance the
-- count; the +1 steps over the title the level-1 menu puts at button 1. (The
-- reference offsets every level alike, level 2's title-less lists included.)
local count = 0;
for index, value in ipairs(UnitPopupMenus[menu.which]) do
if ( UnitPopupShown[index] == 1 ) then
count = count + 1;
if ( rowEnabled(menu, value, party) ) then
UIDropDownMenu_EnableButton(level, count + 1);
else
UIDropDownMenu_DisableButton(level, count + 1);
end
end
end
end
end
end
-- ── What a row does when it is clicked ─────────────────────────────────────────────
-- One handler per row, keyed by the row's own name. A row with no handler does nothing —
-- which is how Cancel, the nested parent rows, and every DEFERRED row are expressed
-- (header: TARGET, RESET_INSTANCES).
local CLICK_ROW = { };
CLICK_ROW["WHISPER"] = function(menu)
ChatFrame_SendTell(menu.name);
end
-- A targeted invite goes by token; a chat name or /who row has only the name.
CLICK_ROW["INVITE"] = function(menu)
if ( menu.unit ) then
InviteToParty(menu.unit);
else
InviteByName(menu.name);
end
end
CLICK_ROW["UNINVITE"] = function(menu)
UninviteFromParty(menu.unit);
end
CLICK_ROW["PROMOTE"] = function(menu)
PromoteToPartyLeader(menu.unit);
end
CLICK_ROW["LEAVE"] = function()
LeaveParty();
end
-- Picking a loot method sets it and writes the choice back onto the parent row — list 1,
-- button 2, the fixed place LOOT_METHOD sits in the SELF menu. Master loot is the one
-- method that also names who holds it.
for rowKey, method in pairs(LOOT_METHOD_TOKENS) do
CLICK_ROW[rowKey] = function(menu)
if ( method == "master" ) then
SetLootMethod(method, menu.name);
else
SetLootMethod(method);
end
UIDropDownMenu_SetButtonText(1, 2, UnitPopupButtons[rowKey].text);
UIDropDownMenu_Refresh(menu, nil, 1);
end
end
CLICK_ROW["LOOT_PROMOTE"] = function(menu)
SetLootMethod("master", menu.name);
end
-- The threshold rows start at uncommon, so the clicked row's own id is one below the
-- quality it sets; the parent row (list 1, button 3) takes that quality's colour.
local function chooseLootThreshold(menu, key, clicked)
local quality = clicked:GetID() + 1;
SetLootThreshold(quality);
local color = ITEM_QUALITY_COLORS[quality];
UIDropDownMenu_SetButtonText(1, 3, UnitPopupButtons[key].text, color.r, color.g, color.b);
end
CLICK_ROW["ITEM_QUALITY2_DESC"] = chooseLootThreshold;
CLICK_ROW["ITEM_QUALITY3_DESC"] = chooseLootThreshold;
CLICK_ROW["ITEM_QUALITY4_DESC"] = chooseLootThreshold;
-- Every row of the icon submenu marks the unit; None clears it (mark 0). Registering
-- against that menu's own membership is also what keeps the parent RAID_TARGET_ICON row
-- out of it — it opens the submenu, it does not mark.
local function setRaidMark(menu, key)
local mark = string.sub(key, 13);
if ( mark == "NONE" ) then
mark = 0;
end
SetRaidTargetIcon(menu.unit, tonumber(mark));
end
for _, key in ipairs(UnitPopupMenus["RAID_TARGET_ICON"]) do
CLICK_ROW[key] = setRaidMark;
end
-- Challenge to a duel (decision 0633): the app resolves the unit token → player guid →
-- a cast of the duel spell (ref UnitPopup_OnClick l.559, StartDuelUnit).
CLICK_ROW["DUEL"] = function(menu)
StartDuelUnit(menu.unit);
end
-- Offer to trade (decision 0592 P1): the app resolves the unit token → player guid →
-- CMSG_INITIATE_TRADE.
CLICK_ROW["TRADE"] = function(menu)
InitiateTrade(menu.unit);
end
-- Open the inspect window (decision 0631) — InspectUnit gates on CanInspect and sends
-- CMSG_INSPECT.
CLICK_ROW["INSPECT"] = function(menu)
InspectUnit(menu.unit);
end
-- Begin auto-following, by NAME and EXACT. That `1` is the resolver's exact-only flag,
-- and it matters: this menu already knows the unit's name to the letter, so unlike
-- `/follow rag` it must not prefix-match its way onto a bystander. Client-side movement
-- only, no wire.
CLICK_ROW["FOLLOW"] = function(menu)
FollowByName(menu.name, 1);
end
-- The four PET rows (decision 1066). The asymmetry is the reference's and it is the right
-- one: DISMISS runs immediately, ABANDON goes behind a confirm. Sending a summon away
-- costs nothing; giving up a tamed pet is permanent, and the server deletes it
-- (`PET_SAVE_AS_DELETED`).
CLICK_ROW["PET_DISMISS"] = function()
PetDismiss();
end
CLICK_ROW["PET_ABANDON"] = function()
StaticPopup_Show("ABANDON_PET");
end
CLICK_ROW["PET_PAPERDOLL"] = function()
ToggleCharacter("PetPaperDollFrame");
end
CLICK_ROW["PET_RENAME"] = function()
StaticPopup_Show("RENAME_PET");
end
-- The two GUILD arms, ref UnitPopup_OnClick l.571-575 (decision 1257). Both go behind a
-- confirm, and both are addressed by NAME — a guild roster row has no unit token behind
-- it, which is the reason this menu takes a name at all.
--
-- GUILD_PROMOTE stashes the name on the DIALOG (`dialog.data`), not on a global: the
-- popup engine hands `data` back to OnAccept, so the dialog acts on the name that was
-- right-clicked even if the selection moved while it was open. GUILD_LEAVE needs no
-- data — it is only ever offered on yourself — and formats the guild's own name into
-- "Really leave %s?". The `if dialog` is ours, and it is not decoration: the ref writes
-- `dialog.data` on the bare return because its engine runs FOUR dialog instances and
-- ours runs two (UiPanels.xml's header), so a refused Show returning nil is reachable
-- here and indexing it would raise inside a menu click.
CLICK_ROW["GUILD_PROMOTE"] = function(menu)
local dialog = StaticPopup_Show("CONFIRM_GUILD_PROMOTE", menu.name);
if ( dialog ) then
dialog.data = menu.name;
end
end
CLICK_ROW["GUILD_LEAVE"] = function()
StaticPopup_Show("CONFIRM_GUILD_LEAVE", GetGuildInfo("player"));
end
-- The four raid rank verbs (ref UnitPopup_OnClick l.624-631). Three go by NAME and one by
-- the row index — the reference's own split, and it is the wire's: `CMSG_GROUP_UNINVITE`
-- would take either, but the assistant and leader opcodes need a guid the engine resolves
-- from a name, and the menu never had one to hand for a raid row.
CLICK_ROW["RAID_LEADER"] = function(menu)
PromoteByName(menu.name);
end
CLICK_ROW["RAID_PROMOTE"] = function(menu)
PromoteToAssistant(menu.name);
end
CLICK_ROW["RAID_DEMOTE"] = function(menu)
DemoteAssistant(menu.name);
end
CLICK_ROW["RAID_REMOVE"] = function(menu)
UninviteFromRaid(menu.userData);
end
function UnitPopup_OnClick()
local clicked = this;
local key = clicked.value;
local menu = getglobal(UIDROPDOWNMENU_INIT_MENU);
local click = CLICK_ROW[key];
if ( click ) then
click(menu, key, clicked);
end
PlaySound("UChatScrollButton");
end
]]></Script>
<!-- The OnUpdate driver (ref UIParent.xml l.21 runs UnitPopup_OnUpdate from UIParent's own
OnUpdate; ours has none — the DeathFrame hidden-driver pattern instead). Awake only while
a unit menu of ours is open: UnitPopup_ShowMenu shows it, its own early-out hides it, and
a hidden frame is never ticked (tick.rs). It ships shown so the first tick runs the
early-out once and parks it. -->
<Frame name="BenillaUnitPopupDriver" parent="UIParent" hidden="false">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnUpdate>UnitPopup_OnUpdate(arg1)</OnUpdate>
</Scripts>
</Frame>
</Ui>