Files
SuppaiNekoandClaude Opus 5.5 dea7af3aca Fix XP edge cases and remove floating XP text option
- Count gains worth more than a whole level as level-ups, and track the
  new level from PLAYER_LEVEL_UP so a lagging UnitLevel can't make the
  next kill look like another level-up.
- Only label a gain "XP (quest)" when the quest reports an XP reward, and
  keep the label until a real gain arrives.
- Clamp /xpp size to 10-60, matching the options slider.
- Remove "hide Blizzard's floating XP text" and /xpp float. That text is
  drawn by the game engine on newer clients, so the CombatText hook never
  caught it. Old hideFloat settings are cleared on load.
- Bump version to 1.0.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 21:46:27 -08:00

419 lines
15 KiB
Lua

--[[
================================================================================
XP Percent - Options.lua (options window)
================================================================================
Builds the "XP Percent" page in the game's options menu:
Esc > Options > AddOns > XP Percent (or type /xpp)
Sections on the page:
- Popup text: on/off, font size, color, test, drag-to-move, reset position
- Blizzard's default XP text: hide/show the chat line
- Other: also print % in chat, number of decimal places
How the controls stay in sync with the saved settings:
- Every control writes to the settings (ns.DB()) as soon as it's changed.
- Every control also registers a "refresher" function that loads the saved
value back into it. All refreshers run each time the page is shown, so
changes made with slash commands appear here too.
This file loads after XPPercent.lua (see the .toc), so everything that file
puts on `ns` (ns.DB, ns.Show, ns.ApplyStyle, ...) is already available.
================================================================================
--]]
-- See XPPercent.lua: ADDON is the folder name, ns is the shared addon table.
local ADDON, ns = ...
-- The page itself. It's just a blank frame; the options menu sizes and places it.
local panel = CreateFrame("Frame")
panel.name = "XP Percent" -- the name shown in the AddOns list
local category -- set by InitOptions() on newer clients; used to open the page
-- One function per control that loads the saved value into that control.
-- All of them run in Refresh(), each time the page is shown.
local refreshers = {}
-- True while Refresh() is running. Setting a slider's value from code fires the
-- same "value changed" script as a player dragging it. This flag lets that
-- script tell the two apart, so loading a value doesn't save it back and run
-- its side effects (like a preview popup) for nothing.
local refreshing = false
--------------------------------------------------------------------------------
-- CONTROL BUILDERS
--------------------------------------------------------------------------------
-- Small helper functions that each create one kind of control. Positions are
-- given as `y`, pixels down from the top of the page (so always negative).
--[[
Header(text, y) - a gold section heading, e.g. "Popup text".
--]]
local function Header(text, y)
local h = panel:CreateFontString(nil, "ARTWORK", "GameFontNormal")
h:SetPoint("TOPLEFT", 16, y)
h:SetText(text)
return h
end
--[[
Checkbox(label, key, y, onChange) - a checkbox tied to one on/off setting.
label - text next to the box
key - which setting it controls, e.g. "hideChat"
y - vertical position
onChange - optional function to run after the setting changes
--]]
local function Checkbox(label, key, y, onChange)
-- UICheckButtonTemplate is Blizzard's standard checkbox.
local cb = CreateFrame("CheckButton", nil, panel, "UICheckButtonTemplate")
cb:SetPoint("TOPLEFT", 20, y)
-- The template's label is called .Text on newer clients and .text on some
-- older ones. If neither exists, make our own to the right of the box.
local text = cb.Text or cb.text
if not text then
text = cb:CreateFontString(nil, "ARTWORK")
text:SetPoint("LEFT", cb, "RIGHT", 2, 0)
end
text:SetFontObject("GameFontHighlight") -- white text, like Blizzard's options
text:SetText(label)
-- Clicked: save the new state. "and true or false" turns GetChecked()'s
-- result into a real boolean (some clients return 1/nil instead).
cb:SetScript("OnClick", function(self)
ns.DB()[key] = self:GetChecked() and true or false
if onChange then onChange() end
end)
-- When the page is shown, tick or untick to match the saved setting.
refreshers[#refreshers + 1] = function()
cb:SetChecked(ns.DB()[key])
end
return cb
end
--[[
Slider(label, key, minV, maxV, y, onChange) - a whole-number slider tied
to one numeric setting.
label - text above the slider; the current value is added after it
key - which setting it controls, e.g. "size"
minV, maxV - lowest and highest allowed values
y - vertical position
onChange - optional function(newValue) to run after the setting changes
Built from a plain Slider rather than a Blizzard template, because the
slider templates differ (or are missing) between game versions.
--]]
local function Slider(label, key, minV, maxV, y, onChange)
-- Title line above the slider, e.g. "Font size: 26".
local title = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
title:SetPoint("TOPLEFT", 24, y)
local s = CreateFrame("Slider", nil, panel)
s:SetOrientation("HORIZONTAL")
s:SetSize(220, 16)
s:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -6)
s:SetMinMaxValues(minV, maxV)
s:SetValueStep(1) -- whole numbers only
-- Snap to whole numbers while dragging too (missing on very old clients).
if s.SetObeyStepOnDrag then s:SetObeyStepOnDrag(true) end
s:EnableMouseWheel(true)
-- What the slider looks like: a dark bar (the track) and Blizzard's
-- standard slider knob (the "thumb" you drag).
local track = s:CreateTexture(nil, "BACKGROUND")
track:SetColorTexture(0, 0, 0, 0.6) -- 60% opaque black
track:SetPoint("LEFT")
track:SetPoint("RIGHT")
track:SetHeight(6)
s:SetThumbTexture("Interface\\Buttons\\UI-SliderBar-Button-Horizontal")
-- Small grey min/max numbers under each end of the slider.
local low = s:CreateFontString(nil, "ARTWORK", "GameFontDisableSmall")
low:SetPoint("TOPLEFT", s, "BOTTOMLEFT", 0, -2)
low:SetText(minV)
local high = s:CreateFontString(nil, "ARTWORK", "GameFontDisableSmall")
high:SetPoint("TOPRIGHT", s, "BOTTOMRIGHT", 0, -2)
high:SetText(maxV)
-- Runs whenever the value changes, whether the player dragged it or
-- Refresh() set it from code.
s:SetScript("OnValueChanged", function(self, v)
v = math.floor(v + 0.5) -- round to a whole number
-- Always update the title. |cffffffff ... |r shows the number in white.
title:SetText(label .. ": |cffffffff" .. v .. "|r")
-- If this change came from Refresh(), stop here: nothing new to save.
if refreshing then return end
ns.DB()[key] = v
if onChange then onChange(v) end
end)
-- Mouse wheel over the slider: up = +1, down = -1. SetValue keeps it within
-- min/max and fires OnValueChanged, which saves the value.
s:SetScript("OnMouseWheel", function(self, delta)
self:SetValue(self:GetValue() + delta)
end)
-- When the page is shown, move the knob and title to the saved value.
-- The title is set here as well, because SetValue doesn't fire
-- OnValueChanged when the value is already the same.
refreshers[#refreshers + 1] = function()
s:SetValue(ns.DB()[key])
title:SetText(label .. ": |cffffffff" .. ns.DB()[key] .. "|r")
end
return s
end
--[[
Button(text, width, onClick) - a standard red Blizzard button.
--]]
local function Button(text, width, onClick)
local b = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
b:SetSize(width, 22)
b:SetText(text)
b:SetScript("OnClick", onClick)
return b
end
-- Shows a sample popup so the player can see a change straight away.
local function Preview()
ns.Show(1.25, "XP")
end
--------------------------------------------------------------------------------
-- PAGE LAYOUT
--------------------------------------------------------------------------------
-- Controls are placed top to bottom using fixed y values (pixels down from the
-- top of the page). To add a control, give it a y and push later ones down.
-- Page title and short description.
local title = panel:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge")
title:SetPoint("TOPLEFT", 16, -16)
title:SetText("XP Percent")
local sub = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
sub:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -6)
sub:SetText("Shows XP gains as a percentage of your current level.")
---------------------------------------- Section: Popup text
Header("Popup text", -70)
Checkbox("Show the floating % popup", "popup", -90)
-- Font size. Every change re-applies the style and shows a preview.
Slider("Font size", "size", 10, 60, -128, function()
ns.ApplyStyle()
Preview()
end)
-- Color swatch: a small square showing the current color (a white border with
-- the color inside). Clicking it opens Blizzard's color picker.
local swatch = CreateFrame("Button", nil, panel)
swatch:SetSize(22, 22)
swatch:SetPoint("TOPLEFT", 24, -184)
local border = swatch:CreateTexture(nil, "BACKGROUND")
border:SetAllPoints() -- fills the whole square...
border:SetColorTexture(1, 1, 1) -- ...in white
local fill = swatch:CreateTexture(nil, "ARTWORK")
fill:SetPoint("TOPLEFT", 2, -2) -- 2 px smaller on every side, so a white
fill:SetPoint("BOTTOMRIGHT", -2, 2) -- edge shows around the color
local swatchLabel = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlight")
swatchLabel:SetPoint("LEFT", swatch, "RIGHT", 8, 0)
swatchLabel:SetText("Text color (click to change)")
--[[
SetColor(r, g, b) - saves a new popup color (each channel 0-1) and updates
the swatch and the popup.
--]]
local function SetColor(r, g, b)
local c = ns.DB().color
c.r, c.g, c.b = r, g, b
fill:SetColorTexture(r, g, b)
ns.ApplyStyle()
end
-- When the page is shown, paint the swatch with the saved color.
refreshers[#refreshers + 1] = function()
local c = ns.DB().color
fill:SetColorTexture(c.r, c.g, c.b)
end
-- Swatch clicked: open the color picker at the current color.
swatch:SetScript("OnClick", function()
local c = ns.DB().color
-- Remember the starting color so Cancel can put it back.
local prev = { r = c.r, g = c.g, b = c.b }
-- The picker calls onChange every time the player moves the selector, so
-- the popup updates live while they choose.
local function onChange() SetColor(ColorPickerFrame:GetColorRGB()) end
local function onCancel() SetColor(prev.r, prev.g, prev.b) end
if ColorPickerFrame.SetupColorPickerAndShow then
-- Newer clients: one function call with a table of options.
ColorPickerFrame:SetupColorPickerAndShow({
r = c.r, g = c.g, b = c.b,
hasOpacity = false, -- no transparency slider
swatchFunc = onChange,
cancelFunc = onCancel,
})
else
-- Older clients: set fields on the picker frame, then show it.
ColorPickerFrame:Hide() -- close it if it's open for something else
ColorPickerFrame.hasOpacity = false
ColorPickerFrame.opacityFunc = nil
ColorPickerFrame.previousValues = prev
ColorPickerFrame.func = onChange
ColorPickerFrame.cancelFunc = onCancel
ColorPickerFrame:SetColorRGB(c.r, c.g, c.b)
ShowUIPanel(ColorPickerFrame)
end
end)
-- "Reset color": back to the default purple from XPPercent.lua.
local resetColor = Button("Reset color", 100, function()
local d = ns.defaults.color
SetColor(d.r, d.g, d.b)
Preview()
end)
resetColor:SetPoint("LEFT", swatchLabel, "RIGHT", 12, 0)
-- "Test popup": show a sample popup with the current settings.
local test = Button("Test popup", 110, Preview)
test:SetPoint("TOPLEFT", 24, -216)
-- "Unlock popup" / "Lock popup": one button that toggles drag-to-move mode.
-- Its text is updated in UpdateMoveControls() below.
local unlock = Button("Unlock popup", 120, function()
ns.SetUnlocked(not ns.IsUnlocked())
end)
unlock:SetPoint("LEFT", test, "RIGHT", 8, 0)
-- "Reset position": move the popup back to its default spot.
local resetPos = Button("Reset position", 120, function()
local db = ns.DB()
db.x, db.y = ns.defaults.x, ns.defaults.y
ns.ApplyStyle()
if ns.IsUnlocked() then
-- The blue box is already on screen and has just jumped to the new
-- spot; only the position readout needs updating.
ns.OnPositionChanged()
else
-- Otherwise show a sample popup so the player can see where it went.
Preview()
end
end)
resetPos:SetPoint("LEFT", unlock, "RIGHT", 8, 0)
-- Grey "Position: x, y" readout to the right of the buttons.
local posText = panel:CreateFontString(nil, "ARTWORK", "GameFontDisableSmall")
posText:SetPoint("LEFT", resetPos, "RIGHT", 10, 0)
--[[
UpdateMoveControls() - updates the Unlock/Lock button text and the
position readout.
It is both a refresher (runs when the page opens) and ns.OnPositionChanged,
which XPPercent.lua calls after a drag, a lock/unlock, or /xpp pos. That
keeps the page correct even while it's open.
--]]
local function UpdateMoveControls()
local db = ns.DB()
unlock:SetText(ns.IsUnlocked() and "Lock popup" or "Unlock popup")
posText:SetText(string.format("Position: %d, %d", db.x, db.y))
end
refreshers[#refreshers + 1] = UpdateMoveControls
ns.OnPositionChanged = UpdateMoveControls
---------------------------------------- Section: Blizzard's default XP text
-- Untick this to keep Blizzard's own XP message alongside ours.
-- (Blizzard's floating "XP: 200" text is drawn by the game engine, which
-- addons can't hide, so there's no option for it.)
Header("Blizzard's default XP text", -256)
-- The chat filter in XPPercent.lua reads this setting every time, so
-- changing it takes effect immediately.
Checkbox("Hide Blizzard's \"You gain 200 experience\" chat message", "hideChat", -276)
---------------------------------------- Section: Other
Header("Other", -316)
Checkbox("Also print the % gain in chat", "chat", -336)
-- 0-4 decimal places, e.g. 2 shows "+1.25%" and 0 shows "+1%".
Slider("Decimal places", "decimals", 0, 4, -374)
-- Grey tip at the bottom of the page.
local hint = panel:CreateFontString(nil, "ARTWORK", "GameFontDisableSmall")
hint:SetPoint("TOPLEFT", 24, -424)
hint:SetText("Tip: click \"Unlock popup\", drag the blue box anywhere, then click \"Lock popup\".")
--------------------------------------------------------------------------------
-- REGISTRATION (adding the page to the game's options menu)
--------------------------------------------------------------------------------
--[[
Refresh() - loads every saved setting into its control.
Runs each time the page is shown.
--]]
local function Refresh()
refreshing = true -- tell slider scripts not to save during the sync
for _, fn in ipairs(refreshers) do fn() end
refreshing = false
end
panel:SetScript("OnShow", Refresh)
--[[
ns.InitOptions() - adds the page to the game's options menu.
Called once from PLAYER_LOGIN in XPPercent.lua.
The options menu was rebuilt in Dragonflight (10.0), so there are two ways:
- Newer clients: the Settings API. A "canvas layout" category means
"use this frame as the page as-is".
- Older clients: InterfaceOptions_AddCategory.
--]]
function ns.InitOptions()
-- Guard so the page can never be added twice.
if category or panel.registered then return end
if Settings and Settings.RegisterCanvasLayoutCategory then
category = Settings.RegisterCanvasLayoutCategory(panel, panel.name)
Settings.RegisterAddOnCategory(category)
elseif InterfaceOptions_AddCategory then
InterfaceOptions_AddCategory(panel)
end
panel.registered = true
end
--[[
ns.OpenOptions() - opens the options menu straight to this page.
Used by a bare /xpp.
--]]
function ns.OpenOptions()
if Settings and Settings.OpenToCategory and category then
-- Newer clients open a page by its category ID.
Settings.OpenToCategory(category:GetID())
elseif InterfaceOptionsFrame_OpenToCategory then
-- Older clients. Called twice on purpose: a long-standing Blizzard bug
-- makes the first call open the menu without selecting our page.
InterfaceOptionsFrame_OpenToCategory(panel)
InterfaceOptionsFrame_OpenToCategory(panel)
end
end