diff --git a/AGENTS.md b/AGENTS.md index 035ae57..d76badb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,7 @@ The repository is structured as a multi-addon project separating core logic, con | frameSpacing | number | 4 | | frameMinHeight | number | 68 | | compactTextLayout | boolean | false | +| reverseButtonOrder | boolean | false | | iconPosition | string | "inside" | | iconSide | string | "left" | | iconOffsetX | number | 0 | @@ -95,7 +96,7 @@ The repository is structured as a multi-addon project separating core logic, con | timerBarStyle | string | "normal" | | timerBarMinimalHeight | number | 3 | -`confirmGreedAndPass` asks for confirmation before Greed or Pass is submitted. `resultLingerDuration` applies only when `keepOpenAfterVote` is enabled. `showRollTally` is available only on Classic (TBC/MoP); Retail removed `C_LootHistory.GetItem` and `GetPlayerInfo` in patch 10.1.0. +`confirmGreedAndPass` asks for confirmation before Greed or Pass is submitted. `reverseButtonOrder` reverses the roll action button order. `resultLingerDuration` applies only when `keepOpenAfterVote` is enabled. `showRollTally` is available only on Classic (TBC/MoP); Retail removed `C_LootHistory.GetItem` and `GetPlayerInfo` in patch 10.1.0. ### History (`db.profile.history`) diff --git a/DragonLoot/Core/Config.lua b/DragonLoot/Core/Config.lua index e2c19c4..2341247 100644 --- a/DragonLoot/Core/Config.lua +++ b/DragonLoot/Core/Config.lua @@ -57,6 +57,7 @@ local defaults = { frameSpacing = 4, frameMinHeight = 68, compactTextLayout = false, + reverseButtonOrder = false, iconPosition = "inside", iconSide = "left", iconOffsetX = 0, @@ -145,7 +146,7 @@ local defaults = { -- Profile Migration ------------------------------------------------------------------------------- -local CURRENT_SCHEMA = 5 +local CURRENT_SCHEMA = 6 local function DeepCopyValue(value) if type(value) ~= "table" then diff --git a/DragonLoot/Display/RollFrame.lua b/DragonLoot/Display/RollFrame.lua index babb543..1f1959d 100755 --- a/DragonLoot/Display/RollFrame.lua +++ b/DragonLoot/Display/RollFrame.lua @@ -294,26 +294,28 @@ local function CalculateFrameHeight(iconSize, frame) return math_max(GetFrameMinHeight(), effectiveIconSize + ROLL_FRAME_EXTRA_HEIGHT) + tallyHeight end +local function GetRollActionButtons(frame) + local middleButton = frame.transmogButton:IsShown() and frame.transmogButton or frame.greedButton + local rollFrameDB = GetRollFrameDB() + if rollFrameDB and rollFrameDB.reverseButtonOrder then + return { frame.passButton, frame.disenchantButton, middleButton, frame.needButton } + end + return { frame.needButton, middleButton, frame.disenchantButton, frame.passButton } +end + local function ApplyTextLayoutOffsets(frame, compact, iconSize, padding, borderSize, rowSpacing) local contentLeftInset = GetRollContentLeftInset(iconSize, padding, borderSize) + local leftmostButton = frame.rollActionButtons[1] + local rightmostButton = frame.rollActionButtons[#frame.rollActionButtons] -- Item name top-left anchor (shared by both modes) frame.itemName:ClearAllPoints() frame.itemName:SetPoint("TOPLEFT", frame, "TOPLEFT", contentLeftInset, -(padding + borderSize)) if compact then -- Compact: buttons sit on the same row as the item name - frame.passButton:ClearAllPoints() - frame.passButton:SetPoint( - "RIGHT", - frame, - "RIGHT", - -(GetRollContentRightInset(iconSize, padding, borderSize)), - 0 - ) - frame.passButton:SetPoint("TOP", frame, "TOP", 0, -(padding + borderSize)) - - -- needButton is always the leftmost button (transmog occupies greed's slot to the right). - local leftmostButton = frame.needButton + rightmostButton:ClearAllPoints() + rightmostButton:SetPoint("RIGHT", frame, "RIGHT", -(GetRollContentRightInset(iconSize, padding, borderSize)), 0) + rightmostButton:SetPoint("TOP", frame, "TOP", 0, -(padding + borderSize)) if frame.bindText:IsShown() then -- bindText sits to the left of the buttons @@ -334,8 +336,8 @@ local function ApplyTextLayoutOffsets(frame, compact, iconSize, padding, borderS frame.bindText:ClearAllPoints() frame.bindText:SetPoint("TOPLEFT", frame.itemName, "BOTTOMLEFT", 0, -rowSpacing) - frame.passButton:ClearAllPoints() - frame.passButton:SetPoint("TOPRIGHT", frame.itemName, "BOTTOMRIGHT", 0, -rowSpacing) + rightmostButton:ClearAllPoints() + rightmostButton:SetPoint("TOPRIGHT", frame.itemName, "BOTTOMRIGHT", 0, -rowSpacing) end end @@ -792,6 +794,8 @@ local function CreateRollFrame(index) end frame.transmogButton:Hide() + frame.rollActionButtons = GetRollActionButtons(frame) + frame.frameIndex = index frame.tallyRowHeight = 0 return frame @@ -901,37 +905,17 @@ local function BuildTestRollData(testEntry) } end -------------------------------------------------------------------------------- --- Re-anchor the roll button chain based on current greed/transmog visibility. --- When transmog is shown (greed's slot), the chain is: --- need <- transmog <- disenchant <- pass --- When greed is shown (normal), the chain is: --- need <- greed <- disenchant <- pass -------------------------------------------------------------------------------- - local function RebuildButtonChain(frame) local btnSpacing = GetButtonSpacing() - -- disenchant always anchors off pass - frame.disenchantButton:ClearAllPoints() - frame.disenchantButton:SetPoint("RIGHT", frame.passButton, "LEFT", -btnSpacing, 0) - - -- greed/transmog share the slot between disenchant and need - -- transmogButton is always created in CreateRollFrame; nil guard is unnecessary. - -- IsShown() (not IsVisible()) is intentional: parent frame may be hidden, - -- but we need to know whether transmog was set for this roll's data. - if frame.transmogButton:IsShown() then - -- transmog occupies greed's slot - frame.transmogButton:ClearAllPoints() - frame.transmogButton:SetPoint("RIGHT", frame.disenchantButton, "LEFT", -btnSpacing, 0) - frame.needButton:ClearAllPoints() - frame.needButton:SetPoint("RIGHT", frame.transmogButton, "LEFT", -btnSpacing, 0) - else - -- greed in its normal slot - frame.greedButton:ClearAllPoints() - frame.greedButton:SetPoint("RIGHT", frame.disenchantButton, "LEFT", -btnSpacing, 0) - frame.needButton:ClearAllPoints() - frame.needButton:SetPoint("RIGHT", frame.greedButton, "LEFT", -btnSpacing, 0) + local buttons = GetRollActionButtons(frame) + + for _, button in ipairs(buttons) do + button:ClearAllPoints() + end + for index = #buttons - 1, 1, -1 do + buttons[index]:SetPoint("RIGHT", buttons[index + 1], "LEFT", -btnSpacing, 0) end + frame.rollActionButtons = buttons end ------------------------------------------------------------------------------- diff --git a/DragonLoot/Locales/enUS.lua b/DragonLoot/Locales/enUS.lua index 5302e95..2d5c248 100755 --- a/DragonLoot/Locales/enUS.lua +++ b/DragonLoot/Locales/enUS.lua @@ -211,6 +211,8 @@ L["Minimum Quality"] = true L["Prevent the roll frame from being dragged"] = true L["Replace the default Blizzard roll frame with DragonLoot's custom version"] = true L["Result Delay"] = true +L["Reverse Button Order"] = true +L["Reverse the complete roll button order so Pass is on the left and Need is on the right"] = true L["Roll Frame"] = true L["Roll Notifications"] = true L["Roll frame scale"] = true diff --git a/DragonLoot_Options/Tabs/LootRollTab.lua b/DragonLoot_Options/Tabs/LootRollTab.lua index b1b3945..728b4ca 100755 --- a/DragonLoot_Options/Tabs/LootRollTab.lua +++ b/DragonLoot_Options/Tabs/LootRollTab.lua @@ -639,6 +639,20 @@ local function CreateButtonsSection(parent, db, yOffset, layoutWidgets) local content = section.content local innerY = -LC.SECTION_PADDING_TOP + local reverseOrderToggle = W.CreateToggle(content, { + label = L["Reverse Button Order"], + tooltip = L["Reverse the complete roll button order so Pass is on the left and Need is on the right"], + get = function() + return db.profile.rollFrame.reverseButtonOrder + end, + set = function(value) + db.profile.rollFrame.reverseButtonOrder = value + NotifyRollManager() + end, + }) + layoutWidgets[#layoutWidgets + 1] = reverseOrderToggle + innerY = LC.AnchorWidget(reverseOrderToggle, content, innerY) - LC.SPACING_BETWEEN_WIDGETS + innerY = CreateLayoutSlider( content, db, diff --git a/spec/Config_spec.lua b/spec/Config_spec.lua index 7eaba1a..763993b 100644 --- a/spec/Config_spec.lua +++ b/spec/Config_spec.lua @@ -41,7 +41,7 @@ describe("Config", function() local db = initWithSeed(ns, nil) -- Keep in sync with CURRENT_SCHEMA in DragonLoot/Core/Config.lua - assert.are.equal(5, db.profile.schemaVersion) + assert.are.equal(6, db.profile.schemaVersion) end) it("has lootIconSize in a fresh profile", function() @@ -80,6 +80,12 @@ describe("Config", function() assert.is_false(db.profile.rollFrame.showRollTally) end) + + it("defaults rollFrame.reverseButtonOrder to false", function() + local db = initWithSeed(ns, nil) + + assert.is_false(db.profile.rollFrame.reverseButtonOrder) + end) end) --------------------------------------------------------------------------- @@ -215,6 +221,28 @@ describe("Config", function() assert.is_true(db.profile.rollFrame.showRollTally) end) + + it("back-fills missing rollFrame.reverseButtonOrder with false", function() + local db = initWithSeed(ns, { + schemaVersion = 5, + rollFrame = { + enabled = true, + }, + }) + + assert.is_false(db.profile.rollFrame.reverseButtonOrder) + end) + + it("preserves an opted-in rollFrame.reverseButtonOrder", function() + local db = initWithSeed(ns, { + schemaVersion = 5, + rollFrame = { + reverseButtonOrder = true, + }, + }) + + assert.is_true(db.profile.rollFrame.reverseButtonOrder) + end) end) --------------------------------------------------------------------------- @@ -276,7 +304,7 @@ describe("Config", function() -- Seed at current schema so FillMissingDefaults is skipped and the -- (unconditional) iconSize-split migration can propagate iconSize=48. -- Keep in sync with CURRENT_SCHEMA in DragonLoot/Core/Config.lua. - schemaVersion = 5, + schemaVersion = 6, appearance = { iconSize = 48, -- lootIconSize intentionally absent to test migration propagation @@ -300,7 +328,7 @@ describe("Config", function() }) -- Keep in sync with CURRENT_SCHEMA in DragonLoot/Core/Config.lua - assert.are.equal(5, db.profile.schemaVersion) + assert.are.equal(6, db.profile.schemaVersion) end) end) end) diff --git a/spec/RollButtonOrder_spec.lua b/spec/RollButtonOrder_spec.lua new file mode 100644 index 0000000..afef41e --- /dev/null +++ b/spec/RollButtonOrder_spec.lua @@ -0,0 +1,360 @@ +------------------------------------------------------------------------------- +-- RollButtonOrder_spec.lua +-- Tests for configurable roll-action button ordering. +------------------------------------------------------------------------------- + +local mock = require("spec.wow_mock") + +local ROLL_PASS = 0 +local ROLL_NEED = 1 +local ROLL_GREED = 2 +local ROLL_DISENCHANT = 3 +local ROLL_TRANSMOG = 4 + +local function OverrideGlobal(name, value) + local hadValue = rawget(_G, name) ~= nil + local originalValue = rawget(_G, name) + rawset(_G, name, value) + return function() + if hadValue then + rawset(_G, name, originalValue) + else + rawset(_G, name, nil) + end + end +end + +local function NewWidget(parent, created) + local widget = { + _parent = parent, + _points = {}, + _shown = true, + _height = 0, + _scripts = {}, + } + + function widget:SetPoint(...) + self._points[#self._points + 1] = { ... } + end + function widget:ClearAllPoints() + self._points = {} + end + function widget:SetSize(_, height) + self._height = height + end + function widget:SetHeight(height) + self._height = height + end + function widget:GetHeight() + return self._height + end + function widget:Show() + self._shown = true + end + function widget:Hide() + self._shown = false + end + function widget:IsShown() + return self._shown + end + function widget:GetParent() + return self._parent + end + function widget:SetScript(script, handler) + self._scripts[script] = handler + end + function widget:GetScript(script) + return self._scripts[script] + end + function widget:CreateTexture() + return NewWidget(self, created) + end + function widget:CreateFontString() + return NewWidget(self, created) + end + function widget:SetHighlightTexture() + self._highlight = self._highlight or NewWidget(self, created) + end + function widget:SetHighlightAtlas() + self._highlight = self._highlight or NewWidget(self, created) + end + function widget:GetHighlightTexture() + return self._highlight + end + + local noOpMethods = { + "Disable", + "Enable", + "EnableMouse", + "RegisterForClicks", + "RegisterForDrag", + "SetAllPoints", + "SetAlpha", + "SetAtlas", + "SetBackdrop", + "SetBackdropBorderColor", + "SetClampedToScreen", + "SetColorTexture", + "SetDesaturated", + "SetDrawLayer", + "SetFont", + "SetFrameLevel", + "SetFrameStrata", + "SetHighlightAtlas", + "SetJustifyH", + "SetMinMaxValues", + "SetMovable", + "SetScale", + "SetShadowColor", + "SetShadowOffset", + "SetStatusBarColor", + "SetStatusBarTexture", + "SetTexCoord", + "SetText", + "SetTextColor", + "SetTexture", + "SetValue", + "SetWidth", + "SetWordWrap", + } + for _, method in ipairs(noOpMethods) do + if not widget[method] then + widget[method] = function() end + end + end + + created[#created + 1] = widget + return widget +end + +local function NewRollFrameNamespace(settings, rollData) + local created = {} + local selections = {} + local restoreCreateFrame = OverrideGlobal("CreateFrame", function(_, _, parent) + return NewWidget(parent, created) + end) + local restoreGetLootRollItemInfo = OverrideGlobal("GetLootRollItemInfo", function() + return 12345, + "Test Item", + 1, + 4, + false, + rollData.canNeed, + rollData.canGreed, + rollData.canDisenchant, + nil, + nil, + nil, + nil, + rollData.canTransmog + end) + local originalLibStub = rawget(_G, "LibStub") + local restoreLibStub = OverrideGlobal("LibStub", function(name) + if name == "LibSharedMedia-3.0" then + return { + Fetch = function(_, _, value) + return value + end, + } + end + return originalLibStub(name) + end) + + local ns = mock.CreateNamespace() + ns.L = setmetatable({}, { + __index = function(_, key) + return key + end, + }) + ns.DisplayUtils = { + WHITE8x8 = "white", + ApplyBackdrop = function() end, + ApplyFontShadow = function() end, + GetFont = function() + return "font", 12, "" + end, + GetQualityColor = function() + return 1, 1, 1 + end, + } + ns.RollAnimations = { + PlayShow = function() end, + PlayHide = function(_, onComplete) + onComplete() + end, + StopAll = function() end, + } + ns.RollManager = { + GetActiveRolls = function() + return {} + end, + RequestRollSelection = function(rollID, rollType) + selections[#selections + 1] = { rollID = rollID, rollType = rollType } + end, + } + ns.RollTypeNames = {} + + local rollFrameSettings = { + enabled = true, + scale = 1, + reverseButtonOrder = false, + compactTextLayout = false, + frameWidth = 328, + frameMinHeight = 68, + buttonSize = 24, + buttonSpacing = 4, + contentPadding = 4, + rowSpacing = 4, + timerBarSpacing = 4, + timerBarHeight = 12, + timerBarTexture = "Blizzard", + timerBarStyle = "normal", + timerBarMinimalHeight = 3, + iconPosition = "inside", + iconSide = "left", + } + for key, value in pairs(settings or {}) do + rollFrameSettings[key] = value + end + ns.Addon = { + db = { + profile = { + appearance = { + borderSize = 1, + qualityBorder = true, + rollIconSize = 36, + showItemLevel = false, + }, + rollFrame = rollFrameSettings, + }, + }, + } + + mock.LoadFile(ns, "DragonLoot/Display/RollFrame.lua") + restoreCreateFrame() + restoreGetLootRollItemInfo() + restoreLibStub() + + ns.RollFrame.Initialize() + ns.RollFrame.ShowRoll(1, 77) + + local buttons = {} + for _, widget in ipairs(created) do + if widget.rollType ~= nil then + buttons[widget.rollType] = widget + end + end + return ns, buttons, selections +end + +local function GetVisibleOrder(buttons) + local visible = {} + local target = {} + for rollType, button in pairs(buttons) do + if button:IsShown() then + visible[rollType] = button + for _, point in ipairs(button._points) do + local relative = point[2] + if relative and relative.rollType ~= nil then + target[rollType] = relative.rollType + end + end + end + end + + local isTarget = {} + for _, rollType in pairs(target) do + isTarget[rollType] = true + end + local current + for rollType in pairs(visible) do + if not isTarget[rollType] then + current = rollType + break + end + end + + local order = {} + while current ~= nil do + order[#order + 1] = current + current = target[current] + end + return order +end + +describe("Roll action button order", function() + local standardRoll = { + canNeed = true, + canGreed = true, + canDisenchant = true, + canTransmog = false, + } + + before_each(function() + mock.Reset() + end) + + it("preserves the default order in normal layout", function() + local _, buttons = NewRollFrameNamespace({}, standardRoll) + + assert.are.same({ ROLL_NEED, ROLL_GREED, ROLL_DISENCHANT, ROLL_PASS }, GetVisibleOrder(buttons)) + end) + + it("reverses the complete order in normal layout", function() + local _, buttons = NewRollFrameNamespace({ reverseButtonOrder = true }, standardRoll) + + assert.are.same({ ROLL_PASS, ROLL_DISENCHANT, ROLL_GREED, ROLL_NEED }, GetVisibleOrder(buttons)) + end) + + it("reverses the complete order in compact layout", function() + local _, buttons = NewRollFrameNamespace({ + reverseButtonOrder = true, + compactTextLayout = true, + }, standardRoll) + + assert.are.same({ ROLL_PASS, ROLL_DISENCHANT, ROLL_GREED, ROLL_NEED }, GetVisibleOrder(buttons)) + end) + + it("reverses visible Transmog while leaving hidden Greed out", function() + local _, buttons, selections = NewRollFrameNamespace({ reverseButtonOrder = true }, { + canNeed = true, + canGreed = false, + canDisenchant = true, + canTransmog = true, + }) + + assert.is_false(buttons[ROLL_GREED]:IsShown()) + assert.are.same({ ROLL_PASS, ROLL_DISENCHANT, ROLL_TRANSMOG, ROLL_NEED }, GetVisibleOrder(buttons)) + buttons[ROLL_TRANSMOG]:GetScript("OnClick")(buttons[ROLL_TRANSMOG]) + assert.are.same({ { rollID = 77, rollType = ROLL_TRANSMOG } }, selections) + end) + + it("keeps disabled actions in the complete visible sequence", function() + local _, buttons = NewRollFrameNamespace({ reverseButtonOrder = true }, { + canNeed = false, + canGreed = false, + canDisenchant = false, + canTransmog = false, + }) + + assert.are.same({ ROLL_PASS, ROLL_DISENCHANT, ROLL_GREED, ROLL_NEED }, GetVisibleOrder(buttons)) + end) + + it("refreshes a visible frame and preserves each button action", function() + local ns, buttons, selections = NewRollFrameNamespace({}, standardRoll) + ns.Addon.db.profile.rollFrame.reverseButtonOrder = true + + ns.RollFrame.ApplySettings() + for _, rollType in ipairs({ ROLL_PASS, ROLL_NEED, ROLL_GREED, ROLL_DISENCHANT }) do + buttons[rollType]:GetScript("OnClick")(buttons[rollType]) + end + + assert.are.same({ ROLL_PASS, ROLL_DISENCHANT, ROLL_GREED, ROLL_NEED }, GetVisibleOrder(buttons)) + assert.are.same({ + { rollID = 77, rollType = ROLL_PASS }, + { rollID = 77, rollType = ROLL_NEED }, + { rollID = 77, rollType = ROLL_GREED }, + { rollID = 77, rollType = ROLL_DISENCHANT }, + }, selections) + end) +end)