-- Vampify -- options window, built in DopingControl's visual language (gui/widgets.lua) so the two -- addons read as the same toolkit rather than as a Blizzard dialog bolted onto a hand-rolled one. -- -- Layout follows DopingControl's ui/options.lua pattern: a lazily-built frame, a layout cursor that -- places widgets top-down, checkboxes with getter/setter pairs repopulated on OnShow, buttons via -- VampifyWidgets.MakeButton, and a throttled info panel that only repaints text that changed -- (VampifyWidgets.SetTextG). The info panel itself is specific to this addon -- there is no grid -- here, just the source list and the honesty lines about what the number does and does not mean. -- -- Pure-Lua 5.0 dofile-loadable: all WoW wiring behind "if CreateFrame then". VampifyOptions = {} local O = VampifyOptions -- ---- WoW wiring ------------------------------------------------------------------------------ if CreateFrame then local W = VampifyWidgets local C = W.COLORS local frame local checkboxes = {} local infoFS local elapsed = 0 local shareBuf = {} -- SCT color/position controls -- declared here (not local to build()) so refreshSwatch() and -- refreshEnabled() below can reach them from OnShow/OnUpdate as well as from the checkboxes' -- own setters. sliderPillH/sliderPanelBarH (2026-08-25) join the same list for the same reason -- -- refreshSliders() below needs to push their persisted values in on OnShow, same as -- sliderFont/sliderRise already do. local swatch, btnMove, btnPosReset, posLabel, sliderFont, sliderRise, btnDemo, btnOutline, sliderPillH, sliderPanelBarH, sliderDuration local function db() return VampifyConfig.get() end -- Same merge as core/commands.lua's recompute(): detected sources plus manual overrides. Kept -- here rather than reading commands.lua's private locals (there is nothing exported to read), -- so the info panel's Total/Floor line matches what the addon actually uses to size a hit. local function totals() local sources = VampifyDetect.getSources() local sum, n = VampifyDetect.summarise(sources) local cfg = VampifyConfig.getChar() if cfg and cfg.manual then for i = 1, table.getn(cfg.manual) do sum = sum + (cfg.manual[i] / 100) n = n + 1 end end return sources, sum, n end -- ------------------------------------------------------------------ -- widget helpers -- ------------------------------------------------------------------ local checkCounter = 0 local function CreateCheckbox(parent, label, getter, setter) checkCounter = checkCounter + 1 local name = "VampifyOptionsCheck" .. checkCounter local cb = CreateFrame("CheckButton", name, parent, "UICheckButtonTemplate") cb:SetWidth(24) cb:SetHeight(24) local text = getglobal(name .. "Text") text:SetText(label) W.ApplyFont(text, 11) W.SetTextColorG(text, C.ink) cb.vfLabelFS = text -- exposed so a caller can chain another widget off the label's -- right edge (measured, not estimated -- see the SCT engine row) cb.vfGet = getter cb.vfSet = setter cb:SetScript("OnClick", function() this.vfSet(this:GetChecked() == 1) -- GetChecked returns 1 | nil end) table.insert(checkboxes, cb) return cb end -- ------------------------------------------------------------------ -- info panel text -- rebuilt only when this fires (throttled OnUpdate), never per damage event -- ------------------------------------------------------------------ local infoLines = {} local floorBuf = {} -- reused result table for the floor-share line below -- The upgrade verdict, THROTTLED to once a second and cached as a finished string. -- -- WHY THROTTLED RATHER THAN COMPUTED ONCE ON OPEN. VampifyHistogram.compare is four passes over -- the whole recorded distribution -- the baseline plus three hypotheticals -- and this panel -- repaints twice a second for as long as it is open, so computing it per repaint would spend -- eight passes a second on a line whose answer moves about as fast as the player's gear does. -- But once-on-open would be worse in the case that matters: the panel is left open while -- fighting, the distribution keeps filling, and a verdict frozen at the moment of opening would -- be stale exactly when the player is watching it change. Once a second is two orders of -- magnitude cheaper than the alternative and still live. -- -- Everything is held in ONE table rather than three locals on purpose: this file's enclosing -- closure is already wide, and Lua 5.0 caps locals and upvalues per function. local cmp = { buf = {}, text = nil, at = nil } local function upgradeLine() local now = GetTime and GetTime() or nil -- No GetTime (offline/stub): compute once and keep it, rather than every repaint. if cmp.text and (not now or (cmp.at and (now - cmp.at) < 1)) then return cmp.text end cmp.at, cmp.text = now, "" if not (VampifyHistogram and VampifyState and VampifyState.hist) then return cmp.text end -- Always at +1 point here. The panel states the CHOICE, not a shopping calculator; a player -- who wants a different step has /vf upgrade , which prints both scopes as well. local c = VampifyHistogram.compare(VampifyState.hist, VampifyState.sourcePercents, 1, cmp.buf) if not (c.hits and c.hits > 0) then return cmp.text end if c.winner == "swap" and c.swapIndex then cmp.text = string.format("Upgrade: swap your weakest source (%d%% -> %d%%) - worth" .." +%d HP here against +%d for one more 1%% source", c.swapFrom, c.swapTo, math.floor(c.swapDelta), math.floor(c.addDelta)) elseif c.winner == "add" then cmp.text = string.format("Upgrade: one more 1%% source - worth +%d HP here against" .." +%d for swapping your weakest one", math.floor(c.addDelta), math.floor(c.swapDelta)) else cmp.text = string.format("Upgrade: one more 1%% source and swapping your weakest one" .." are worth the same here (+%d HP each)", math.floor(c.addDelta)) end return cmp.text end local function composeInfo() VampifyConst.resetList(infoLines) -- nil-ing indices is not enough in 5.0; see const.lua local sources, sumPercent, nSources = totals() local n = table.getn(sources) for i = 1, n do table.insert(infoLines, string.format("Slot %d: %d%% (%s)", sources[i].slot, sources[i].percent, sources[i].kind)) end table.insert(infoLines, string.format("Total: %.0f%% from %d sources", sumPercent * 100, nSources)) table.insert(infoLines, string.format("Floor: %d HP per hit", nSources)) -- How much of this session's healing the floor ACTUALLY paid, over the recorded hit -- distribution (core/histogram.lua) rather than as a per-hit minimum. A high share means -- the hits are small enough that every source is pinned at 1 HP, which is the regime where -- one MORE source beats swapping a weak one out. One pass over the distribution, cheap -- enough to run on every repaint; the verdict line below it is the expensive part and is -- throttled separately (see upgradeLine). if VampifyHistogram and VampifyState and VampifyState.hist then local t = VampifyHistogram.totals(VampifyState.hist, VampifyState.sourcePercents, floorBuf) if t.hits > 0 then table.insert(infoLines, string.format( "Floor share: %d of %d HP (%.0f%%) over %d hits", math.floor(t.floorHeal), math.floor(t.heal), t.floorShare * 100, t.hits)) end -- The decision itself, so it is readable without typing anything. /vf upgrade still -- gives the full report: both scopes, a chosen step size, and the ceiling figure. local up = upgradeLine() if up ~= "" then table.insert(infoLines, up) end end local fight = VampifyAggregate.fight(VampifyState) local session = VampifyAggregate.session(VampifyState) local eff = VampifyState.effHeal or 0 local overheal = (fight.heal or 0) - eff if overheal < 0 then overheal = 0 end local ohPct = 0 if fight.heal and fight.heal > 0 then ohPct = overheal / fight.heal * 100 end table.insert(infoLines, string.format("Fight: %d healed, %.2f%%", math.floor(fight.heal or 0), (fight.pct or 0) * 100)) -- Overheal is CALCULATED from the health deficit at hit time, not measured -- Vampirism -- emits no heal event. The line right below says so; do not let the number alone imply -- more precision than that. table.insert(infoLines, string.format("Effective: %d healed, %d overheal (%.0f%%)", math.floor(eff), math.floor(overheal), ohPct)) table.insert(infoLines, "|cff" .. string.format("%02x%02x%02x", C.faint.r * 255, C.faint.g * 255, C.faint.b * 255) .. "Overheal is derived from the health deficit at the time of the hit, not from a" .. " heal event - Vampirism emits none.|r") table.insert(infoLines, string.format("Session: %d healed, %.2f%%", math.floor(session.heal or 0), (session.pct or 0) * 100)) local flag = VampifyDisplay.boundFlag(VampifyConst.FORMULA.channels, VampifyDamage.aoeAvailable()) if flag == "lower" or flag == "uncertain" then table.insert(infoLines, "|cffe0c98aLower bound: DoT and PvP damage are unmeasured and excluded.|r") end if flag == "upper" or flag == "uncertain" then table.insert(infoLines, "|cffe0c98aUpper bound: no SPELL_GO seen, AoE damping not applied.|r") end if not VampifyDamage.damageSeen() then table.insert(infoLines, "|cffd05252No damage events received - nampower may be absent.|r") end return table.concat(infoLines, "\n") end -- The info panel grows and shrinks with what it has to say: the source list is as long as the -- player's gear, and the honesty lines come and go. A fixed frame height therefore cannot fit -- it -- reported in-game as text hanging out of the bottom edge. -- -- Note the panel is measured, not estimated: 1.12 has no GetStringHeight (only GetStringWidth -- exists in Blizzard's own FrameXML), but a FontString with an explicit width lays its wrapped -- text out and reports the real height through GetHeight(). local function fitHeight() if not (frame and infoFS) then return end local top, iTop, iH = frame:GetTop(), infoFS:GetTop(), infoFS:GetHeight() if not (top and iTop and iH) then return end local need = (top - iTop) + iH + 18 -- header block + text + bottom padding if need < 300 then need = 300 end -- never collapse to nothing if math.abs(frame:GetHeight() - need) > 2 then frame:SetHeight(need) end end local function refreshInfo() if infoFS then W.SetTextG(infoFS, composeInfo()) fitHeight() end end local function refreshChecks() local cfg = db() if not cfg then return end for i = 1, table.getn(checkboxes) do local cb = checkboxes[i] cb:SetChecked(cb.vfGet() and 1 or nil) end end -- Swatch background = the currently configured SCT color. Applies in both modes (see -- gui/fct.lua), so it is never part of refreshEnabled()'s greying below. local function refreshSwatch() if not swatch then return end local cfg = db() local col = cfg and cfg.sct and cfg.sct.color if col then swatch:SetBackdropColor(col.r, col.g, col.b, 1) end end -- Greys out the controls that are genuinely inert outside "Free anchor" mode: Blizzard mode -- owns its own position and timing (Blizzard_CombatText), and bar mode derives its position -- from VampifyDisplayFrame rather than the stored free position -- in both cases the slider/ -- Move/Reset controls below have nothing to act on. NOT the color swatch, which still applies -- in all three modes. local function refreshEnabled() local cfg = db() local mode = cfg and cfg.sct and cfg.sct.mode -- Active only in "own" -- nil (fresh/unmigrated profile) and any unrecognised value fall -- back to enabled too, matching gui/sct.lua's resolveAnchorMode's "unknown -> free" rule. local posEnabled = (mode ~= "bar" and mode ~= "blizzard") if btnMove then W.SetButtonEnabled(btnMove, posEnabled) end if btnPosReset then W.SetButtonEnabled(btnPosReset, posEnabled) end if posLabel then W.SetTextColorG(posLabel, posEnabled and C.ink or C.faint) end -- The sliders shape THIS addon's own SCT only; Blizzard's owns its font and travel. -- NOT Enable()/Disable(): a Slider has neither in 1.12 (it threw in-game). See -- W.SetSliderEnabled, which follows Blizzard's own hide-the-thumb recipe. W.SetSliderEnabled(sliderRise, posEnabled) W.SetSliderEnabled(sliderFont, posEnabled) -- Fade duration is the same category as fontSize/rise above: it shapes OUR pooled -- FontStrings only (gui/sct.lua's S.spawn reads sct.duration into s.dur), so it follows -- the same posEnabled greying. W.SetSliderEnabled(sliderDuration, posEnabled) if btnDemo then W.SetButtonEnabled(btnDemo, posEnabled) end -- The outline shapes OUR numbers only; Blizzard's engine draws its own. if btnOutline then W.SetButtonEnabled(btnOutline, posEnabled) end end -- Push stored values into the sliders without letting OnValueChanged write them straight back. local function refreshSliders() if sliderFont then local v = sliderFont.vfGetter() sliderFont:SetValue(v) getglobal("VampifyOptionsFontSliderText"):SetText(sliderFont.vfLabel .. ": " .. v) end if sliderRise then local v = sliderRise.vfGetter() sliderRise:SetValue(v) getglobal("VampifyOptionsRiseSliderText"):SetText(sliderRise.vfLabel .. ": " .. v) end if sliderPillH then local v = sliderPillH.vfGetter() sliderPillH:SetValue(v) getglobal("VampifyOptionsPillHeightSliderText"):SetText(sliderPillH.vfLabel .. ": " .. v) end if sliderPanelBarH then local v = sliderPanelBarH.vfGetter() sliderPanelBarH:SetValue(v) getglobal("VampifyOptionsPanelBarHeightSliderText"):SetText(sliderPanelBarH.vfLabel .. ": " .. v) end if sliderDuration then local v = sliderDuration.vfGetter() sliderDuration:SetValue(v) getglobal("VampifyOptionsDurationSliderText"):SetText( sliderDuration.vfLabel .. ": " .. string.format(sliderDuration.vfFmt or "%s", v)) end end -- ------------------------------------------------------------------ -- frame -- ------------------------------------------------------------------ local function build() if frame then return frame end local f = CreateFrame("Frame", "VampifyOptionsFrame", UIParent) f:SetWidth(340) f:SetHeight(608) -- +42 (Fade duration slider) +26 (Text shadow checkbox) over the -- previous 540; fitHeight() below still re-measures against the -- real info-panel position on every OnShow, so this is only the -- pre-first-show estimate, not the operative bound. f:SetPoint("CENTER", UIParent, "CENTER", 0, 0) -- HIGH, deliberately NOT DIALOG. Blizzard's ColorPickerFrame lives at DIALOG, and a frame -- created later in the same strata draws on top of it -- so at DIALOG this window buried -- the very colour picker it opens (reported in-game). HIGH keeps us above the normal UI -- while leaving DIALOG to Blizzard's own popups, which is where they belong. f:SetFrameStrata("HIGH") f:SetBackdrop(W.PANEL_BACKDROP) f:SetBackdropColor(1, 1, 1, 0.97) f:SetMovable(true) f:EnableMouse(true) f:SetClampedToScreen(true) f:RegisterForDrag("LeftButton") f:SetScript("OnDragStart", function() this:StartMoving() end) f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) -- title (gold), version alongside it local title = W.MakeText(f, 14, C.goldHi, "ARTWORK") title:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -16) title:SetText("Vampify v" .. VampifyConst.VERSION) -- close X local closeX = CreateFrame("Button", "VampifyOptionsCloseX", f, "UIPanelCloseButton") closeX:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8) -- layout cursor local x, y = 24, -46 local function place(w, dy) w:SetPoint("TOPLEFT", f, "TOPLEFT", x, y) y = y + dy end -- ---- checkboxes ------------------------------------------------------------------- -- Reads the stored preference rather than the frame, so it cannot drift from the minimap -- button (which writes the same field) and cannot report "off" merely because the bar is -- currently down for a reason of its own -- no sources equipped, or the inert verdict. local cbShow = CreateCheckbox(f, "Show display", function() local cfg = db(); return cfg and cfg.shown end, function(v) local cfg = db() if cfg then cfg.shown = v end if v then VampifyDisplay.show() else VampifyDisplay.hide() end end) place(cbShow, -26) W.AttachTooltip(cbShow, "Same as left-clicking the minimap button.") local cbLock = CreateCheckbox(f, "Lock display", function() local cfg = db(); return cfg and cfg.locked end, function(v) local cfg = db(); if cfg then cfg.locked = v end end) place(cbLock, -26) W.AttachTooltip(cbLock, "Prevents dragging the display frame around.") local cbMinimap = CreateCheckbox(f, "Minimap button", function() local cfg = db(); return cfg and cfg.minimap end, function(v) local cfg = db() if cfg then cfg.minimap = v end if VampifyMinimap and VampifyMinimap.update then VampifyMinimap.update() end end) place(cbMinimap, -26) local cbOverheal = CreateCheckbox(f, "Show overheal", function() local cfg = db(); return cfg and cfg.showOverheal end, function(v) local cfg = db(); if cfg then cfg.showOverheal = v end end) place(cbOverheal, -26) -- Tooltip text updated 2026-08-24 (Befund 2, pixel measurement pass): the compact bar no -- longer shows a "(N OH)" suffix at all (moved to the row-detail panel and the breakdown -- panel's Total line, to make room on an overflowing bar) -- this option's only remaining -- effect is the combat-text half, so the tooltip must not describe a bar effect that no -- longer exists. W.AttachTooltip(cbOverheal, "Spawns dimmed, parenthesized overheal numbers on the combat text.") -- "Show vampirism % on bar" (2026-08-25, feature request: "one more: show vampirism % on -- bar - with total%/source count"). This existed on the bar once before ("14.0% (9 Gear)") -- and was cut entirely during the 340px->160px compaction pass -- it now comes back as an -- OPT-IN toggle instead of a permanent segment. DEFAULT OFF (per the original suggestion, -- taken as decided rather than merely offered): the bar stays at its established narrow -- width for every existing player, and nobody is surprised by it growing on the very next -- login -- the same "an existing profile sees zero visible change until it explicitly opts -- in" principle this file's own sliders (pillHeight/panelBarHeight, added at the same time) -- already follow. gui/display.lua's V.update() calls V.formatSources(VampifyState.sourcePercents) -- for the actual text -- the SAME figure this window's own info panel already shows further -- down ("Total: N% from M sources" -- composeInfo() above), fed by the same -- VampifyDetect.getSources() + manual overrides core/commands.lua's recompute() already -- merges into sourcePercents -- not a second computation. local cbGearPct = CreateCheckbox(f, "Show vampirism % on bar", function() local cfg = db(); return cfg and cfg.showGearPct end, function(v) local cfg = db() if cfg then cfg.showGearPct = v end -- Immediate effect, no /reload -- V.update() already runs the segment's own -- show/hide + text + pill-width re-layout logic every tick; calling it once here -- (rather than waiting up to ~0.5s for the next throttled tick) matches this file's -- own "sofort" convention for every other live-applied option/slider. if VampifyDisplay and VampifyDisplay.update then VampifyDisplay.update() end end) place(cbGearPct, -26) W.AttachTooltip(cbGearPct, "Adds \" ()\" to the bar -- the sum of your" .. " equipped gear's Vampirism percentages and how many sources make it up. Same figure" .. " as the \"Total\" line in this window's own info panel below. The bar grows to fit it.") -- Same field (VampifyDB.perhitEnabled) /vf perhit on|off writes, core/commands.lua -- the -- two cannot drift, same as cbShow above and the minimap button's left-click both writing -- cfg.shown. The actual enable/disable side effect (not just the saved preference) is -- applied here directly, mirroring cbMinimap's direct VampifyMinimap.update() call below. local cbPerHit = CreateCheckbox(f, "Per-hit debug export", function() local cfg = db(); return cfg and cfg.perhitEnabled end, function(v) local cfg = db() if cfg then cfg.perhitEnabled = v end if not VampifyPerHit then return end if v then if not VampifyPerHit.isEnabled() then VampifyPerHit.enable() end elseif VampifyPerHit.isEnabled() then VampifyPerHit.disable(UnitHealth and UnitHealth("player")) end end) place(cbPerHit, -26) W.AttachTooltip(cbPerHit, "Writes one line per own hit to imports\\vampify_perhit_*.txt" .." for offline analysis (per-source Vampirism factor measurement). On by default.") local cbSct = CreateCheckbox(f, "Scrolling combat text", function() local cfg = db(); return cfg and cfg.sct and cfg.sct.enabled end, function(v) local cfg = db() if cfg and cfg.sct then cfg.sct.enabled = v end end) place(cbSct, -26) local cbCoalesce = CreateCheckbox(f, "One number per cast (not per hit)", function() local cfg = db(); return cfg and cfg.sct and cfg.sct.coalesce end, function(v) local cfg = db() if cfg and cfg.sct then cfg.sct.coalesce = v end end) place(cbCoalesce, -18) local coalesceHint = W.MakeText(f, 9, C.faint, "ARTWORK") coalesceHint:SetPoint("TOPLEFT", f, "TOPLEFT", x + 26, y) coalesceHint:SetWidth(268) coalesceHint:SetJustifyH("LEFT") coalesceHint:SetText("An AoE hitting five targets shows its summed return once instead of" .. " five times. Totals are unaffected - this only changes what is drawn.") y = y - 26 -- ---- SCT engine (three mutually exclusive modes, VampifyDB.sct.mode) -------------------- -- Not place()'s usual stacked column: three in a row reads as ONE choice, not three -- independent switches. A plain CheckButton has no native radio grouping in 1.12, so -- exclusivity is hand-rolled -- each setter writes cfg.sct.mode, then refreshChecks() -- resyncs every checkbox in the window from its own getter (checkboxes[] already holds all -- of them), which is also what snaps a click back to checked when it tried to uncheck the -- currently active mode (there is always exactly one -- "off" is not a fourth option here). local engineLabel = W.MakeText(f, 11, C.ink, "ARTWORK") place(engineLabel, -18) engineLabel:SetText("SCT engine") local function setEngineMode(v) local cfg = db() if cfg and cfg.sct then cfg.sct.mode = v end -- Dragging the free anchor only means anything in "own" mode -- leaving move mode -- engaged while switching away would leave it silently unreachable from the (now grey) -- Move button, so it is force-released the same moment the mode stops being "own". if v ~= "own" and VampifySCT and VampifySCT.isMoveMode and VampifySCT.isMoveMode() then VampifySCT.setMoveMode(false) if btnMove then btnMove.text:SetText("Move") end end refreshChecks() refreshEnabled() -- Hangs the anchor immediately instead of waiting for the next SCT emit -- see -- gui/sct.lua's S.applyPos/reanchor comments for why a forced, explicit call is needed -- here on top of spawn()'s own lazy re-check. if VampifySCT and VampifySCT.applyPos then VampifySCT.applyPos() end end local cbFree = CreateCheckbox(f, "Free anchor", function() local cfg = db(); local m = cfg and cfg.sct and cfg.sct.mode return m ~= "bar" and m ~= "blizzard" end, function(v) if v then setEngineMode("own") else refreshChecks() end end) place(cbFree, 0) -- dy 0: cbBar/cbBlizzard below share this row, y advances once after all three W.AttachTooltip(cbFree, "Vampify's own combat text on a draggable anchor you position anywhere.") local cbBar = CreateCheckbox(f, "At the bar", function() local cfg = db(); return cfg and cfg.sct and cfg.sct.mode == "bar" end, function(v) if v then setEngineMode("bar") else refreshChecks() end end) cbBar:SetPoint("LEFT", cbFree.vfLabelFS, "RIGHT", 16, 0) W.AttachTooltip(cbBar, "Vampify's own combat text, anchored just above the display bar.") local cbBlizzard = CreateCheckbox(f, "Blizzard FCT", function() local cfg = db(); return cfg and cfg.sct and cfg.sct.mode == "blizzard" end, function(v) if v then setEngineMode("blizzard") else refreshChecks() end end) cbBlizzard:SetPoint("LEFT", cbBar.vfLabelFS, "RIGHT", 16, 0) W.AttachTooltip(cbBlizzard, "Blizzard's own combat text (Blizzard_CombatText) - only the" .." colour is ours, position and timing are Blizzard's.") y = y - 26 local engineHint = W.MakeText(f, 9, C.faint, "ARTWORK") engineHint:SetPoint("TOPLEFT", f, "TOPLEFT", x, y) engineHint:SetWidth(292) engineHint:SetJustifyH("LEFT") engineHint:SetText("Free anchor and At the bar both use Vampify's own combat text below;" .." Blizzard FCT hands numbers to Blizzard's engine instead.") y = y - 24 -- ---- SCT color ----------------------------------------------------------------------- swatch = CreateFrame("Button", "VampifyOptionsSctSwatch", f) swatch:SetWidth(16) swatch:SetHeight(16) swatch:SetPoint("TOPLEFT", f, "TOPLEFT", x, y) swatch:SetBackdrop(W.SOLID_BACKDROP) swatch:SetBackdropBorderColor(C.goldSoft.r, C.goldSoft.g, C.goldSoft.b, 1) local swatchLabel = W.MakeText(f, 11, C.ink, "ARTWORK") swatchLabel:SetPoint("LEFT", swatch, "RIGHT", 8, 0) swatchLabel:SetText("SCT color") -- Applies to both engines (gui/sct.lua and gui/fct.lua) -- never greyed by refreshEnabled(). swatch:SetScript("OnClick", function() local cfg = db() if not (cfg and cfg.sct) then return end local c = cfg.sct.color -- Order matters and is verified against Blizzard 1.12.1 FrameXML (UIDropDownMenu.lua, -- FloatingChatFrame.lua): ColorPickerFrame:SetColorRGB() FIRES .func immediately, so -- func/opacityFunc/cancelFunc/previousValues must all be assigned BEFORE that call -- -- otherwise either a stale callback from whichever addon used the picker last fires, or -- ours fires against state that is not set up yet. Do not reorder this "for tidiness". ColorPickerFrame.func = function() local r, g, b = ColorPickerFrame:GetColorRGB() c.r, c.g, c.b = r, g, b refreshSwatch() end ColorPickerFrame.hasOpacity = nil -- no transparency slider needed ColorPickerFrame.opacityFunc = nil ColorPickerFrame.cancelFunc = function(prevVals) if prevVals then c.r, c.g, c.b = prevVals.r, prevVals.g, prevVals.b end refreshSwatch() end ColorPickerFrame.previousValues = { r = c.r, g = c.g, b = c.b } -- named fields, not an array ColorPickerFrame:SetColorRGB(c.r, c.g, c.b) -- Blizzard's 1.12 colour picker is not draggable out of the box, which is painful when -- it lands on top of what you are trying to recolour. Make it movable once, defensively -- (another addon may already have done it), and leave it that way -- this is a strict -- improvement for every consumer of the frame, not a behaviour change. if not ColorPickerFrame.vfMadeMovable then ColorPickerFrame.vfMadeMovable = true ColorPickerFrame:SetMovable(true) -- already true in 1.12, but be explicit ColorPickerFrame:EnableMouse(true) -- The missing piece: 1.12's picker is movable but registers no drag handler, so -- nothing ever picks it up. Measured in-game: IsMovable() == 1, yet it would not -- budge. ColorPickerFrame:RegisterForDrag("LeftButton") ColorPickerFrame:SetScript("OnDragStart", function() this:StartMoving() end) ColorPickerFrame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) end -- Raise it above this window explicitly. Measured in-game: the picker sits at strata -- MEDIUM, so moving our window down to HIGH was not enough -- HIGH still covers MEDIUM. -- A colour picker is modal in spirit; putting it on top is what the user expects, and -- it stays correct no matter what strata this window ends up at later. ColorPickerFrame:SetFrameStrata("FULLSCREEN_DIALOG") ShowUIPanel(ColorPickerFrame) end) y = y - 26 -- ---- SCT position ---------------------------------------------------------------------- posLabel = W.MakeText(f, 11, C.ink, "ARTWORK") place(posLabel, -18) posLabel:SetText("SCT position") btnMove = W.MakeButton(f, "VampifyOptionsSctMove", "Move", 68, 20) btnMove:SetPoint("TOPLEFT", f, "TOPLEFT", x, y) btnMove:SetScript("OnClick", function() if this.vfDisabled then return end if not (VampifySCT and VampifySCT.setMoveMode and VampifySCT.isMoveMode) then return end local on = not VampifySCT.isMoveMode() VampifySCT.setMoveMode(on) this.text:SetText(on and "Done" or "Move") end) -- No X/Y entry fields. They existed briefly and were removed on sight: after a drag the -- stored offsets are raw anchor coordinates (902.75 / -629.99 on a real screen), which -- carry no meaning for anyone positioning a frame by eye. Dragging IS the interface here; -- a number nobody can reason about is worse than no number. -- Demo: tuning colour, size and height is guesswork without numbers on screen, and nobody -- wants to pull a mob to check a slider. Fires a burst spread over a few seconds so spacing -- and fade are judgeable, not just the colour. btnDemo = W.MakeButton(f, "VampifyOptionsSctDemo", "Demo", 56, 20) btnDemo:SetPoint("LEFT", btnMove, "RIGHT", 10, 0) btnDemo:SetScript("OnClick", function() if this.vfDisabled then return end if VampifySCT and VampifySCT.demo then VampifySCT.demo(10) end end) btnPosReset = W.MakeButton(f, "VampifyOptionsSctPosReset", "Reset", 50, 20) btnPosReset:SetPoint("LEFT", btnDemo, "RIGHT", 10, 0) btnPosReset:SetScript("OnClick", function() if this.vfDisabled then return end local cfg = db() if not (cfg and cfg.sct) then return end local d = VampifyConfig.DEFAULTS.sct.pos cfg.sct.pos = { point = d.point, x = d.x, y = d.y } if VampifySCT and VampifySCT.applyPos then VampifySCT.applyPos() end -- (position fields removed; nothing to mirror) end) y = y - 34 -- ---- SCT sliders ------------------------------------------------------------------- -- Replaces the X/Y entry boxes: raw anchor offsets meant nothing to a human, whereas these -- two are the values you actually want to tune by eye. -- fmt (optional): a string.format pattern for the slider's own value, e.g. "%.1f" for a -- fractional-second slider (Fade duration below, step 0.1). Omitted, values print with a -- plain tostring -- unchanged behaviour for every integer-step slider that existed before it. local function makeSlider(name, label, lo, hi, step, getter, setter, fmt) local sl = CreateFrame("Slider", name, f, "OptionsSliderTemplate") sl:SetWidth(200) sl:SetHeight(16) sl:SetMinMaxValues(lo, hi) sl:SetValueStep(step) sl:SetPoint("TOPLEFT", f, "TOPLEFT", x + 6, y) getglobal(name .. "Low"):SetText(fmt and string.format(fmt, lo) or tostring(lo)) getglobal(name .. "High"):SetText(fmt and string.format(fmt, hi) or tostring(hi)) local cap = getglobal(name .. "Text") W.ApplyFont(cap, 11) sl.vfLabel = label sl.vfSetter = setter sl.vfGetter = getter sl.vfStep = step sl.vfFmt = fmt sl:SetScript("OnValueChanged", function() local v = this:GetValue() -- OptionsSliderTemplate does not quantise for us in 1.12; round to the slider's own -- step by hand. math.floor(v/step+0.5)*step generalises the old "nearest integer" -- rounding (which was step-1-only, hardcoded) to the fractional step a duration-in- -- seconds slider needs (0.1) -- and is a no-op for every existing step-1 slider. v = math.floor(v / this.vfStep + 0.5) * this.vfStep -- Round-trip a fractional value through its own format string (e.g. "%.1f") before -- storing it. The multiply above leaves binary-float noise (1.2000000000000002), -- which would both write an ugly value into SavedVariables and never compare equal -- to the plain decimal literal a getter's own fallback or a test uses -- the -- round-trip snaps it back to the canonical double for "1.2". No-op for integer -- sliders (fmt is nil there). local txt = this.vfFmt and string.format(this.vfFmt, v) or tostring(v) if this.vfFmt then v = tonumber(txt) end this.vfSetter(v) getglobal(this:GetName() .. "Text"):SetText(this.vfLabel .. ": " .. txt) end) y = y - 42 return sl end sliderFont = makeSlider("VampifyOptionsFontSlider", "Font size", 8, 48, 1, function() local cfg = db() return (cfg and cfg.sct and cfg.sct.fontSize) or 16 end, function(v) local cfg = db() if not (cfg and cfg.sct) then return end cfg.sct.fontSize = v if VampifySCT and VampifySCT.applyFont then VampifySCT.applyFont() end end) sliderRise = makeSlider("VampifyOptionsRiseSlider", "SCT height", 20, 320, 5, function() local cfg = db() return (cfg and cfg.sct and cfg.sct.rise) or 40 end, function(v) local cfg = db() if not (cfg and cfg.sct) then return end cfg.sct.rise = v end) -- Fade duration -- how long a number stays on screen (gui/sct.lua's S.spawn does -- "s.dur = sct.duration or DUR_FALLBACK" fresh on every spawn, not cached, so no applyX -- entry point is needed here: the very next number picks up a changed value on its own, -- same as the rise slider above. sliderDuration = makeSlider("VampifyOptionsDurationSlider", "Fade duration", 0.5, 4.0, 0.1, function() local cfg = db() return (cfg and cfg.sct and cfg.sct.duration) or 1.5 end, function(v) local cfg = db() if not (cfg and cfg.sct) then return end cfg.sct.duration = v end, "%.1f") W.AttachTooltip(sliderDuration, "How long a Vampify combat-text number stays on screen" .. " before fading out, in seconds.") -- Text effect. Cycles rather than three radio buttons: it is one setting with three steps, -- and the label always states the current one, so nothing is hidden behind a menu. local OUTLINES = { "THICKOUTLINE", "OUTLINE", "" } local OUTLINE_LABEL = { THICKOUTLINE = "Thick", OUTLINE = "Thin", [""] = "None" } local function outlineText() local cfg = db() local cur = (cfg and cfg.sct and cfg.sct.outline) or "THICKOUTLINE" return "Outline: " .. (OUTLINE_LABEL[cur] or "Thick") end btnOutline = W.MakeButton(f, "VampifyOptionsOutline", outlineText(), 120, 20) btnOutline:SetPoint("TOPLEFT", f, "TOPLEFT", x + 6, y) btnOutline:SetScript("OnClick", function() if this.vfDisabled then return end local cfg = db() if not (cfg and cfg.sct) then return end local cur = cfg.sct.outline or "THICKOUTLINE" local nxt = OUTLINES[1] for i = 1, table.getn(OUTLINES) do if OUTLINES[i] == cur then nxt = OUTLINES[math.mod(i, table.getn(OUTLINES)) + 1] end end cfg.sct.outline = nxt this.text:SetText(outlineText()) if VampifySCT and VampifySCT.applyFont then VampifySCT.applyFont() end if VampifySCT and VampifySCT.demo then VampifySCT.demo(4) end -- show the change at once end) O.refreshOutlineLabel = function() if btnOutline then btnOutline.text:SetText(outlineText()) end end y = y - 28 -- Text shadow -- the other half of "readable over anything" alongside the outline above. -- Getter mirrors gui/sct.lua's own shadowOn(): default ON, an EXPLICIT false is what turns -- it off (a missing/unmigrated key must not read as off). applyFont() re-stamps every -- pooled FontString immediately, same as the outline cycle button above -- otherwise -- already-spawned slots would keep the old shadow state until the pool cycled. local cbShadow = CreateCheckbox(f, "Text shadow", function() local cfg = db() local sct = cfg and cfg.sct if sct and sct.shadow == false then return false end return true end, function(v) local cfg = db() if cfg and cfg.sct then cfg.sct.shadow = v end if VampifySCT and VampifySCT.applyFont then VampifySCT.applyFont() end end) place(cbShadow, -26) W.AttachTooltip(cbShadow, "Drops a shadow behind Vampify's own combat-text numbers, for" .. " readability over bright backgrounds. On by default.") -- ---- display size (2026-08-25, feature request: "two more sliders in the options that -- determine the height of the bar and the detail bars") ----------------------------------- -- Two sliders, same makeSlider() recipe as the SCT ones above, driving VampifyDisplay's own -- PILL_H/BAR_H (compact-bar capsule) and ROW_H/SPLIT_ROW_H/DETAIL_LINE_H (breakdown-panel -- bars) -- see gui/display.lua's own comments on V.PILL_H_MIN/MAX and -- V.PANEL_BAR_H_MIN/MAX for exactly why those two ranges were chosen (the readable-text -- floor and the "too fat" ceiling it was named after). Bounds are read from VampifyDisplay itself -- rather than copied here a second time, so the two files cannot silently drift apart. -- Setters write the SavedVariables field AND call the matching VampifyDisplay.apply* entry -- point immediately -- same split the SCT sliders above already use (VampifySCT.applyFont/ -- applyPos): this file owns persistence, gui/display.lua owns making the change visible with -- no /reload. local sizeLabel = W.MakeText(f, 11, C.ink, "ARTWORK") place(sizeLabel, -18) sizeLabel:SetText("Display size") sliderPillH = makeSlider("VampifyOptionsPillHeightSlider", "Bar height", (VampifyDisplay and VampifyDisplay.PILL_H_MIN) or 8, (VampifyDisplay and VampifyDisplay.PILL_H_MAX) or 40, 1, function() local cfg = db() return (cfg and cfg.pillHeight) or (VampifyDisplay and VampifyDisplay.PILL_H_DEFAULT) or 10 end, function(v) local cfg = db() if cfg then cfg.pillHeight = v end if VampifyDisplay and VampifyDisplay.applyPillHeight then VampifyDisplay.applyPillHeight(v) end end) W.AttachTooltip(sliderPillH, "Height of the compact bar's capsule (the ST/AoE ratio pill)." .. " The bar itself and its drag-resize grip follow automatically.") sliderPanelBarH = makeSlider("VampifyOptionsPanelBarHeightSlider", "Detail bar height", (VampifyDisplay and VampifyDisplay.PANEL_BAR_H_MIN) or 10, (VampifyDisplay and VampifyDisplay.PANEL_BAR_H_MAX) or 40, 1, function() local cfg = db() return (cfg and cfg.panelBarHeight) or (VampifyDisplay and VampifyDisplay.PANEL_BAR_H_DEFAULT) or 18 end, function(v) local cfg = db() if cfg then cfg.panelBarHeight = v end if VampifyDisplay and VampifyDisplay.applyPanelBarHeight then VampifyDisplay.applyPanelBarHeight(v) end end) W.AttachTooltip(sliderPanelBarH, "Height of the ability/split bars in the hover breakdown" .. " panel, and (scaled with them) the row-detail panel's own line height.") -- divider local divider = f:CreateTexture("VampifyOptionsDivider", "ARTWORK") divider:SetHeight(1) divider:SetWidth(292) divider:SetTexture(C.line.r, C.line.g, C.line.b) divider:SetPoint("TOPLEFT", f, "TOPLEFT", x, y - 4) y = y - 16 -- ---- buttons ------------------------------------------------------------------------ local btnReset = W.MakeButton(f, "VampifyOptionsResetTotals", "Reset totals", 138, 22) btnReset:SetPoint("TOPLEFT", f, "TOPLEFT", x, y) -- Goes through the one reset path rather than repeating it. Reassigning VampifyState here -- would ORPHAN the per-spell tables: since persistence landed they point INTO -- VampifyCharDB, so a fresh state would keep recording to tables nothing saves, silently, -- until the next reload re-wired them. VampifyResetSession clears in place for exactly -- that reason. Scope is "session" -- a button labelled "Reset totals" must not wipe the -- lifetime set, which has to be asked for by name. btnReset:SetScript("OnClick", function() if VampifyResetSession then VampifyResetSession("session") end refreshInfo() end) local btnPos = W.MakeButton(f, "VampifyOptionsResetPos", "Reset position", 138, 22) btnPos:SetPoint("TOPLEFT", f, "TOPLEFT", x + 154, y) btnPos:SetScript("OnClick", function() VampifyDisplay.resetPos() end) y = y - 32 -- divider before the info panel local divider2 = f:CreateTexture("VampifyOptionsDivider2", "ARTWORK") divider2:SetHeight(1) divider2:SetWidth(292) divider2:SetTexture(C.line.r, C.line.g, C.line.b) divider2:SetPoint("TOPLEFT", f, "TOPLEFT", x, y) y = y - 12 -- ---- info panel ----------------------------------------------------------------------- infoFS = W.MakeText(f, 10, C.ink, "ARTWORK") infoFS:SetPoint("TOPLEFT", f, "TOPLEFT", x, y) infoFS:SetWidth(292) infoFS:SetJustifyH("LEFT") infoFS:SetJustifyV("TOP") f:SetScript("OnShow", function() refreshChecks() refreshInfo() refreshSwatch() refreshSliders() if O.refreshOutlineLabel then O.refreshOutlineLabel() end refreshEnabled() if btnMove then local on = VampifySCT and VampifySCT.isMoveMode and VampifySCT.isMoveMode() btnMove.text:SetText(on and "Done" or "Move") end elapsed = 0 end) -- Refreshed only while shown: a hidden frame never receives OnUpdate in 1.12, so this is -- naturally free at rest without an extra IsShown() guard. The swatch rides along on the -- same 0.5s tick. The sliders deliberately do NOT -- they are only pushed on OnShow, since -- writing to a slider fires OnValueChanged and would fight the user mid-drag. f:SetScript("OnUpdate", function() elapsed = elapsed + arg1 if elapsed < 0.5 then return end elapsed = 0 refreshInfo() -- (position fields removed; nothing to mirror) end) table.insert(UISpecialFrames, "VampifyOptionsFrame") frame = f f:Hide() return f end function O.toggle() local f = build() if f:IsShown() then f:Hide() else f:Show() end end end