--[[-------------------------------------------------------------------------- Maniax CVar — the settings page, built for Blizzard's options panel. This replaces UI.lua's standalone 768x580 dialog. The controls are rendered natively into the Interface -> AddOns page instead of a window being reparented into it. WHY NOT REPARENT THE OLD WINDOW It was tried. A WoW frame does not clip to its parent, so a layout built for a 768-wide dialog drew over the category list and out past the options frame no matter where it was anchored, and it brought its own title bar, close button and Okay/Close row inside Blizzard's - two of each, one of which shut only half of what it appeared to. Moving a layout is not the same as building one for the space. WHAT IS REUSED Everything that is not layout. `ACVar.CVARS` in Constants.lua stays the single source of truth for which CVars exist, their types, ranges, defaults and mode lists; `ACVar:GetCVarValue` / `SetCVarValue` in Core.lua stay the only path to the client. This file renders that data and nothing more, so adding a CVar still means editing one table. WIDTHS ARE FOR THE STOCK PANEL Laid out to fit Blizzard's un-enlarged 648x520 frame - about 600 usable - rather than the 858x660 that client/FrameXML/InterfaceOptionsFrame.xml installs. The enlargement is a client-side file that a player may not have, and a settings page that only fits on some installs is the bug this replaced. Extra width becomes margin. Height is handled by a scroll frame per category, so the page never depends on the panel being tall enough. ONE BEHAVIOURAL FIX OVER UPSTREAM A CVar the client does not implement made `GetCVar` return nil, and upstream printed `tostring(nil)` into the slider readout - the literal word "nil", with a live-looking slider above it. Those are now marked as unavailable and their controls disabled, because a control that cannot do anything should not look like one that can. ----------------------------------------------------------------------------]] -- The addon PRIVATE table, exactly as Constants.lua and Core.lua take it. -- _G["AwesomeCVar"] exists but is an empty "public API" stub - reading ACVar -- from there yields a table with no CVARS and no methods, and every control -- silently builds against nothing. local addonName, ACVar = ... local L = ACVar.L or {} local CVARS = ACVar.CVARS local PANEL_W = 600 -- fits the stock 648-wide options frame local ROW_W = PANEL_W - 30 local TAB_TOP = -52 local BODY_TOP = -84 local controls = {} -- every widget, for one refresh path local pages = {} -- category -> scroll child local unsupported = {} -- cvar name -> true, when the client has no such CVar --------------------------------------------------------------------------- -- Helpers. --------------------------------------------------------------------------- local uid = 0 local function nextName(suffix) uid = uid + 1 return "ManiaxCVar" .. suffix .. uid end local function fmt(v) if v == nil then return "-" end if type(v) ~= "number" then return tostring(v) end if math.floor(v) == v then return tostring(math.floor(v)) end return string.format("%.2f", v) end -- A CVar this client does not implement returns nil from GetCVar. Asked once, -- at build time: the answer cannot change while the client is running. local function isSupported(name) return GetCVar(name) ~= nil end --------------------------------------------------------------------------- -- Controls. Each returns a frame carrying Refresh(), and each is appended to -- `controls` so there is exactly one refresh path. --------------------------------------------------------------------------- local function addResetButton(parent, def, onReset) if def.default == nil then return nil end local b = CreateFrame("Button", nextName("Reset"), parent, "UIPanelButtonTemplate") b:SetSize(110, 20) b:SetPoint("TOPRIGHT", -6, -6) -- L.RESET_TO is a format string ("Reset to %s") in all nine locales, so it -- has to be formatted, not concatenated onto. b:SetText(string.format(L.RESET_TO or "Reset to %s", fmt(def.default))) b:SetScript("OnClick", function() ACVar:SetCVarValue(def.name, def.default, def) ACVar:PrintCVarChange(def.name, def.default, def.label) onReset() ACVar:UpdateAllUI() end) return b end -- A FontString reports its WRAPPED height through GetHeight; GetStringHeight -- measures the string as if it were one line and ignores the wrap (LibQTip -- carries the same note). GetStringHeight is only the fallback for a string -- that has not been laid out yet. local function textHeight(fs) local h = fs:GetHeight() if not h or h < 1 then h = fs:GetStringHeight() end return h or 12 end -- Every control sits in a bordered block, which is what gives the page its -- shape once the dialog's own backdrop is gone. The height is set by the -- builder once the text is in, never guessed up front: DESC_INFO alone is 13 -- lines, and a constant 108 drew it straight through the block below. local function newBlock(parent, y) local b = CreateFrame("Frame", nil, parent) b:SetWidth(ROW_W) b:SetHeight(1) b:SetPoint("TOPLEFT", 8, y) b:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", tile = true, tileSize = 16, edgeSize = 12, insets = { left = 3, right = 3, top = 3, bottom = 3 }, }) b:SetBackdropColor(0, 0, 0, 0.25) b:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.8) return b end -- Returns the frame later controls anchor to, and how much block height the -- title (plus description, if any) has already consumed. local function blockTitle(block, def) local fs = block:CreateFontString(nil, "OVERLAY", "GameFontNormal") fs:SetPoint("TOPLEFT", 12, -10) fs:SetText(def.label or def.name) fs:SetTextColor(1, 0.82, 0) if not isSupported(def.name) then unsupported[def.name] = true local warn = block:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") warn:SetPoint("LEFT", fs, "RIGHT", 8, 0) warn:SetText("|cffff6060" .. (L.NOT_SUPPORTED or "not on this client") .. "|r") end local used = 10 + textHeight(fs) if def.desc then local d = block:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") d:SetPoint("TOPLEFT", fs, "BOTTOMLEFT", 0, -3) d:SetWidth(ROW_W - 140) d:SetJustifyH("LEFT") d:SetText(def.desc) return d, used + 3 + textHeight(d) end return fs, used end local function makeSlider(parent, def, y) local step = def.step or 1 local block = newBlock(parent, y) local anchor, used = blockTitle(block, def) -- 18 gap + 17 slider + 10 readout + 8 padding. local rows = used + 53 block:SetHeight(rows) local name = nextName("Slider") local s = CreateFrame("Slider", name, block, "OptionsSliderTemplate") s:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 6, -18) s:SetWidth(ROW_W - 60) s:SetMinMaxValues(def.min or 0, def.max or 1) s:SetValueStep(step) _G[name .. "Text"]:SetText("") _G[name .. "Low"]:SetText(fmt(def.min)) _G[name .. "High"]:SetText(fmt(def.max)) local readout = s:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") readout:SetPoint("TOP", s, "BOTTOM", 0, 2) local reset = addResetButton(block, def, function() end) s:SetScript("OnValueChanged", function(self, value) local snapped = math.floor((value / step) + 0.5) * step readout:SetText(fmt(snapped)) if self.loading then return end if ACVar:GetCVarValue(def.name) ~= snapped then ACVar:SetCVarValue(def.name, snapped, def) ACVar:PrintCVarChange(def.name, snapped, def.label) if reset then if snapped == def.default then reset:Disable() else reset:Enable() end end end end) block.Refresh = function() local v = ACVar:GetCVarValue(def.name) s.loading = true if v == nil then -- Disabled rather than showing a live slider over the word "nil". s:SetValue(def.min or 0) readout:SetText("|cff808080" .. (L.NOT_SUPPORTED or "not on this client") .. "|r") s:Disable() if reset then reset:Disable() end else s:Enable() s:SetValue(v) readout:SetText(fmt(v)) if reset then if v == def.default then reset:Disable() else reset:Enable() end end end s.loading = false end controls[#controls + 1] = block return rows + 8 end local function makeToggle(parent, def, y) local block = newBlock(parent, y) local anchor, used = blockTitle(block, def) local cbName = nextName("Check") local cb = CreateFrame("CheckButton", cbName, block, "InterfaceOptionsCheckButtonTemplate") cb:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 4, -6) local cbH = cb:GetHeight() if not cbH or cbH < 1 then cbH = 26 end local rows = used + 6 + cbH + 10 block:SetHeight(rows) local txt = _G[cbName .. "Text"] if txt then txt:SetText(L.ENABLED or "Enabled") end cb:SetScript("OnClick", function(self) local v = self:GetChecked() and (def.max or 1) or (def.min or 0) ACVar:SetCVarValue(def.name, v, def) ACVar:PrintCVarChange(def.name, v, def.label) end) block.Refresh = function() local v = ACVar:GetCVarValue(def.name) if v == nil then cb:SetChecked(false) cb:Disable() else cb:Enable() cb:SetChecked(v == (def.max or 1)) end end controls[#controls + 1] = block return rows + 8 end -- Radio group. Blizzard has no radio template on 3.3.5a, so these are check -- buttons the group keeps mutually exclusive - which is what upstream does -- too, and what the screenshots show. local function makeMode(parent, def, y) local n = #(def.modes or {}) local block = newBlock(parent, y) local anchor, used = blockTitle(block, def) -- 6 gap, then one 16px radio every 22px, then 12 padding. local rows = used + n * 22 + 12 block:SetHeight(rows) local buttons = {} for i, mode in ipairs(def.modes or {}) do local rbName = nextName("Radio") local rb = CreateFrame("CheckButton", rbName, block, "UIRadioButtonTemplate") rb:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 6, -6 - (i - 1) * 22) local label = block:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") label:SetPoint("LEFT", rb, "RIGHT", 4, 0) label:SetText(mode.label) rb:SetScript("OnClick", function() ACVar:SetCVarValue(def.name, mode.value, def) ACVar:PrintCVarChange(def.name, mode.value, mode.label) for j, other in ipairs(buttons) do other:SetChecked(j == i) end end) buttons[i] = rb end addResetButton(block, def, function() end) block.Refresh = function() local v = ACVar:GetCVarValue(def.name) for i, mode in ipairs(def.modes or {}) do buttons[i]:SetChecked(v ~= nil and v == mode.value) if v == nil then buttons[i]:Disable() else buttons[i]:Enable() end end end controls[#controls + 1] = block return rows + 8 end local function makeDescription(parent, def, y) local block = newBlock(parent, y) local fs = block:CreateFontString(nil, "OVERLAY", "GameFontNormal") fs:SetPoint("TOPLEFT", 12, -10) fs:SetText(def.label or "") fs:SetTextColor(1, 0.82, 0) local d = block:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") d:SetPoint("TOPLEFT", fs, "BOTTOMLEFT", 0, -6) d:SetWidth(ROW_W - 30) d:SetJustifyH("LEFT") d:SetText(def.desc or "") local rows = 10 + textHeight(fs) + 6 + textHeight(d) + 12 block:SetHeight(rows) block.Refresh = function() end controls[#controls + 1] = block return rows + 8 end local BUILDERS = { slider = makeSlider, toggle = makeToggle, mode = makeMode, description = makeDescription, } --------------------------------------------------------------------------- -- The page. --------------------------------------------------------------------------- function ACVar:CreateMainFrame() local panel = CreateFrame("Frame", "ManiaxCVarPanel", UIParent) -- Hidden on purpose. InterfaceOptionsList_DisplayPanel reparents the panel -- and calls Show(); on a frame that was never hidden that is a no-op, OnShow -- does not fire, and selectTab(1) below never runs - so the first open of the -- session showed tabs with no page under them. panel:Hide() panel.name = L.ADDON_NAME_SHORT or addonName self.Panel = panel local title = panel:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge") title:SetPoint("TOPLEFT", 16, -16) title:SetText(panel.name) local subtitle = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") subtitle:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4) subtitle:SetText(L.ADDON_NOTES or "Client CVars, grouped.") -- Stable tab order. pairs() over CVARS would reorder the tabs between -- sessions, which makes a settings page feel broken even when it is not. local order = { L.CATEGORY_NAMEPLATES, L.CATEGORY_CAMERA, L.CATEGORY_INTERACTION, L.CATEGORY_OTHER } local tabs = {} local function selectTab(index) for i, cat in ipairs(order) do local p = pages[cat] if p then if i == index then p:GetParent():Show() else p:GetParent():Hide() end end end PanelTemplates_SetTab(panel, index) end for i, cat in ipairs(order) do local defs = CVARS[cat] if defs then -- One scroll frame per category: the content is taller than any panel, -- so this is what keeps the page independent of the frame's height. local scroll = CreateFrame("ScrollFrame", nextName("Scroll"), panel, "UIPanelScrollFrameTemplate") scroll:SetPoint("TOPLEFT", 8, BODY_TOP) scroll:SetSize(PANEL_W, 380) scroll:Hide() local child = CreateFrame("Frame", nil, scroll) child:SetSize(PANEL_W, 1) scroll:SetScrollChild(child) pages[cat] = child local y = -4 for _, def in ipairs(defs) do local build = BUILDERS[def.type] if build then y = y - build(child, def, y) end end child:SetHeight(math.abs(y) + 10) -- Named off the panel: PanelTemplates_SetTab looks tabs up as -- _G[panelName.."Tab"..i], and a tab named anything else is never found. local tab = CreateFrame("Button", panel:GetName() .. "Tab" .. i, panel, "OptionsFrameTabButtonTemplate") tab:SetText(cat) tab:SetID(i) if i == 1 then tab:SetPoint("TOPLEFT", 14, TAB_TOP) else tab:SetPoint("LEFT", tabs[i - 1], "RIGHT", -14, 0) end tab:SetScript("OnClick", function(self) selectTab(self:GetID()) PlaySound("igCharacterInfoTab") end) PanelTemplates_TabResize(tab, 0) tabs[i] = tab end end panel.numTabs = #tabs PanelTemplates_SetNumTabs(panel, #tabs) -- Bottom row. Blizzard's own Okay/Cancel sit below this, so there is no -- second set here - that duplication was one of the things wrong with -- reparenting the old dialog. local defaults = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") defaults:SetSize(110, 22) defaults:SetPoint("TOPLEFT", 14, BODY_TOP - 388) defaults:SetText(_G.DEFAULTS or "Defaults") defaults:SetScript("OnClick", function() ACVar:CreateDefaultConfirmationPopup() if ACVar.DefaultConfirmationPopup then ACVar.DefaultConfirmationPopup:Show() end end) local mmName = nextName("Check") local mm = CreateFrame("CheckButton", mmName, panel, "InterfaceOptionsCheckButtonTemplate") mm:SetPoint("LEFT", defaults, "RIGHT", 12, 0) if _G[mmName .. "Text"] then _G[mmName .. "Text"]:SetText(L.MINIMAP_ICON or "Minimap Icon") end mm:SetScript("OnClick", function(self) ACVar.DB.minimap.hide = not self:GetChecked() ACVar:UpdateMinimapButton() end) local gmName = nextName("Check") local gm = CreateFrame("CheckButton", gmName, panel, "InterfaceOptionsCheckButtonTemplate") gm:SetPoint("LEFT", mm, "RIGHT", 130, 0) if _G[gmName .. "Text"] then _G[gmName .. "Text"]:SetText(L.GAME_MENU_BUTTON or "Game Menu Button") end gm:SetScript("OnClick", function(self) ACVar.DB.showGameMenuButton = self:GetChecked() and true or false ACVar:UpdateGameMenuButton() end) controls[#controls + 1] = { Refresh = function() if not ACVar.DB then return end mm:SetChecked(not ACVar.DB.minimap.hide) gm:SetChecked(ACVar.DB.showGameMenuButton and true or false) end } -- 3.3.5a does not call panel.refresh on show; that arrived in Cataclysm. panel:SetScript("OnShow", function() ACVar:UpdateAllUI() selectTab(1) end) panel.refresh = function() ACVar:UpdateAllUI() end panel.okay = function() end panel.cancel = function() end panel.default = function() end InterfaceOptions_AddCategory(panel) end --------------------------------------------------------------------------- -- The entry points the rest of the add-on calls. --------------------------------------------------------------------------- function ACVar:UpdateAllUI() for _, c in ipairs(controls) do if c.Refresh then c.Refresh() end end end function ACVar:UpdateUIForCVar() -- Kept for callers; refreshing everything costs one pass over 38 controls -- and removes a second code path that can fall out of step. self:UpdateAllUI() end function ACVar:UpdateResetButtonState() end local function openCategory() local panel = ACVar.Panel if not panel then return end -- Twice: on 3.3.5a the first call opens the window and the second selects -- the category inside it. InterfaceOptionsFrame_OpenToCategory(panel) InterfaceOptionsFrame_OpenToCategory(panel) end function ACVar:ShowFrame() openCategory() end function ACVar:HideFrame() if InterfaceOptionsFrame and InterfaceOptionsFrame:IsShown() then HideUIPanel(InterfaceOptionsFrame) end end function ACVar:ToggleFrame() if InterfaceOptionsFrame and InterfaceOptionsFrame:IsShown() and InterfaceOptionsFrameAddOns and InterfaceOptionsFrameAddOns:IsShown() then self:HideFrame() else openCategory() end end -- There is no floating frame to reposition any more. function ACVar:ResetFramePosition() end --------------------------------------------------------------------------- -- Popups and the game-menu button, carried over from the old UI.lua. -- -- These were never the problem - only the window was - so they are ported -- rather than rewritten, with the frame names kept so saved layouts and any -- external reference still resolve. --------------------------------------------------------------------------- local function createPopupFrame(name, title, message) local frame = CreateFrame("Frame", name, UIParent, "DialogBoxFrame") frame:SetPoint("CENTER") frame:SetWidth(350) frame:SetHeight(120) frame:SetFrameStrata("DIALOG") frame:Hide() frame:SetBackdropColor(0, 0, 0, 0.8) -- DialogBoxFrame brings its own OK button; these popups supply their own -- pair, so the template's is hidden rather than left to overlap them. if _G[name .. "Button"] then _G[name .. "Button"]:Hide() end local titleText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") titleText:SetPoint("TOP", 0, -16) titleText:SetText(title) local messageText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") messageText:SetPoint("TOP", titleText, "BOTTOM", 0, -10) messageText:SetWidth(frame:GetWidth() - 40) messageText:SetJustifyH("CENTER") messageText:SetText(message) return frame end local function popupButton(parent, name, text) local b = CreateFrame("Button", name, parent, "UIPanelButtonTemplate") b:SetSize(100, 25) b:SetText(text) return b end function ACVar:CreateReloadPopup() if self.ReloadPopupFrame then return end local frame = createPopupFrame("ManiaxCVarReloadPopup", L.RELOAD_POPUP_TITLE, L.RELOAD_POPUP_TEXT) self.ReloadPopupFrame = frame local accept = popupButton(frame, "ManiaxCVarAcceptButton", _G.ACCEPT) accept:SetPoint("BOTTOM", frame, "BOTTOM", -60, 20) accept:SetScript("OnClick", function() ReloadUI() PlaySound("igMainMenuClose") end) local cancel = popupButton(frame, "ManiaxCVarCancelButton", _G.CANCEL) cancel:SetPoint("BOTTOM", frame, "BOTTOM", 60, 20) cancel:SetScript("OnClick", function() self.reloadIsPending = false frame:Hide() PlaySound("igMainMenuOptionFaerTab") end) frame:SetScript("OnShow", function() PlaySound("igMainMenuOpen") end) end function ACVar:CreateDefaultConfirmationPopup() if self.DefaultConfirmationPopup then return end local frame = createPopupFrame("ManiaxCVarDefaultConfirmationPopup", L.RESET_POPUP_TITLE, L.RESET_POPUP_TEXT) self.DefaultConfirmationPopup = frame local okay = popupButton(frame, "ManiaxCVar_ConfirmResetButton", _G.OKAY) okay:SetPoint("BOTTOM", frame, "BOTTOM", -60, 20) okay:SetScript("OnClick", function() for _, cvarList in pairs(CVARS) do for _, def in ipairs(cvarList) do -- Skip what this client does not implement: writing a default to a -- CVar that does not exist prints a change that did not happen. if def.default ~= nil and not unsupported[def.name] then self:SetCVarValue(def.name, def.default, def) self:PrintCVarChange(def.name, def.default) end end end self:UpdateAllUI() frame:Hide() PlaySound("igMainMenuClose") end) local cancel = popupButton(frame, "ManiaxCVar_CancelResetButton", _G.CANCEL) cancel:SetPoint("BOTTOM", frame, "BOTTOM", 60, 20) cancel:SetScript("OnClick", function() frame:Hide() PlaySound("igMainMenuOptionFaerTab") end) frame:SetScript("OnShow", function() PlaySound("igMainMenuOpen") end) tinsert(UISpecialFrames, "ManiaxCVarDefaultConfirmationPopup") end function ACVar:AddGameMenuButton() local button = self.GameMenuButton or CreateFrame("Button", "GameMenuButtonManiaxCVar", _G.GameMenuFrame, "GameMenuButtonTemplate") if not button then return end button:SetText(L.ADDON_NAME_SHORT or "Maniax CVar") button:ClearAllPoints() button:SetPoint("TOP", _G.GameMenuButtonContinue, "BOTTOM", 0, -1) button:SetScript("OnClick", function() self:ToggleFrame() HideUIPanel(_G.GameMenuFrame) end) button:SetScript("OnShow", function() if not self.DB.showGameMenuButton then button:Hide() return end -- Anchored on the next frame: the other menu buttons have not been laid -- out yet at OnShow, so the lowest one cannot be found until then. button:SetScript("OnUpdate", function() local anchor, anchorBottom for _, child in ipairs({ _G.GameMenuFrame:GetChildren() }) do if child ~= button and child:IsShown() and child:GetObjectType() == "Button" then local bottom = child:GetBottom() if bottom and (not anchorBottom or bottom < anchorBottom) then anchorBottom, anchor = bottom, child end end end button:ClearAllPoints() button:SetPoint("TOP", anchor or _G.GameMenuButtonContinue, "BOTTOM", 0, -1) if not self.MenuExtended then _G.GameMenuFrame:SetHeight(_G.GameMenuFrame:GetHeight() + 24) self.MenuExtended = true end button:SetScript("OnUpdate", nil) end) end) button:SetScript("OnHide", function() if self.MenuExtended then _G.GameMenuFrame:SetHeight(_G.GameMenuFrame:GetHeight() - 24) self.MenuExtended = false end end) self.GameMenuButton = button end