mirror of
https://github.com/fuffc/WeakestAuras.git
synced 2026-11-10 20:07:35 +00:00
ClassicAPI 1.18 is now the only requirement Requirements - Needs ClassicAPI 1.18.0 or newer. SuperWoW is no longer used, and Nampower is optional. - On an older ClassicAPI the addon stays off, and a popup links the download. - Custom triggers on SuperWoW or Nampower events only fire while that mod is installed. Triggers - New Combat Log trigger, with lists of spell IDs, spell names and NPC IDs. WeakAuras2 auras that use one now import. - Custom triggers can listen to the combat log with CLEU or CLEU:SUBEVENT. - Crowd Control matches a specific type (stun, fear, root, silence, disarm and more) or a school lockout, with the effect's real timer. - Saved Crowd Control auras: Root, Silence and Disarm filters carry over; CC and Snare become "any type", and snares are no longer detected. - Cast bars follow pushback and channel updates. Displays - Icon cooldown swipes are drawn by the client's own cooldown frame. - Sound actions play on a chosen channel, and a new Stop sound type ends a looping sound. Fixes - Cooldown auras no longer sweep on every wand shot. - The Global Cooldown trigger ignores wand and Auto Shot shots.
2218 lines
88 KiB
Lua
2218 lines
88 KiB
Lua
-- WeakestAuras -- shared widget factories and the declarative options-table
|
|
-- renderer. Widget look/feel (flat dark backdrop buttons, tooltip-style edit
|
|
-- boxes) follows the sibling Quartermaster config.
|
|
--
|
|
-- WA.Widgets.BuildOptions(page, fields) paints a whole tab's controls from a
|
|
-- plain array of field descriptors instead of hand-building a frame per field:
|
|
-- { type = "header"|"disclosure"|"toggle"|"input"|"multiline"|"range"|"select"
|
|
-- |"color"|"spell"|"item"|"icon"|"namelist"|"opnumber"|"button"|"menu"|"space"|"anchorgrid",
|
|
-- name = "...",
|
|
-- key = "...", -- stable field id, see below; omitted for header/button
|
|
-- get = function() end, set = function(value) end, -- color: get/set a {r,g,b,a}
|
|
-- half = true, -- pack two-per-row (any non-header type); default full row
|
|
-- min, max, step, softMin, softMax, bigStep, -- range only
|
|
-- values, labels, -- select only: ordered values + optional value->label
|
|
-- swatches, -- select only: value -> texture path; each entry (and
|
|
-- -- the button face) previews it as a filled bar
|
|
-- getOp, setOp, getVal, setVal, -- opnumber only: operator select + number
|
|
-- onClick, width } -- button only: name is its label, width its pixel size
|
|
-- A `header` additionally takes `collapsed` (a bool -- present at all makes the
|
|
-- section collapsible: draws an up/down arrow, and the title line itself
|
|
-- toggles) with `onToggle`, `onDelete` (a two-click-confirm delete button), and
|
|
-- `actions` (icon buttons painted left-to-right on the right).
|
|
-- A `select` can also carry `actions`, which are painted beside its dropdown.
|
|
-- Collapsing is the *generator's* job: BuildOptions only paints the affordance,
|
|
-- so a collapsed section simply omits its body fields from the array it returns.
|
|
-- A `disclosure` folds the same way but is a control rather than a divider: a
|
|
-- gear leading a clickable one-line label, for settings that get set once and
|
|
-- then only get in the way. It takes the same `collapsed`/`onToggle`, plus
|
|
-- `summary` -- appended to `name` in grey, so the folded state still says what
|
|
-- the hidden rows hold.
|
|
-- A `range` is a spin box (LibWidgets.NewSpinBox), not a bare slider: `min`/
|
|
-- `max`/`step` drive the track, and the value is typeable inside it. `softMin`/
|
|
-- `softMax` narrow the track without narrowing the field: dragging and stepping
|
|
-- stop there, typing carries on out to `min`/`max` if there are any and without
|
|
-- limit if there are not, and `bigStep` coarsens what a drag or an arrow moves
|
|
-- by while `step` still governs a typed value (WeakAuras2's own four fields,
|
|
-- which its spin box treats the same way).
|
|
-- `code` is a `multiline` for user-authored Lua: syntax-coloured per keystroke
|
|
-- unless WeakestAurasDB.codeEditorLive is turned off (/wa codelive), in which
|
|
-- case colouring waits for blur. With
|
|
-- `height`, `validate(text) -> errOrNil` (a red line under the box, re-run on
|
|
-- every keystroke -- W.LuaSyntaxError is what both call sites pass) and
|
|
-- `default` (string or function; adds a two-click-confirm Reset button that
|
|
-- seeds the box back to it, and is what a never-configured field opens at --
|
|
-- committed, not just shown, which is why a `code` field's `get` returns the
|
|
-- raw stored value rather than `... or ""`: nil means never set, "" means the
|
|
-- user cleared it and must stay cleared). The error line and Reset belong to
|
|
-- the widget (LibWidgets.NewCodeEditBox), not to this renderer.
|
|
-- `spell` renders a spell-name/ID box with a live icon preview (numeric -> its
|
|
-- spell icon); `item` is the same idea keyed by WA.ResolveItemID/GetItemInfo
|
|
-- instead. `icon` is the same box but stores a resolved texture path (a
|
|
-- spellID resolves to its icon; a raw path is kept as-is) -- the manual-icon
|
|
-- picker's stopgap input until a browsing widget lands. `opnumber` is an
|
|
-- operator dropdown + number box on one line, for
|
|
-- "stacks >= N" style filters. `menu` is a drop button whose face stays fixed
|
|
-- at `name` (it labels an action, e.g. "+ Add Display Effect") and whose entries
|
|
-- are `values`/`labels`, calling `onSelect(value)` -- one control in place of a
|
|
-- row of near-identical add buttons. `namelist` is an add/remove/reorder list of
|
|
-- plain strings (LibWidgets.NewListEditor) -- `get` returns the live backing
|
|
-- array (mutated in place), `onChange` fires after any add/remove/reorder.
|
|
-- `half` lays a field in a two-column grid: the
|
|
-- first half goes left, the next right; a full-width field or header closes the
|
|
-- row. Columns size to the page's current width.
|
|
-- Region/trigger type option generators (Regions.lua, Triggers.lua) return one
|
|
-- of these arrays per aura; OptionsFrame.lua just hands it to BuildOptions.
|
|
--
|
|
-- Repaints reuse the widgets already on the page rather than minting new ones
|
|
-- (frames can't be destroyed on this client, so the old approach leaked a page
|
|
-- of controls per tab switch) -- see the widget pool below.
|
|
--
|
|
-- `key` is redundant with get/set (BuildOptions never reads it) but is the
|
|
-- field's stable identity: get/set are closures bound to one specific `data`
|
|
-- table, so nothing outside them can tell which property a field maps to, and
|
|
-- editing a field across N selected auras at once needs that identity to match
|
|
-- "the same field" between one aura's descriptor array and another's. Every
|
|
-- settable field carries one -- tools/optionsim's key gate enforces it. The
|
|
-- conventions:
|
|
-- - the key names the property the set writes (data.<key> for region fields,
|
|
-- data.triggers[n].trigger.<key> for trigger fields); a nested path is
|
|
-- dotted ("information.ignoreOptionsEventErrors")
|
|
-- - a field writing several properties as a unit gets one coined name
|
|
-- ("message_color" for action.r/g/b)
|
|
-- - a "__" prefix names a session-local control (a search box) whose set
|
|
-- never touches aura data
|
|
-- - foldable/deletable section headers carry the section's own key
|
|
-- ("trigger:<n>", "sub:<n>:<type>", "region:<name>"), which is what
|
|
-- qualifies a field key that repeats across sections
|
|
-- - `scope = "tab"` marks a field whose identity is tab-wide rather than
|
|
-- section-local: the trailing structural add buttons, which sit after the
|
|
-- last section and would otherwise take that section's qualification and
|
|
-- stop matching between auras with different section counts
|
|
-- - `soloOnly` marks a field that never joins a merged (multi-selection)
|
|
-- array: an identity (Rename), an op whose fan-out is wrong (Delete's
|
|
-- two-click confirm), per-aura prose (warnings, provenance)
|
|
-- - `tristate` is stamped by the merge onto merged toggles alone: their nil
|
|
-- read is real disagreement and draws the indeterminate dash, where an
|
|
-- ordinary toggle's nil just means false
|
|
-- - `tooltip` ({ title, lines }) hangs a hover zone over the field's
|
|
-- caption -- the merge attaches one to a disagreeing field listing each
|
|
-- member's own value.
|
|
|
|
if WeakestAuras.disabled then return end
|
|
|
|
local WA = WeakestAuras
|
|
WA.Widgets = {}
|
|
local W = WA.Widgets
|
|
local OPTIONS_INDENT_W = 14
|
|
|
|
-- Absolute path to the vendored LibWidgets textures. The library can't discover
|
|
-- its own path (no debug library here), so callers pass it in. Handed to widgets
|
|
-- that need their own art, e.g. the drop button's menu-affordance arrow.
|
|
W.LIBWIDGETS_TEXTURES = "Interface\\AddOns\\WeakestAuras\\libs\\LibWidgets\\textures\\"
|
|
W.DUPLICATE_TEXTURE = "Interface\\AddOns\\WeakestAuras\\textures\\duplicate.tga"
|
|
|
|
function W.DeleteAction(onClick)
|
|
return { icon = LibWidgets.ICON_DELETE, tooltip = "Delete", onClick = onClick }
|
|
end
|
|
|
|
-- Duplicate/up/down/delete for a positional list, as a header `actions` array.
|
|
-- `mutators` replaces any of the four with a handler taking (list, index); the
|
|
-- ones it omits get the plain list mutation, and `onChanged` runs after either.
|
|
--
|
|
-- A list whose *index* is addressed from outside the list must override every
|
|
-- key it can reach: the plain mutations renumber nothing, so anything holding an
|
|
-- index (data.subRegions' `sub.<n>.<key>` condition properties) silently
|
|
-- retargets at whatever moves into the slot. The bounds checks stay here so a
|
|
-- mutator never has to repeat them.
|
|
--
|
|
-- `mutators.delete = false` drops the delete action entirely, for a header that
|
|
-- already carries its own `onDelete`: that one is the arming two-click button,
|
|
-- which a plain action icon is not. `mutators.duplicate = false` drops that one
|
|
-- the same way, for a row a second copy of would be meaningless (the enforced
|
|
-- sub-region standing for the region's own art).
|
|
function W.ListActions(list, index, onChanged, mutators)
|
|
mutators = mutators or {}
|
|
local actions = {}
|
|
if mutators.duplicate ~= false then
|
|
table.insert(actions, { icon = W.DUPLICATE_TEXTURE, tooltip = "Duplicate", onClick = function()
|
|
if mutators.duplicate then
|
|
mutators.duplicate(list, index)
|
|
else
|
|
table.insert(list, index + 1, WA.DeepCopy(list[index]))
|
|
end
|
|
onChanged()
|
|
end })
|
|
end
|
|
table.insert(actions, { icon = W.LIBWIDGETS_TEXTURES .. "up", tooltip = "Move Up", onClick = function()
|
|
if index > 1 then
|
|
if mutators.moveUp then
|
|
mutators.moveUp(list, index)
|
|
else
|
|
list[index - 1], list[index] = list[index], list[index - 1]
|
|
end
|
|
onChanged()
|
|
end
|
|
end })
|
|
table.insert(actions, { icon = W.LIBWIDGETS_TEXTURES .. "down", tooltip = "Move Down", onClick = function()
|
|
if index < #list then
|
|
if mutators.moveDown then
|
|
mutators.moveDown(list, index)
|
|
else
|
|
list[index + 1], list[index] = list[index], list[index + 1]
|
|
end
|
|
onChanged()
|
|
end
|
|
end })
|
|
if mutators.delete ~= false then
|
|
table.insert(actions, W.DeleteAction(function()
|
|
if mutators.delete then
|
|
mutators.delete(list, index)
|
|
else
|
|
table.remove(list, index)
|
|
end
|
|
onChanged()
|
|
end))
|
|
end
|
|
return actions
|
|
end
|
|
|
|
-- Status bar art. The names are LibWidgets' (so every consumer offers the same
|
|
-- set); the files are this addon's, for the same no-self-path reason as above.
|
|
W.BAR_TEXTURE_DIR = "Interface\\AddOns\\WeakestAuras\\textures\\bars\\"
|
|
|
|
-- Three-state eye for the aura list's forced-visibility toggle: full / partial /
|
|
-- empty. These are the frames WA2 reads out of Interface\LFGFrame, which this
|
|
-- client does not ship -- AshenBannerLFG had to vendor its own copies for its
|
|
-- minimap eye, which is why ours are vendored too rather than pathed at the
|
|
-- stock location. Referencing that addon's copies directly would blank the
|
|
-- indicator if it were ever uninstalled, and blank is indistinguishable from
|
|
-- the empty state.
|
|
W.EYE_TEXTURES = "Interface\\AddOns\\WeakestAuras\\textures\\eye\\"
|
|
|
|
-- The code editor's fixed-width face. Same no-self-path reason as above: the
|
|
-- library can't discover its own path, so the consumer supplies it. Vendored
|
|
-- under fonts\ (not a .toc entry -- see pack.ps1's $fontsDir block).
|
|
W.CODE_FONT = "Interface\\AddOns\\WeakestAuras\\fonts\\RobotoMono.ttf"
|
|
-- RobotoMono runs large for its nominal size, so the code boxes sit well below
|
|
-- the UI's usual body size. Only the default: WeakestAurasDB.codeEditorFontSize
|
|
-- overrides it (/wa codefont <6-16>), and the widget takes it per paint.
|
|
W.CODE_FONT_SIZE = 9
|
|
-- The client's own guild-MOTD horn, 16x16 and amber, which is also what a drop
|
|
-- button's row preview draws with nothing passed -- taken from the library rather
|
|
-- than respelled so the two cannot drift apart. Shared with the `sound` warning
|
|
-- severity, so the mark that means "play this" in the picker means "this aura
|
|
-- plays one" on the aura list's row.
|
|
--
|
|
-- Spelled out as a fallback because an older LibWidgets copy can win the version
|
|
-- race and publish no such field (see LIBWIDGETS_DEV).
|
|
W.SOUND_PREVIEW_TEXTURE = LibWidgets.PREVIEW_TEXTURE
|
|
or "Interface\\Buttons\\UI-GuildButton-MOTD-Up"
|
|
|
|
-- Falls back to the client's own bar art rather than erroring, so an older
|
|
-- LibWidgets copy winning the version race degrades a bar's *look* instead of
|
|
-- taking down the whole options paint (LibWidgetsProblem reports the real cause).
|
|
function W.BarTexturePath(name)
|
|
if not LibWidgets.BarTexturePath then return "Interface\\TargetingFrame\\UI-StatusBar" end
|
|
return LibWidgets.BarTexturePath(W.BAR_TEXTURE_DIR, name)
|
|
end
|
|
|
|
-- Bar textures this addon ships on top of LibWidgets' shared set. They stay
|
|
-- here rather than in LibWidgets.BAR_TEXTURES because that list is shared by
|
|
-- every addon vendoring the library, and only this one ships these files -- a
|
|
-- name added there would offer the other consumers a texture that resolves to
|
|
-- nothing.
|
|
local EXTRA_BAR_TEXTURES = {
|
|
"Clean", "Stripes", "ThinStripes", "ThickStripes",
|
|
"Armory", "Charcoal", "Cilo", "Comet", "Dabs", "DarkBottom",
|
|
"Diagonal", "Frost", "Glass", "Glaze", "Glaze2", "Grid", "Hatched",
|
|
"LiteStep", "Melli", "MelliDark", "Perl2", "Pill", "Smoothv2",
|
|
"Steel", "Striped", "Tube", "Water", "Wglass", "Wisps", "Xeon",
|
|
}
|
|
|
|
-- LibWidgets' set plus ours, built once. Every consumer of the bar list reads
|
|
-- this rather than LibWidgets.BAR_TEXTURES directly, or the two drift.
|
|
function W.BarTextures()
|
|
if not W.barTextures then
|
|
W.barTextures = {}
|
|
local shared = LibWidgets.BAR_TEXTURES or {}
|
|
for i = 1, #shared do
|
|
table.insert(W.barTextures, shared[i])
|
|
end
|
|
for i = 1, #EXTRA_BAR_TEXTURES do
|
|
table.insert(W.barTextures, EXTRA_BAR_TEXTURES[i])
|
|
end
|
|
end
|
|
return W.barTextures
|
|
end
|
|
|
|
-- value -> path map for a `select` field's `swatches`, built once.
|
|
function W.BarTextureSwatches()
|
|
if not W.barSwatches then
|
|
W.barSwatches = {}
|
|
local list = W.BarTextures()
|
|
for i = 1, #list do
|
|
W.barSwatches[list[i]] = W.BarTexturePath(list[i])
|
|
end
|
|
end
|
|
return W.barSwatches
|
|
end
|
|
|
|
-- The LibWidgets surface this file is written against. Every addon vendoring
|
|
-- the library shares one global instance and the highest MINOR wins, so another
|
|
-- addon's older copy can be the one actually running here -- in which case the
|
|
-- first symptom is a nil call in the middle of a repaint, naming a function
|
|
-- rather than the version problem behind it. Check it once at load and record
|
|
-- what's missing; the options window reports it on open and `/wa libs` dumps
|
|
-- it (chat output at file-load time is unreliable on this client, so nothing is
|
|
-- printed from here). libs\LibWidgetsDev.lua is the escape hatch.
|
|
W.LIBWIDGETS_REQUIRED = {
|
|
"NewButton", "NewIconButton", "NewCheckBox", "NewColorSwatch", "NewTextBox",
|
|
"NewMultiLineEditBox", "NewScrollFrame", "NewScrollBar", "NewSpinBox", "NewDropButton",
|
|
"NewListEditor", "NewIconPicker", "GetIconDatabase", "CloseAllMenus", "ClearFocus",
|
|
"FormatNumber", "BarTexturePath",
|
|
"LuaColorize", "LuaEncode", "LuaDecode", "LuaStripColors",
|
|
"LuaPadWithLinebreaks", "LuaNextToken", "NewCodeEditBox", "NewAnchorGrid",
|
|
}
|
|
W.libWidgetsMissing = {}
|
|
for i = 1, #W.LIBWIDGETS_REQUIRED do
|
|
local name = W.LIBWIDGETS_REQUIRED[i]
|
|
if not LibWidgets[name] then table.insert(W.libWidgetsMissing, name) end
|
|
end
|
|
|
|
-- One line describing which LibWidgets copy is live, or nil when it's fine.
|
|
function W.LibWidgetsProblem()
|
|
if #W.libWidgetsMissing == 0 then return nil end
|
|
return "LibWidgets in use is MINOR " .. tostring(LibWidgets.MINOR or "?")
|
|
.. " and is missing " .. table.concat(W.libWidgetsMissing, ", ")
|
|
.. " -- another addon's older copy won the LibStub version race."
|
|
end
|
|
|
|
W.PANEL_BACKDROP = {
|
|
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
|
|
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
|
|
tile = true, tileSize = 32, edgeSize = 16,
|
|
insets = { left = 5, right = 5, top = 5, bottom = 5 },
|
|
}
|
|
W.EDITBOX_BACKDROP = {
|
|
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
|
|
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
|
|
tile = true, tileSize = 16, edgeSize = 9,
|
|
insets = { left = 3, right = 3, top = 3, bottom = 3 },
|
|
}
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Base controls
|
|
--
|
|
-- Thin adapters over LibWidgets.New* (libs\LibWidgets) for the panel's own
|
|
-- chrome -- the controls that are built once and live for the session (search
|
|
-- box, tab strip, the buttons around the aura list). Everything inside the
|
|
-- options page itself goes through the pooled builders further down instead.
|
|
-- New *generic* controls belong in LibWidgets, not here.
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- A live-filter edit box: fires onChange(text) on every keystroke and shows a
|
|
-- greyed "Search" hint while empty. Escape clears the text (not just focus),
|
|
-- which the shared NewTextBox leaves to the caller.
|
|
function W.searchbox(parent, width, onChange)
|
|
local e = LibWidgets.NewTextBox(parent, { width = width, height = 20, hint = "Search", onChange = onChange })
|
|
e:SetScript("OnEscapePressed", function() this:SetText(""); this:ClearFocus() end)
|
|
return e
|
|
end
|
|
|
|
function W.sectionHeader(parent, text)
|
|
local fs = parent:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
|
|
fs:SetJustifyH("LEFT")
|
|
fs:SetText(text)
|
|
fs:SetTextColor(1, 0.82, 0)
|
|
return fs
|
|
end
|
|
|
|
function W.fieldLabel(parent, text)
|
|
local fs = parent:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
|
|
fs:SetJustifyH("LEFT")
|
|
fs:SetText(text)
|
|
return fs
|
|
end
|
|
|
|
function W.button(parent, text, onClick)
|
|
return LibWidgets.NewButton(parent, { text = text, onClick = onClick })
|
|
end
|
|
|
|
-- Icon-only toolbar button. Mirrors WA2's WeakAurasToolbarButton, minus the
|
|
-- text label: its toolbar sits over a left column that grows with the window
|
|
-- (~290px at that addon's default size), ours over one fixed at 200, which has
|
|
-- room for icons and tooltips only.
|
|
--
|
|
-- setToggled marks the buttons standing for a persistent mode rather than a
|
|
-- one-shot action, matching the strong-highlight state upstream gives Lock
|
|
-- Positions and Magnetically Align.
|
|
-- `label`, when given, puts the caption beside the icon and widens the button to
|
|
-- fit it; without one the button stays the 22px square the icon fills. The
|
|
-- tooltip is kept either way -- it carries the second explanatory line a caption
|
|
-- has no room for.
|
|
function W.toolbarButton(parent, icon, tooltip, onClick, label)
|
|
local b = CreateFrame("Button", nil, parent)
|
|
b:SetWidth(22); b:SetHeight(22)
|
|
|
|
local on = b:CreateTexture(nil, "BACKGROUND")
|
|
on:SetAllPoints(b)
|
|
on:SetTexture(0.9, 0.8, 0.2, 0.25)
|
|
on:Hide()
|
|
|
|
-- 16px hard-sized and pinned left rather than inset from both corners: the
|
|
-- two agree exactly on an unlabelled 22px button, and only this form survives
|
|
-- the button growing to fit a caption.
|
|
local tex = b:CreateTexture(nil, "ARTWORK")
|
|
tex:SetWidth(16); tex:SetHeight(16)
|
|
tex:SetPoint("LEFT", b, "LEFT", 3, 0)
|
|
tex:SetTexture(icon)
|
|
b.icon = tex
|
|
|
|
if label then
|
|
local fs = b:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
|
|
fs:SetPoint("LEFT", tex, "RIGHT", 4, 0)
|
|
fs:SetJustifyH("LEFT")
|
|
fs:SetText(label)
|
|
b.label = fs
|
|
b:SetWidth(3 + 16 + 4 + (fs:GetStringWidth() or 0) + 6)
|
|
end
|
|
|
|
local hl = b:CreateTexture(nil, "HIGHLIGHT")
|
|
hl:SetAllPoints(b)
|
|
hl:SetTexture(1, 1, 1, 0.15)
|
|
|
|
function b.setToggled(v)
|
|
b.toggled = v and true or false
|
|
if b.toggled then on:Show() else on:Hide() end
|
|
end
|
|
|
|
b:SetScript("OnEnter", function()
|
|
GameTooltip:SetOwner(this, "ANCHOR_NONE")
|
|
GameTooltip:SetPoint("BOTTOMLEFT", this, "TOPLEFT", 0, 4)
|
|
GameTooltip:SetText(this.tipTitle or "", 1, 1, 1)
|
|
if this.tipDesc then GameTooltip:AddLine(this.tipDesc, 0.8, 0.8, 0.8, true) end
|
|
GameTooltip:Show()
|
|
end)
|
|
b:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
|
b.tipTitle = tooltip
|
|
if onClick then b:SetScript("OnClick", onClick) end
|
|
return b
|
|
end
|
|
|
|
local QUESTION_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
|
|
|
|
-- The `disclosure` field's glyph. A stock item icon rather than vendored art:
|
|
-- 1.12 ships no gear in Interface\Buttons, and this one (Charged Gear, from
|
|
-- Gnomeregan) is a single cog that stays legible shrunk to a 9px button face.
|
|
local GEAR_ICON = "Interface\\Icons\\INV_Misc_Gear_01"
|
|
|
|
-- One shared icon-browser dialog behind every `icon` field's Browse button.
|
|
-- Built on first use, not at load: LibWidgets.GetIconDatabase's first call walks
|
|
-- the client's whole icon set, which is not worth paying for unless the user
|
|
-- actually opens the picker. The pick is routed through a stored callback rather
|
|
-- than rebuilding the dialog per field.
|
|
function W.OpenIconPicker(current, onPick)
|
|
if not LibWidgets.NewIconPicker then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff4040WeakestAuras:|r "
|
|
.. (W.LibWidgetsProblem() or "the loaded LibWidgets has no NewIconPicker."))
|
|
return
|
|
end
|
|
if not W.iconPicker then
|
|
W.iconPicker = LibWidgets.NewIconPicker(UIParent, {
|
|
nameFrame = "WeakestAurasIconPicker",
|
|
title = "Select Icon",
|
|
onAccept = function(path)
|
|
if W.onIconPicked then W.onIconPicked(path) end
|
|
end,
|
|
})
|
|
end
|
|
W.onIconPicked = onPick
|
|
W.iconPicker.Open(current)
|
|
end
|
|
|
|
local TEXTURE_PATH_PREFIX = "Interface\\AddOns\\WeakestAuras\\textures\\"
|
|
local TEXTURE_CATEGORIES = {
|
|
Shapes = {
|
|
"arrows_target.tga", "Circle_AlphaGradient_In.tga", "Circle_AlphaGradient_Out.tga", "circle_border5.tga",
|
|
"Circle_Smooth.tga", "Circle_Smooth2.tga", "Circle_Smooth_Border.tga", "Circle_Squirrel.tga", "Circle_Squirrel_Border.tga",
|
|
"Circle_White.tga", "Circle_White_Border.tga", "Ring_10px.tga", "Ring_20px.tga", "Ring_30px.tga", "Ring_40px.tga",
|
|
"ring_glow3.tga", "Square_AlphaGradient.tga", "square_border_10px.tga", "square_border_1px.tga", "square_border_5px.tga",
|
|
"Square_FullWhite.tga", "square_mini.tga", "Square_Smooth.tga", "Square_Smooth_Border.tga", "Square_Squirrel.tga",
|
|
"Square_Squirrel_Border.tga", "Square_White.tga", "Square_White_Border.tga", "target_indicator.tga",
|
|
"target_indicator_glow.tga", "Trapezoid.tga", "triangle-border.tga", "triangle.tga", "Triangle45.tga",
|
|
},
|
|
Alerts = {
|
|
"ArcaneMissiles.blp", "ArcaneMissiles1.blp", "ArcaneMissiles2.blp", "ArcaneMissiles3.blp", "ArcaneSoul.blp",
|
|
"ArtOfWar.blp", "BacklashGreen.blp", "Backslash.blp", "BanditsGuile.blp", "Berserk.blp",
|
|
"BloodBoil.blp", "BloodSurge.blp", "BrainFreeze.blp", "DarkTiger.blp", "DarkTransformation.blp", "Daybreak.blp",
|
|
"DemonicCore.blp", "DemonicCoreVertical.blp", "Denounce.blp", "EchoOfTheElements.blp", "EclipseMoon.blp", "EclipseSun.blp",
|
|
"EssenceBurst.blp", "FocusFire.blp", "FrozenFingers.blp", "Fulmination.blp", "FuryOfStormrage.blp", "GenericArc1.blp",
|
|
"GenericArc2.blp", "GenericArc3.blp", "GenericArc4.blp", "GenericArc5.blp", "GenericArc6.blp", "GenericTop1.blp",
|
|
"GenericTop2.blp", "GrandCrusader.blp", "HandOfLight.blp", "HighTide.blp", "HotStreak.blp", "Hyperthermia.blp",
|
|
"ImpEmpowerment.blp", "ImpEmpowermentGreen.blp", "Impact.blp", "KillingMachine.blp", "LockAndLoad.blp", "MaelstromWeapon.blp",
|
|
"MaelstromWeapon1.blp", "MaelstromWeapon2.blp", "MaelstromWeapon3.blp", "MaelstromWeapon4.blp", "MasterMarksman.blp",
|
|
"MoltenCore.blp", "MonkBlackoutKick.blp", "MonkOx.blp", "MonkOx2.blp", "MonkOx3.blp", "MonkSerpent.blp",
|
|
"MonkTiger.blp", "MonkTigerPalm.blp", "NatureSGrace.blp", "Necropolis.blp", "Nightfall.blp", "PredatorySwiftness.blp",
|
|
"OmenOfClarityFeral.blp", "PredatorySwiftnessGreen.blp", "RagingBlow.blp", "Rime.blp", "Serendipity.blp", "ShadowOfDeath.blp",
|
|
"ShadowWordInsanity.blp", "ShootingStars.blp",
|
|
"SliceAndDice.blp", "Snapfire.blp", "SuddenDeath.blp", "SuddenDoom.blp", "SurgeOfDarkness.blp", "SurgeOfLight.blp",
|
|
"SpellActivationOverlay0.blp", "SwordAndBoard.blp", "ThrillOfTheHunt1.blp", "ThrillOfTheHunt2.blp", "ThrillOfTheHunt3.blp", "ToothAndClaw.blp",
|
|
"Ultimatum.blp", "WhiteTiger.blp",
|
|
},
|
|
-- Power Auras art, split the four ways upstream splits it. Every name here
|
|
-- also has to be reachable from WA2Import's rewrite of an incoming
|
|
-- PowerAurasMedia\Auras path, which looks the basename up in this table --
|
|
-- so a file dropped from here stops resolving on import as well as in the
|
|
-- picker. Aura146-246 exist upstream of WeakAuras2 but not in it, and are
|
|
-- not bundled: see textures/README.md.
|
|
PowerAurasHeadsUp = {
|
|
"Aura1.tga", "Aura2.tga", "Aura3.tga", "Aura4.tga", "Aura5.tga", "Aura6.tga", "Aura7.tga",
|
|
"Aura11.tga", "Aura16.tga", "Aura17.tga", "Aura18.tga", "Aura23.tga", "Aura24.tga", "Aura28.tga",
|
|
"Aura33.tga",
|
|
},
|
|
PowerAurasIcons = {
|
|
"Aura8.tga", "Aura9.tga", "Aura10.tga", "Aura12.tga", "Aura13.tga", "Aura14.tga", "Aura15.tga",
|
|
"Aura19.tga", "Aura21.tga", "Aura22.tga", "Aura25.tga", "Aura26.tga", "Aura27.tga", "Aura29.tga",
|
|
"Aura30.tga", "Aura31.tga", "Aura32.tga", "Aura34.tga", "Aura35.tga", "Aura36.tga", "Aura45.tga",
|
|
"Aura48.tga", "Aura49.tga", "Aura50.tga", "Aura51.tga", "Aura52.tga", "Aura53.tga", "Aura54.tga",
|
|
"Aura68.tga", "Aura69.tga", "Aura70.tga", "Aura71.tga", "Aura72.tga", "Aura73.tga", "Aura74.tga",
|
|
"Aura75.tga", "Aura76.tga", "Aura77.tga", "Aura78.tga", "Aura79.tga", "Aura84.tga", "Aura85.tga",
|
|
"Aura86.tga", "Aura87.tga", "Aura88.tga", "Aura95.tga", "Aura96.tga", "Aura97.tga", "Aura98.tga",
|
|
"Aura99.tga", "Aura100.tga", "Aura101.tga", "Aura102.tga", "Aura103.tga", "Aura110.tga",
|
|
"Aura111.tga", "Aura112.tga", "Aura113.tga", "Aura114.tga", "Aura115.tga", "Aura116.tga",
|
|
"Aura117.tga", "Aura118.tga", "Aura119.tga", "Aura120.tga", "Aura130.tga", "Aura131.tga",
|
|
"Aura132.tga", "Aura138.tga", "Aura139.tga", "Aura140.tga", "Aura141.tga", "Aura142.tga",
|
|
"Aura143.tga",
|
|
},
|
|
PowerAurasSeparated = {
|
|
"Aura46.tga", "Aura47.tga", "Aura55.tga", "Aura56.tga", "Aura57.tga", "Aura58.tga", "Aura59.tga",
|
|
"Aura60.tga", "Aura61.tga", "Aura62.tga", "Aura63.tga", "Aura64.tga", "Aura65.tga", "Aura66.tga",
|
|
"Aura67.tga", "Aura80.tga", "Aura81.tga", "Aura82.tga", "Aura83.tga", "Aura89.tga", "Aura90.tga",
|
|
"Aura91.tga", "Aura92.tga", "Aura93.tga", "Aura94.tga", "Aura104.tga", "Aura105.tga", "Aura106.tga",
|
|
"Aura107.tga", "Aura108.tga", "Aura109.tga", "Aura121.tga", "Aura122.tga", "Aura123.tga",
|
|
"Aura124.tga", "Aura125.tga", "Aura126.tga", "Aura127.tga", "Aura128.tga", "Aura129.tga",
|
|
"Aura133.tga", "Aura134.tga", "Aura135.tga", "Aura136.tga", "Aura137.tga", "Aura144.tga",
|
|
"Aura145.tga",
|
|
},
|
|
PowerAurasWords = {
|
|
"Aura20.tga", "Aura37.tga", "Aura38.tga", "Aura39.tga", "Aura40.tga", "Aura41.tga", "Aura42.tga",
|
|
"Aura43.tga", "Aura44.tga",
|
|
},
|
|
}
|
|
WA.textureTypes = TEXTURE_CATEGORIES
|
|
|
|
-- All four Power Auras categories draw from one folder, so the category name
|
|
-- cannot be lowercased into the path the way Shapes and Alerts are.
|
|
local TEXTURE_CATEGORY_DIRS = {
|
|
PowerAurasHeadsUp = "powerauras", PowerAurasIcons = "powerauras",
|
|
PowerAurasSeparated = "powerauras", PowerAurasWords = "powerauras",
|
|
}
|
|
local TEXTURE_CATEGORY_VALUES = {
|
|
"Alerts", "Shapes",
|
|
"PowerAurasHeadsUp", "PowerAurasIcons", "PowerAurasSeparated", "PowerAurasWords",
|
|
}
|
|
local TEXTURE_CATEGORY_LABELS = {
|
|
Alerts = "Blizzard Alerts", Shapes = "Shapes",
|
|
PowerAurasHeadsUp = "Power Auras: Heads-Up", PowerAurasIcons = "Power Auras: Icons",
|
|
PowerAurasSeparated = "Power Auras: Separated", PowerAurasWords = "Power Auras: Words",
|
|
}
|
|
|
|
-- Full path to one bundled texture. Public because WA2Import builds its rewrite
|
|
-- index off WA.textureTypes and has to form the same paths the picker stores.
|
|
function W.TexturePath(category, name)
|
|
local dir = TEXTURE_CATEGORY_DIRS[category] or string.lower(category)
|
|
return TEXTURE_PATH_PREFIX .. dir .. "\\" .. name
|
|
end
|
|
local texturePath = W.TexturePath
|
|
|
|
-- lowercased file name -> the category holding it, for the categories whose
|
|
-- folder does not name them. Built on first use, since the four Power Auras
|
|
-- lists share one folder and the path alone cannot say which tab to open on.
|
|
local powerAurasCategory
|
|
local function powerAurasCategoryFor(name)
|
|
if not powerAurasCategory then
|
|
powerAurasCategory = {}
|
|
for i = 1, #TEXTURE_CATEGORY_VALUES do
|
|
local category = TEXTURE_CATEGORY_VALUES[i]
|
|
if TEXTURE_CATEGORY_DIRS[category] == "powerauras" then
|
|
local list = TEXTURE_CATEGORIES[category]
|
|
for j = 1, #list do
|
|
powerAurasCategory[string.lower(list[j])] = category
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return powerAurasCategory[string.lower(name or "")]
|
|
end
|
|
|
|
function W.OpenTexturePicker(current, onPick)
|
|
if not LibWidgets.NewIconPicker then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff4040WeakestAuras:|r "
|
|
.. (W.LibWidgetsProblem() or "the loaded LibWidgets has no texture picker support."))
|
|
return
|
|
end
|
|
if not W.texturePicker then
|
|
W.texturePicker = LibWidgets.NewIconPicker(UIParent, {
|
|
nameFrame = "WeakestAurasTexturePicker",
|
|
title = "Select Texture",
|
|
categories = TEXTURE_CATEGORIES,
|
|
categoryValues = TEXTURE_CATEGORY_VALUES,
|
|
categoryLabels = TEXTURE_CATEGORY_LABELS,
|
|
defaultCategory = "Shapes",
|
|
pathFor = function(name, category) return texturePath(category or "Shapes", name) end,
|
|
normalize = function(value)
|
|
local _, _, name = string.find(string.lower(value or ""), "textures\\[^\\]+\\([^\\]+)$")
|
|
return name or value
|
|
end,
|
|
categoryFor = function(value)
|
|
local lower = string.lower(value or "")
|
|
if string.find(lower, "textures\\alerts\\", 1, true) then return "Alerts" end
|
|
if string.find(lower, "textures\\powerauras\\", 1, true) then
|
|
local _, _, name = string.find(lower, "([^\\]+)$")
|
|
return powerAurasCategoryFor(name) or "PowerAurasIcons"
|
|
end
|
|
return "Shapes"
|
|
end,
|
|
labelFor = function(name) return name end,
|
|
onAccept = function(path)
|
|
if W.onTexturePicked then W.onTexturePicked(path) end
|
|
end,
|
|
})
|
|
end
|
|
W.onTexturePicked = onPick
|
|
W.texturePicker.Open(current)
|
|
end
|
|
|
|
function W.CloseTexturePicker()
|
|
if W.texturePicker then W.texturePicker.Close() end
|
|
end
|
|
|
|
function W.CloseIconPicker()
|
|
if W.iconPicker then W.iconPicker.Close() end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Model browser
|
|
--
|
|
-- The icon and texture pickers browse art a grid cell can paint; a model
|
|
-- cannot be painted into a tile, so this is a name list beside one live
|
|
-- PlayerModel preview. Fed from WA2ModelIDs.lua's packed string -- the models
|
|
-- this client provably contains -- indexed per top directory on first open, so
|
|
-- a session that never browses pays nothing.
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local MODEL_CATEGORY_VALUES = { "creature", "spells", "character", "item", "world", "interface", "particles" }
|
|
local MODEL_CATEGORY_LABELS = {
|
|
creature = "Creatures", spells = "Spells", character = "Characters",
|
|
item = "Items", world = "World", interface = "Interface", particles = "Particles",
|
|
}
|
|
|
|
local modelIndex
|
|
local function modelListFor(category)
|
|
if not modelIndex then
|
|
modelIndex = {}
|
|
for i = 1, #MODEL_CATEGORY_VALUES do
|
|
modelIndex[MODEL_CATEGORY_VALUES[i]] = {}
|
|
end
|
|
for path in string.gmatch(WA.wa2ModelIDs or "", "%d+,([^;]+)") do
|
|
local _, _, top, rest = string.find(path, "^([^/]+)/(.+)$")
|
|
local list = top and modelIndex[top]
|
|
if list then table.insert(list, rest) end
|
|
end
|
|
for i = 1, #MODEL_CATEGORY_VALUES do
|
|
table.sort(modelIndex[MODEL_CATEGORY_VALUES[i]])
|
|
end
|
|
end
|
|
return modelIndex[category] or {}
|
|
end
|
|
|
|
-- The stored form: backslashes and the .mdx spelling SetModel provably takes.
|
|
local function modelPickPath(category, rest)
|
|
return string.gsub(category .. "/" .. rest, "/", "\\") .. ".mdx"
|
|
end
|
|
|
|
-- The stored form back to (category, rest), for opening the dialog on the
|
|
-- model already in use. nil for a path outside the indexed trees.
|
|
local function modelPickParse(value)
|
|
if type(value) ~= "string" or value == "" then return nil end
|
|
local low = string.lower(string.gsub(value, "\\", "/"))
|
|
low = string.gsub(low, "%.md[xl]$", "")
|
|
low = string.gsub(low, "%.m2$", "")
|
|
local _, _, top, rest = string.find(low, "^([^/]+)/(.+)$")
|
|
if top and MODEL_CATEGORY_LABELS[top] then return top, rest end
|
|
return nil
|
|
end
|
|
|
|
local MODEL_ROWS = 14
|
|
local MODEL_ROW_H = 16
|
|
|
|
local function buildModelPicker()
|
|
local scrollName = "WeakestAurasModelPickerScroll"
|
|
local pad = 10
|
|
local listW, previewW = 290, 200
|
|
local listH = MODEL_ROWS * MODEL_ROW_H + 8
|
|
|
|
local frame = CreateFrame("Frame", "WeakestAurasModelPickerDialog", UIParent)
|
|
frame:SetWidth(listW + previewW + pad * 3)
|
|
frame:SetHeight(listH + 124)
|
|
frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
|
|
frame:SetBackdrop(W.EDITBOX_BACKDROP)
|
|
frame:SetBackdropColor(0, 0, 0, 0.92)
|
|
frame:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.9)
|
|
frame:SetFrameStrata("FULLSCREEN_DIALOG")
|
|
frame:SetToplevel(true)
|
|
frame:EnableMouse(true)
|
|
frame:SetMovable(true)
|
|
frame:RegisterForDrag("LeftButton")
|
|
frame:SetScript("OnDragStart", function() this:StartMoving() end)
|
|
frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
|
|
frame:SetScript("OnMouseDown", function() LibWidgets.CloseAllMenus() end)
|
|
frame:SetScript("OnHide", function() LibWidgets.CloseAllMenus() end)
|
|
frame:Hide()
|
|
|
|
local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
|
|
title:SetPoint("TOPLEFT", pad, -pad)
|
|
title:SetText("Select Model")
|
|
|
|
local count = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
|
|
count:SetPoint("TOPRIGHT", -pad, -pad - 2)
|
|
count:SetJustifyH("RIGHT")
|
|
|
|
local selectedCategory, selectedRest = "spells", nil
|
|
local filtered = {}
|
|
local refresh, applyFilter
|
|
|
|
local search = LibWidgets.NewTextBox(frame, {
|
|
width = 150, height = 20, hint = "Search",
|
|
onChange = function(text) applyFilter(text) end,
|
|
})
|
|
search:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -pad, -pad - 22)
|
|
search:SetScript("OnEscapePressed", function() this:SetText(""); this:ClearFocus() end)
|
|
|
|
local categoryButton = LibWidgets.NewDropButton(frame, {
|
|
values = MODEL_CATEGORY_VALUES,
|
|
labels = MODEL_CATEGORY_LABELS,
|
|
width = 130, height = 20,
|
|
menuParent = frame,
|
|
get = function() return selectedCategory end,
|
|
onSelect = function(value)
|
|
selectedCategory = value
|
|
selectedRest = nil
|
|
applyFilter(search:GetText() or "")
|
|
end,
|
|
})
|
|
categoryButton:SetPoint("TOPLEFT", frame, "TOPLEFT", pad, -pad - 22)
|
|
|
|
local list = CreateFrame("Frame", nil, frame)
|
|
list:SetPoint("TOPLEFT", pad, -pad - 48)
|
|
list:SetWidth(listW)
|
|
list:SetHeight(listH)
|
|
list:SetBackdrop(W.EDITBOX_BACKDROP)
|
|
list:SetBackdropColor(0, 0, 0, 0.5)
|
|
list:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.8)
|
|
|
|
local scroll = CreateFrame("ScrollFrame", scrollName, list, "FauxScrollFrameTemplate")
|
|
scroll:SetPoint("TOPLEFT", list, "TOPLEFT", 4, -4)
|
|
scroll:SetPoint("BOTTOMRIGHT", list, "BOTTOMRIGHT", -22, 4)
|
|
|
|
-- The live preview. Configuration rides wa* fields plus an OnShow
|
|
-- re-apply, because a hidden model drops its geometry.
|
|
local previewBox = CreateFrame("Frame", nil, frame)
|
|
previewBox:SetPoint("TOPLEFT", list, "TOPRIGHT", pad, 0)
|
|
previewBox:SetWidth(previewW)
|
|
previewBox:SetHeight(listH)
|
|
previewBox:SetBackdrop(W.EDITBOX_BACKDROP)
|
|
previewBox:SetBackdropColor(0, 0, 0, 0.5)
|
|
previewBox:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.8)
|
|
local previewApply = function(model)
|
|
if model.waPath then
|
|
-- A display that dresses the path textures the preview; the raw
|
|
-- path is the white fallback (skins live in CreatureDisplayInfo,
|
|
-- which SetModel never consults).
|
|
local display = WA.ResolveModelDisplay(model.waPath)
|
|
if display then
|
|
model:SetDisplayInfo(display)
|
|
else
|
|
pcall(model.SetModel, model, model.waPath)
|
|
end
|
|
pcall(model.SetPosition, model, 0, 0, 0)
|
|
pcall(model.SetFacing, model, 0)
|
|
else
|
|
pcall(model.ClearModel, model)
|
|
end
|
|
end
|
|
local preview
|
|
local ok, built = pcall(CreateFrame, "PlayerModel", nil, previewBox)
|
|
if ok and built then
|
|
preview = built
|
|
preview:SetPoint("TOPLEFT", previewBox, "TOPLEFT", 4, -4)
|
|
preview:SetPoint("BOTTOMRIGHT", previewBox, "BOTTOMRIGHT", -4, 4)
|
|
preview:SetScript("OnShow", function() previewApply(this) end)
|
|
-- The model renders where the frame sat at apply time and does not
|
|
-- follow a later move (Regions.lua's modelOnUpdate documents the
|
|
-- quirk), so re-apply when the dialog stops moving.
|
|
preview:SetScript("OnUpdate", function()
|
|
local left, top = this:GetLeft(), this:GetTop()
|
|
if left ~= this.waLeft or top ~= this.waTop then
|
|
this.waLeft, this.waTop = left, top
|
|
this.waMoved = left and true or nil
|
|
elseif this.waMoved then
|
|
this.waMoved = nil
|
|
previewApply(this)
|
|
end
|
|
end)
|
|
end
|
|
local previewLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
|
|
previewLabel:SetPoint("TOPLEFT", previewBox, "BOTTOMLEFT", 2, -4)
|
|
previewLabel:SetPoint("TOPRIGHT", previewBox, "BOTTOMRIGHT", -2, -4)
|
|
previewLabel:SetJustifyH("LEFT")
|
|
|
|
local function setPreview(rest)
|
|
if preview then
|
|
preview.waPath = rest and modelPickPath(selectedCategory, rest) or nil
|
|
previewApply(preview)
|
|
end
|
|
previewLabel:SetText(rest or "")
|
|
end
|
|
|
|
local rows = {}
|
|
local function rowAt(i)
|
|
local r = rows[i]
|
|
if r then return r end
|
|
r = CreateFrame("Button", nil, list)
|
|
r:SetWidth(listW - 30)
|
|
r:SetHeight(MODEL_ROW_H)
|
|
r:SetPoint("TOPLEFT", list, "TOPLEFT", 6, -4 - (i - 1) * MODEL_ROW_H)
|
|
local hl = r:CreateTexture(nil, "HIGHLIGHT")
|
|
hl:SetAllPoints(r)
|
|
hl:SetTexture(1, 0.82, 0, 0.2)
|
|
local fs = r:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
|
|
fs:SetPoint("LEFT", 2, 0)
|
|
fs:SetPoint("RIGHT", -2, 0)
|
|
fs:SetJustifyH("LEFT")
|
|
r.text = fs
|
|
r:SetScript("OnClick", function()
|
|
LibWidgets.CloseAllMenus()
|
|
if not this.rest then return end
|
|
selectedRest = this.rest
|
|
setPreview(selectedRest)
|
|
refresh()
|
|
end)
|
|
rows[i] = r
|
|
return r
|
|
end
|
|
|
|
refresh = function()
|
|
local n = #filtered
|
|
FauxScrollFrame_Update(scroll, n, MODEL_ROWS, MODEL_ROW_H)
|
|
local offset = FauxScrollFrame_GetOffset(scroll)
|
|
for i = 1, MODEL_ROWS do
|
|
local r = rowAt(i)
|
|
local rest = filtered[offset + i]
|
|
if rest then
|
|
r.rest = rest
|
|
r.text:SetText(rest)
|
|
if rest == selectedRest then
|
|
r.text:SetTextColor(1, 0.82, 0)
|
|
else
|
|
r.text:SetTextColor(1, 1, 1)
|
|
end
|
|
r:Show()
|
|
else
|
|
r.rest = nil
|
|
r:Hide()
|
|
end
|
|
end
|
|
count:SetText(n .. (n == 1 and " model" or " models"))
|
|
end
|
|
|
|
applyFilter = function(text)
|
|
local all = modelListFor(selectedCategory)
|
|
filtered = {}
|
|
if not text or text == "" then
|
|
for i = 1, #all do filtered[i] = all[i] end
|
|
else
|
|
local needle = string.lower(text)
|
|
for i = 1, #all do
|
|
if string.find(all[i], needle, 1, true) then table.insert(filtered, all[i]) end
|
|
end
|
|
end
|
|
local bar = getglobal(scrollName .. "ScrollBar")
|
|
if bar then bar:SetValue(0) end
|
|
setPreview(selectedRest)
|
|
refresh()
|
|
end
|
|
|
|
scroll:SetScript("OnVerticalScroll", function()
|
|
FauxScrollFrame_OnVerticalScroll(MODEL_ROW_H, refresh)
|
|
end)
|
|
local function wheel()
|
|
local bar = getglobal(scrollName .. "ScrollBar")
|
|
if bar then bar:SetValue(bar:GetValue() - arg1 * MODEL_ROW_H * 3) end
|
|
end
|
|
scroll:EnableMouseWheel(true); scroll:SetScript("OnMouseWheel", wheel)
|
|
list:EnableMouseWheel(true); list:SetScript("OnMouseWheel", wheel)
|
|
|
|
local function scrollToSelected()
|
|
if not selectedRest then return end
|
|
for i = 1, #filtered do
|
|
if filtered[i] == selectedRest then
|
|
local bar = getglobal(scrollName .. "ScrollBar")
|
|
if bar then
|
|
local target = (i - 1 - math.floor(MODEL_ROWS / 2)) * MODEL_ROW_H
|
|
local lo, hi = bar:GetMinMaxValues()
|
|
if target < lo then target = lo elseif target > hi then target = hi end
|
|
bar:SetValue(target)
|
|
end
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
local cancel = LibWidgets.NewButton(frame, {
|
|
text = "Cancel", width = 90,
|
|
onClick = function() frame.Close() end,
|
|
})
|
|
cancel:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -pad, pad)
|
|
|
|
local accept = LibWidgets.NewButton(frame, {
|
|
text = "Okay", width = 90,
|
|
onClick = function()
|
|
local category, rest = selectedCategory, selectedRest
|
|
frame.Close()
|
|
if rest and W.onModelPicked then W.onModelPicked(modelPickPath(category, rest)) end
|
|
end,
|
|
})
|
|
accept:SetPoint("RIGHT", cancel, "LEFT", -6, 0)
|
|
|
|
function frame.Open(current)
|
|
local category, rest = modelPickParse(current)
|
|
if category then
|
|
selectedCategory, selectedRest = category, rest
|
|
else
|
|
selectedRest = nil
|
|
end
|
|
search:SetText("")
|
|
applyFilter("")
|
|
scrollToSelected()
|
|
setPreview(selectedRest)
|
|
refresh()
|
|
frame:Show()
|
|
end
|
|
|
|
function frame.Close()
|
|
LibWidgets.CloseAllMenus()
|
|
search:ClearFocus()
|
|
frame:Hide()
|
|
end
|
|
|
|
if UISpecialFrames then table.insert(UISpecialFrames, "WeakestAurasModelPickerDialog") end
|
|
|
|
frame.search = search
|
|
frame.categoryButton = categoryButton
|
|
frame.accept = accept
|
|
frame.rows = rows
|
|
frame.preview = preview
|
|
|
|
return frame
|
|
end
|
|
|
|
-- One shared dialog behind the model region's Browse button, same shape as
|
|
-- the icon picker's: built on first use, pick routed through a stored
|
|
-- callback rather than rebuilding per field.
|
|
function W.OpenModelPicker(current, onPick)
|
|
if not (LibWidgets.NewTextBox and LibWidgets.NewDropButton and LibWidgets.NewButton) then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff4040WeakestAuras:|r "
|
|
.. (W.LibWidgetsProblem() or "the loaded LibWidgets is missing the controls the model browser needs."))
|
|
return
|
|
end
|
|
if not W.modelPicker then W.modelPicker = buildModelPicker() end
|
|
W.onModelPicked = onPick
|
|
W.modelPicker.Open(current)
|
|
end
|
|
|
|
function W.CloseModelPicker()
|
|
if W.modelPicker then W.modelPicker.Close() end
|
|
end
|
|
-- Comparison operators offered by opnumber fields; the same set the runtime
|
|
-- cmp() in TriggerAura understands.
|
|
W.OPERATORS = { "==", "~=", "<", "<=", ">", ">=" }
|
|
|
|
-- Compiles `source` and returns "line N: message", or nil when it parses --
|
|
-- the `validate` hook a user-authored-Lua field hands to BuildOptions.
|
|
--
|
|
-- `chunkName` is what Lua puts inside the error's `[string "..."]:LINE:`
|
|
-- prefix, so keeping it short and caller-supplied makes stripping that prefix a
|
|
-- plain (non-pattern) find instead of a pattern that would have to survive
|
|
-- whatever the user typed. Callers that wrap the body ("return " .. body) must
|
|
-- use a prefix containing **no newline**, or every reported line is off by one.
|
|
function W.LuaSyntaxError(source, chunkName)
|
|
local chunk, err = loadstring(source or "", chunkName or "code")
|
|
if chunk then return nil end
|
|
if not err then return "syntax error" end
|
|
-- Lua 5.0 error strings are `[string "name"]:LINE: message`. Take the last
|
|
-- "]:" so a chunk name containing one can't cut the prefix short.
|
|
local last, from = nil, 1
|
|
while true do
|
|
local s, e = string.find(err, "]:", from, true)
|
|
if not s then break end
|
|
last = e
|
|
from = s + 1
|
|
end
|
|
if not last then return err end
|
|
local rest = string.sub(err, last + 1)
|
|
if string.find(rest, "^%d+") then return "line " .. rest end
|
|
return rest
|
|
end
|
|
|
|
local EMPTY = {}
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Widget pool
|
|
--
|
|
-- Frames can't be destroyed on this client, so a repaint that minted a fresh
|
|
-- widget per field and merely hid the previous set leaked a full page of
|
|
-- controls on every tab/aura switch -- and not cheaply: a single dropdown
|
|
-- brings a popup frame, a scroll frame and one button per menu entry with it,
|
|
-- and the slider and list editor each burn a unique *global* frame name per
|
|
-- instance (their Blizzard templates address child frames by name).
|
|
--
|
|
-- Widgets are therefore acquired from a per-kind free list kept on the page and
|
|
-- rebound to whatever field is being painted. Each pooled widget is built once
|
|
-- around a `bind` table this file owns, and every callback handed to LibWidgets
|
|
-- is an indirection that reads the current binding out of it -- so rebinding is
|
|
-- just overwriting `bind` fields, with no library-side support needed.
|
|
--
|
|
-- `kind` folds in any parameter a widget can only take at construction (a
|
|
-- dropdown's width, a list editor's visible row count), so an instance is only
|
|
-- ever reused where those still hold.
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local function acquire(page, kind, create)
|
|
local pool = page.widgetPools[kind]
|
|
if not pool then pool = {}; page.widgetPools[kind] = pool end
|
|
local n = (page.widgetUsed[kind] or 0) + 1
|
|
page.widgetUsed[kind] = n
|
|
local w = pool[n]
|
|
if not w then
|
|
w = create()
|
|
pool[n] = w
|
|
end
|
|
w:ClearAllPoints()
|
|
w:Show()
|
|
return w
|
|
end
|
|
|
|
local function poolLabel(page, text)
|
|
local fs = acquire(page, "label", function() return W.fieldLabel(page, "") end)
|
|
fs:SetText(text or "")
|
|
return fs
|
|
end
|
|
|
|
-- A wrapping explanatory line under a field. Pooled apart from `label` because
|
|
-- it carries a wrap width, which a plain label reused from the same pool would
|
|
-- inherit and then wrap inside its own row.
|
|
local function poolDescription(page, text, width)
|
|
local fs = acquire(page, "desc", function()
|
|
local f = page:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
|
|
f:SetJustifyH("LEFT")
|
|
f:SetTextColor(0.6, 0.6, 0.6)
|
|
return f
|
|
end)
|
|
fs:SetWidth(width)
|
|
fs:SetText(text or "")
|
|
return fs
|
|
end
|
|
|
|
local function poolHeaderLabel(page, text)
|
|
local fs = acquire(page, "headerlabel", function() return W.sectionHeader(page, "") end)
|
|
fs:SetText(text or "")
|
|
return fs
|
|
end
|
|
|
|
local function poolRule(page)
|
|
return acquire(page, "rule", function()
|
|
local t = page:CreateTexture(nil, "ARTWORK")
|
|
t:SetHeight(1)
|
|
t:SetTexture(0.9, 0.75, 0.2, 0.45)
|
|
return t
|
|
end)
|
|
end
|
|
|
|
-- The 18px spell/item/icon preview swatch beside a text field.
|
|
local function poolPreviewIcon(page)
|
|
return acquire(page, "previewicon", function()
|
|
local t = page:CreateTexture(nil, "OVERLAY")
|
|
t:SetWidth(18); t:SetHeight(18)
|
|
t:SetTexCoord(0.07, 0.93, 0.07, 0.93)
|
|
return t
|
|
end)
|
|
end
|
|
|
|
local function poolCheck(page, text, onClick, get, tristate)
|
|
local cb = acquire(page, "check", function()
|
|
local bind = {}
|
|
local w = LibWidgets.NewCheckBox(page, {
|
|
onClick = function(v) if bind.onClick then bind.onClick(v) end end,
|
|
})
|
|
w.bind = bind
|
|
return w
|
|
end)
|
|
cb.bind.onClick = onClick
|
|
cb.label:SetText(text or "")
|
|
-- Only a field that declares `tristate` (a mass-edit merged toggle) shows
|
|
-- the indeterminate dash on nil -- an ordinary field's get returning nil
|
|
-- just means false, and a dash there would read as disagreement where
|
|
-- there is none.
|
|
local v = get and get()
|
|
if not tristate then v = v and true or false end
|
|
cb.setChecked(v)
|
|
return cb
|
|
end
|
|
|
|
-- Shift-clicking an item drops its link into whichever field is focused and has
|
|
-- asked for links (the item fields). `HandleModifiedItemClick` is the real entry
|
|
-- point on this client -- it forwards to `ChatEdit_InsertLink` only while the
|
|
-- *chat* edit box is visible, so wrapping `ChatEdit_InsertLink` alone never sees
|
|
-- a link bound for one of ours. Both are wrapped: the second still carries a
|
|
-- link clicked in chat, which arrives there directly. Quartermaster's config
|
|
-- intercepts the same pair for the same reason.
|
|
local linkSink
|
|
|
|
-- The itemID is stored rather than the link or the name: an id always resolves,
|
|
-- where a name has to be found in the player's bags first. The readable name
|
|
-- comes back through the trigger's own nameFunc, so nothing is lost on screen.
|
|
local function routeLink(text)
|
|
if not text or not linkSink or not linkSink:IsVisible() then return false end
|
|
local _, _, id = string.find(text, "item:(%d+)")
|
|
if not id then return false end
|
|
linkSink:SetText(id)
|
|
linkSink:SetFocus()
|
|
if linkSink.bind and linkSink.bind.onCommit then linkSink.bind.onCommit(id) end
|
|
return true
|
|
end
|
|
|
|
local origModifiedClick = HandleModifiedItemClick
|
|
function HandleModifiedItemClick(link)
|
|
if link and IsModifiedClick("CHATLINK") and routeLink(link) then return true end
|
|
if origModifiedClick then return origModifiedClick(link) end
|
|
end
|
|
|
|
local origInsertLink = ChatEdit_InsertLink
|
|
function ChatEdit_InsertLink(text)
|
|
if routeLink(text) then return true end
|
|
if origInsertLink then return origInsertLink(text) end
|
|
return false
|
|
end
|
|
|
|
local function poolEditBox(page, width, onCommit)
|
|
local e = acquire(page, "editbox", function()
|
|
local bind = {}
|
|
local w = LibWidgets.NewTextBox(page, {
|
|
height = 20,
|
|
onCommit = function(t) if bind.onCommit then bind.onCommit(t) end end,
|
|
})
|
|
w.bind = bind
|
|
-- Chained, not replaced: the library's own handler closes open menus.
|
|
local origFocus = w:GetScript("OnEditFocusGained")
|
|
w:SetScript("OnEditFocusGained", function()
|
|
if origFocus then origFocus() end
|
|
linkSink = this.acceptsLinks and this or nil
|
|
end)
|
|
w:SetScript("OnEditFocusLost", function()
|
|
if linkSink == this then linkSink = nil end
|
|
end)
|
|
return w
|
|
end)
|
|
e.bind.onCommit = onCommit
|
|
-- One pool serves every field, so a box that last served an item field must
|
|
-- not keep accepting links on behalf of the next one.
|
|
e.acceptsLinks = false
|
|
e:SetWidth(width)
|
|
-- A reused box may still hold focus from the edit that triggered this
|
|
-- repaint; its text is about to be replaced under the cursor either way.
|
|
e:ClearFocus()
|
|
return e
|
|
end
|
|
|
|
local function poolButton(page, text, width, onClick)
|
|
local b = acquire(page, "button", function() return LibWidgets.NewButton(page, {}) end)
|
|
b.setText(text)
|
|
b:SetWidth(width or 80)
|
|
-- NewButton's own OnMouseDown already closes any open menu, so rebinding
|
|
-- only OnClick here loses nothing.
|
|
b:SetScript("OnClick", onClick or function() end)
|
|
return b
|
|
end
|
|
|
|
-- The small texture-faced buttons on a collapsible header (arrow, actions, delete) and on
|
|
-- a disclosure line (gear). The tint is reset on every acquisition, since one
|
|
-- pool serves all of them and a dimmed disclosure gear would otherwise carry
|
|
-- over onto whatever header arrow lands on that instance next.
|
|
local function poolIconButton(page, icon, onClick, tooltip)
|
|
local b = acquire(page, "iconbutton", function()
|
|
local w = LibWidgets.NewIconButton(page, { width = 16, height = 16, iconSize = 9 })
|
|
w.baseOnEnter = w:GetScript("OnEnter")
|
|
w.baseOnLeave = w:GetScript("OnLeave")
|
|
return w
|
|
end)
|
|
b.icon:SetTexture(icon)
|
|
b.icon:SetVertexColor(1, 1, 1)
|
|
b:SetScript("OnClick", function() LibWidgets.CloseAllMenus(); if onClick then onClick() end end)
|
|
b:SetScript("OnEnter", function()
|
|
if b.baseOnEnter then b.baseOnEnter() end
|
|
if tooltip and tooltip ~= "" then
|
|
GameTooltip:SetOwner(b, "ANCHOR_RIGHT")
|
|
GameTooltip:AddLine(tooltip)
|
|
GameTooltip:Show()
|
|
end
|
|
end)
|
|
b:SetScript("OnLeave", function()
|
|
if b.baseOnLeave then b.baseOnLeave() end
|
|
GameTooltip:Hide()
|
|
end)
|
|
return b
|
|
end
|
|
|
|
-- A section header's delete button, armed by a first click and committed by a
|
|
-- second within 3s -- the same two-click confirm the aura list's own Delete
|
|
-- uses, since deleting a trigger/effect/condition is just as unrecoverable. A
|
|
-- 16px icon button has no label to morph into "Confirm?" and a tinted 9px glyph
|
|
-- is too small to read as a state change, so an armed button goes solid red --
|
|
-- backdrop, border and glyph together -- which is legible at a glance and can't
|
|
-- be mistaken for the hover highlight.
|
|
local function poolDeleteButton(page, onDelete)
|
|
local b = acquire(page, "delbutton", function()
|
|
local bind = {}
|
|
local w = LibWidgets.NewIconButton(page, { width = 16, height = 16, iconSize = 9 })
|
|
w.icon:SetTexture(LibWidgets.ICON_DELETE)
|
|
w.bind = bind
|
|
w.arm = function()
|
|
w.armed = true
|
|
w:SetBackdropColor(0.65, 0.06, 0.06, 1)
|
|
w:SetBackdropBorderColor(1, 0.3, 0.3, 1)
|
|
w.icon:SetVertexColor(1, 0.9, 0.9)
|
|
end
|
|
w.disarm = function()
|
|
w.armed = nil
|
|
w:SetBackdropColor(0, 0, 0, 0.7)
|
|
w:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.8)
|
|
w.icon:SetVertexColor(1, 1, 1)
|
|
end
|
|
w:SetScript("OnClick", function()
|
|
LibWidgets.CloseAllMenus()
|
|
if w.armed then
|
|
w.disarm()
|
|
if bind.onDelete then bind.onDelete() end
|
|
else
|
|
w.arm()
|
|
C_Timer.After(3, function() if w.armed then w.disarm() end end)
|
|
end
|
|
end)
|
|
-- Hover must not repaint an armed button back to the neutral border, or
|
|
-- moving the mouse would silently undo the only cue that it's primed.
|
|
w:SetScript("OnEnter", function()
|
|
if not w.armed then this:SetBackdropBorderColor(0.9, 0.8, 0.2, 1) end
|
|
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
|
GameTooltip:AddLine(w.armed and "Click again to confirm" or "Delete")
|
|
GameTooltip:Show()
|
|
end)
|
|
w:SetScript("OnLeave", function()
|
|
if not w.armed then this:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.8) end
|
|
GameTooltip:Hide()
|
|
end)
|
|
return w
|
|
end)
|
|
b.bind.onDelete = onDelete
|
|
-- Disarm on reuse: a pooled button armed for the section it served in the
|
|
-- last paint must never commit that click against whatever section it lands
|
|
-- on in this one.
|
|
b.disarm()
|
|
return b
|
|
end
|
|
|
|
local function poolActionButton(page, action)
|
|
if action.confirm then return poolDeleteButton(page, action.onClick) end
|
|
return poolIconButton(page, action.icon, action.onClick, action.tooltip)
|
|
end
|
|
|
|
-- A bare click target laid over a collapsible header's title line. It doubles
|
|
-- as the header's tooltip zone (`f.tooltip`, the mass-edit member listing) --
|
|
-- a separate zone over the title would sit on this button and steal its
|
|
-- toggle clicks. Assigned every acquire, so a pooled area reused by a
|
|
-- tooltip-less header goes quiet again.
|
|
local function poolHitArea(page, onClick, tooltip)
|
|
local b = acquire(page, "hitarea", function()
|
|
local z = CreateFrame("Button", nil, page)
|
|
z:SetScript("OnEnter", function()
|
|
if not this.tipTitle then return end
|
|
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
|
GameTooltip:SetText(this.tipTitle, 1, 1, 1)
|
|
local lines = this.tipLines or EMPTY
|
|
for i = 1, #lines do
|
|
GameTooltip:AddLine(lines[i], 0.9, 0.9, 0.9, true)
|
|
end
|
|
GameTooltip:Show()
|
|
end)
|
|
z:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
|
return z
|
|
end)
|
|
b:SetHeight(16)
|
|
b:SetScript("OnClick", onClick)
|
|
b.tipTitle = tooltip and tooltip.title or nil
|
|
b.tipLines = tooltip and tooltip.lines or nil
|
|
return b
|
|
end
|
|
|
|
-- `swatches` (value -> texture path) turns this into a previewing picker; a
|
|
-- swatch button can't be un-swatched after construction, so it pools separately.
|
|
local function poolDropdown(page, width, values, labels, onSelect, get, swatches, previews, onPreview)
|
|
local kind = (swatches and "dropsw" or previews and "droppreview" or "drop") .. width
|
|
local d = acquire(page, kind, function()
|
|
local bind = {}
|
|
-- NewDropButton captures `labels` once; a proxy that forwards misses to
|
|
-- the live binding is what lets one pooled button serve every field.
|
|
-- `values` needs no such trick -- passed as a function it already means
|
|
-- "rebuild the menu from this on every open".
|
|
local labelProxy = setmetatable({}, { __index = function(t, k)
|
|
if k == nil then return nil end
|
|
return bind.labels and bind.labels[k]
|
|
end })
|
|
local swatchProxy = swatches and setmetatable({}, { __index = function(t, k)
|
|
if k == nil then return nil end
|
|
return bind.swatches and bind.swatches[k]
|
|
end })
|
|
local previewProxy = previews and setmetatable({}, { __index = function(t, k)
|
|
if k == nil then return nil end
|
|
return bind.previews and bind.previews[k]
|
|
end })
|
|
local w = LibWidgets.NewDropButton(page, {
|
|
width = width,
|
|
values = function() return bind.values or EMPTY end,
|
|
labels = labelProxy,
|
|
swatches = swatchProxy,
|
|
previews = previewProxy,
|
|
previewTexture = W.SOUND_PREVIEW_TEXTURE,
|
|
onPreview = function(v) if bind.onPreview then bind.onPreview(v) end end,
|
|
onSelect = function(v) if bind.onSelect then bind.onSelect(v) end end,
|
|
get = function() return bind.get and bind.get() end,
|
|
textureDir = W.LIBWIDGETS_TEXTURES,
|
|
-- The library's default of 8 rows scrolls the longest lists here (the
|
|
-- trigger-type picker, a condition's property list) far more than they
|
|
-- need. Read once at construction, so it applies to every field this
|
|
-- pooled button is later rebound to.
|
|
maxVisibleItems = 14,
|
|
})
|
|
w.bind = bind
|
|
return w
|
|
end)
|
|
d.bind.values = values
|
|
d.bind.labels = labels
|
|
d.bind.swatches = swatches
|
|
d.bind.previews = previews
|
|
d.bind.onPreview = onPreview
|
|
d.bind.onSelect = onSelect
|
|
d.bind.get = get
|
|
d.setValue(get and get())
|
|
return d
|
|
end
|
|
|
|
local function poolAnchorGrid(page, f, width)
|
|
local grid = acquire(page, "anchorgrid", function()
|
|
local bind = {}
|
|
local w = LibWidgets.NewAnchorGrid(page, {
|
|
values = f.values,
|
|
get = function() return bind.get and bind.get() end,
|
|
onSelect = function(v) if bind.set then bind.set(v) end end,
|
|
})
|
|
w.bind = bind
|
|
return w
|
|
end)
|
|
grid.bind.get = f.get
|
|
grid.bind.set = f.set
|
|
grid.setBindings(f.values, function() return grid.bind.get and grid.bind.get() end,
|
|
function(v) if grid.bind.set then grid.bind.set(v) end end)
|
|
grid.setSize(f.width or 100, f.height or 50)
|
|
grid.setValue(f.get())
|
|
return grid
|
|
end
|
|
|
|
-- A drop button whose face never changes: it names an action and its entries
|
|
-- are the choices. NewDropButton repaints the face from the picked value only
|
|
-- when given a `get`, so omitting one leaves the face entirely to setValue --
|
|
-- which this calls with the caption on every paint.
|
|
local function poolMenuButton(page, width, caption, values, labels, onSelect)
|
|
local d = acquire(page, "menu" .. width, function()
|
|
local bind = {}
|
|
local labelProxy = setmetatable({}, { __index = function(t, k)
|
|
if k == nil then return nil end
|
|
return bind.labels and bind.labels[k]
|
|
end })
|
|
local w = LibWidgets.NewDropButton(page, {
|
|
width = width,
|
|
values = function() return bind.values or EMPTY end,
|
|
labels = labelProxy,
|
|
onSelect = function(v) if bind.onSelect then bind.onSelect(v) end end,
|
|
textureDir = W.LIBWIDGETS_TEXTURES,
|
|
})
|
|
w.bind = bind
|
|
return w
|
|
end)
|
|
d.bind.values = values
|
|
d.bind.labels = labels
|
|
d.bind.onSelect = onSelect
|
|
-- No label maps the caption, so setValue falls through to the caption itself.
|
|
d.setValue(caption)
|
|
return d
|
|
end
|
|
|
|
local function poolColor(page, get, set)
|
|
local sw = acquire(page, "color", function()
|
|
local bind = {}
|
|
local w = LibWidgets.NewColorSwatch(page, {
|
|
get = function() return bind.get and bind.get() end,
|
|
set = function(v) if bind.set then bind.set(v) end end,
|
|
})
|
|
w.bind = bind
|
|
return w
|
|
end)
|
|
sw.bind.get = get
|
|
sw.bind.set = set
|
|
sw.repaint()
|
|
return sw
|
|
end
|
|
|
|
-- A syntax-coloured Lua editor. The error line and the Reset button are the
|
|
-- widget's own (see LibWidgets.NewCodeEditBox), so nothing here pools them
|
|
-- separately -- which is also what keeps them from being stranded by a repaint.
|
|
-- Height is a construction-time parameter, so it folds into the pool key.
|
|
local function poolCode(page, f, width, height)
|
|
local box = acquire(page, "code" .. height, function()
|
|
local bind = {}
|
|
local w = LibWidgets.NewCodeEditBox(page, {
|
|
width = width, height = height,
|
|
font = { path = W.CODE_FONT, size = W.CODE_FONT_SIZE },
|
|
onChange = function(t) if bind.onChange then bind.onChange(t) end end,
|
|
onCommit = function(t) if bind.onCommit then bind.onCommit(t) end end,
|
|
validate = function(t) return bind.validate and bind.validate(t) end,
|
|
})
|
|
w.bind = bind
|
|
return w
|
|
end)
|
|
-- Unbound across the seed: setText re-validates and would otherwise run the
|
|
-- previous field's validator against this field's text.
|
|
box.bind.onChange, box.bind.onCommit, box.bind.validate = nil, nil, nil
|
|
box.setSize(width, height)
|
|
-- Read per paint rather than at construction, so /wa codelive takes effect on
|
|
-- the next repaint instead of needing a reload. `false` is a real stored
|
|
-- value, so this can't collapse to `or true` -- only an unset (nil) setting
|
|
-- falls back to the default, the same shape as the tab width below.
|
|
if box.setLive then
|
|
local liveOn = WeakestAurasDB and WeakestAurasDB.codeEditorLive
|
|
if liveOn == nil then liveOn = true end
|
|
box.setLive(liveOn)
|
|
end
|
|
if box.setFontSize then
|
|
box.setFontSize((WeakestAurasDB and WeakestAurasDB.codeEditorFontSize) or W.CODE_FONT_SIZE)
|
|
end
|
|
if box.setTabWidth then
|
|
-- `false` means hard tabs, so this can't collapse to `or 2` -- only an
|
|
-- unset (nil) value falls back to the default.
|
|
local tw = WeakestAurasDB and WeakestAurasDB.codeEditorTabWidth
|
|
if tw == nil then tw = 2 end
|
|
box.setTabWidth(tw)
|
|
end
|
|
-- setDefault also disarms, so a Reset primed for the field this box served
|
|
-- in the last paint can never commit against the one it lands on now.
|
|
box.setDefault(f.default)
|
|
-- A field that has never been configured opens *at* its Reset default rather
|
|
-- than empty: an empty box runs nothing and teaches nothing, and the default
|
|
-- is the signature the user has to start from either way. It is committed,
|
|
-- not merely displayed -- a box showing logic the aura is not running is
|
|
-- worse than an empty one -- so it goes through `set` once the binds are
|
|
-- back.
|
|
--
|
|
-- "Never configured" is `get` returning nil, and the distinction from "" is
|
|
-- load-bearing: seeding an empty *string* would put the default back every
|
|
-- time the user cleared the box, which makes an emptied code field
|
|
-- impossible to keep. A `code` field's `get` must therefore return the raw
|
|
-- stored value, not `... or ""`.
|
|
local stored = f.get()
|
|
-- `noSeed` (a mass-edit merged field): nil there means the sources disagree
|
|
-- at least as often as "never configured", and a seed would go through the
|
|
-- merged set -- writing the default into every one of them on mere paint.
|
|
local seeding = (stored == nil and f.default ~= nil and not f.noSeed)
|
|
local text = stored or ""
|
|
if seeding then
|
|
text = (type(f.default) == "function" and f.default() or f.default) or ""
|
|
end
|
|
box.setText(text)
|
|
box.bind.validate = f.validate
|
|
box.bind.onCommit = f.set
|
|
box.bind.onChange = f.onChange
|
|
if seeding and f.set then f.set(text) end
|
|
-- The seed above ran with no validator bound; drive the error line now that
|
|
-- the real one is in place.
|
|
box.revalidate()
|
|
return box
|
|
end
|
|
|
|
local function poolMultiline(page, width, height, text, onCommit, onChange)
|
|
local box = acquire(page, "multiline", function()
|
|
local bind = {}
|
|
local w = LibWidgets.NewMultiLineEditBox(page, {
|
|
onChange = function(t) if bind.onChange then bind.onChange(t) end end,
|
|
})
|
|
w.bind = bind
|
|
w.edit:SetScript("OnEditFocusLost", function()
|
|
if bind.onCommit then bind.onCommit(w.getText()) end
|
|
end)
|
|
return w
|
|
end)
|
|
-- Unbound across the seed: SetText fires OnTextChanged, and the field being
|
|
-- painted validates its own starting text below -- an echo here would only
|
|
-- run the same check a second time (and, before the rebind, against the
|
|
-- previous field's).
|
|
box.bind.onChange = nil
|
|
box.bind.onCommit = nil
|
|
-- setSize, not SetWidth/SetHeight: the widget has to re-wrap and re-measure
|
|
-- its text to keep the scroll range right.
|
|
box.setSize(width, height)
|
|
box.setText(text or "")
|
|
box.bind.onChange = onChange
|
|
box.bind.onCommit = onCommit
|
|
return box
|
|
end
|
|
|
|
-- Boundary-move splice shared by the list editor's arrow buttons and its
|
|
-- drag-drop (see LibWidgets.lua's NewListEditor doc comment): `before` names an
|
|
-- original index the moved entry should land just ahead of.
|
|
local function spliceReorder(list, fromIndex, before)
|
|
local v = table.remove(list, fromIndex)
|
|
local insertAt = (fromIndex < before) and (before - 1) or before
|
|
table.insert(list, insertAt, v)
|
|
end
|
|
|
|
local function poolListEditor(page, f, x, y, rightInset, visibleRows)
|
|
local frame = acquire(page, "list" .. visibleRows, function()
|
|
W._listSeq = (W._listSeq or 0) + 1
|
|
local bind = {}
|
|
local function list() return (bind.list and bind.list()) or EMPTY end
|
|
local function changed(kind, first, second)
|
|
if bind[kind] then
|
|
if second ~= nil then bind[kind](first, second) else bind[kind](first) end
|
|
elseif bind.onChange then
|
|
bind.onChange()
|
|
end
|
|
end
|
|
local editor = LibWidgets.NewListEditor(page, {
|
|
nameFrame = "WeakestAurasOptList" .. W._listSeq,
|
|
textureDir = W.LIBWIDGETS_TEXTURES,
|
|
rowHeight = 20, visibleRows = visibleRows,
|
|
list = list,
|
|
reorder = function(fromIndex, before)
|
|
spliceReorder(list(), fromIndex, before)
|
|
changed("onReorder", fromIndex, before)
|
|
end,
|
|
remove = function(index)
|
|
table.remove(list(), index)
|
|
changed("onRemove", index)
|
|
end,
|
|
add = { onAdd = function(text)
|
|
if text and text ~= "" then
|
|
table.insert(list(), text)
|
|
changed("onAdd", text)
|
|
end
|
|
end },
|
|
nameGet = function(entry) return entry end,
|
|
})
|
|
editor.frame.editor = editor
|
|
editor.frame.bind = bind
|
|
return editor.frame
|
|
end)
|
|
frame.bind.list = f.get
|
|
frame.bind.onChange = f.onChange
|
|
frame.bind.onReorder = f.onReorder
|
|
frame.bind.onRemove = f.onRemove
|
|
frame.bind.onAdd = f.onAdd
|
|
frame:SetPoint("TOPLEFT", page, "TOPLEFT", x, y)
|
|
frame:SetPoint("RIGHT", page, "RIGHT", -rightInset, 0)
|
|
frame.editor.refresh()
|
|
return frame
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Declarative options-table renderer
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- Distance from a field's top edge down to the top of its control body -- the
|
|
-- caption line a labelled field draws above its widget, zero for one that has
|
|
-- none. A two-column row aligns on this rather than on its top edge, so a bare
|
|
-- toggle or swatch sits level with the control beside it instead of level with
|
|
-- that control's caption. `range` is listed although `placeField` puts its
|
|
-- widget on the row's top edge: NewSpinBox draws its own caption inside the
|
|
-- frame, in the 14px it reserves above the track.
|
|
local FIELD_LEAD = {
|
|
input = 16, multiline = 16, namelist = 16, select = 16, opnumber = 16,
|
|
spell = 16, item = 16, talent = 16, icon = 16, texture = 16,
|
|
anchorgrid = 16, anchorlayout = 16,
|
|
code = 20,
|
|
range = 14,
|
|
}
|
|
|
|
local function fieldLead(f) return FIELD_LEAD[f.type] or 0 end
|
|
|
|
-- A hover zone over a field's caption for `f.tooltip` ({ title, lines }) --
|
|
-- the per-member value listing a disagreeing mass-edit field carries.
|
|
-- FontStrings take no mouse events on this client, so the zone is its own
|
|
-- pooled button, sized to the caption text rather than the column so it can
|
|
-- never sit over a control (or a code field's Reset button) and steal its
|
|
-- clicks.
|
|
local function poolTipZone(page, f, label, x, y, h)
|
|
if not f.tooltip then return end
|
|
local zone = acquire(page, "tipzone", function()
|
|
local z = CreateFrame("Button", nil, page)
|
|
z:SetScript("OnEnter", function()
|
|
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
|
GameTooltip:SetText(this.tipTitle or "", 1, 1, 1)
|
|
local lines = this.tipLines or EMPTY
|
|
for i = 1, #lines do
|
|
GameTooltip:AddLine(lines[i], 0.9, 0.9, 0.9, true)
|
|
end
|
|
GameTooltip:Show()
|
|
end)
|
|
z:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
|
return z
|
|
end)
|
|
zone.tipTitle = f.tooltip.title
|
|
zone.tipLines = f.tooltip.lines
|
|
local tw = label and label.GetStringWidth and label:GetStringWidth() or 0
|
|
if not tw or tw < 24 then tw = 80 end
|
|
zone:SetWidth(tw + 6)
|
|
zone:SetHeight(h or 14)
|
|
zone:ClearAllPoints()
|
|
zone:SetPoint("TOPLEFT", x, y)
|
|
return zone
|
|
end
|
|
|
|
-- Places one field at (x, y) with widget width `w` and returns the vertical
|
|
-- space it consumed. Headers are handled by the caller (they're always
|
|
-- full-width). Shared by the single-column and two-column paths so both stay
|
|
-- identical per field type.
|
|
local function placeField(page, f, x, y, w)
|
|
local indent = (f.indent or 0) * OPTIONS_INDENT_W
|
|
if indent > 0 then
|
|
x = x + indent
|
|
w = w - indent
|
|
if w < 90 then w = 90 end
|
|
end
|
|
local lead = fieldLead(f)
|
|
if f.type == "toggle" then
|
|
local cb = poolCheck(page, f.name, f.set, f.get, f.tristate)
|
|
cb:SetPoint("TOPLEFT", x, y)
|
|
poolTipZone(page, f, cb.label, x + 24, y, 20)
|
|
return 24
|
|
elseif f.type == "space" then
|
|
local line = 24
|
|
return f.useHeight and (f.height or 1) * line or line
|
|
elseif f.type == "input" then
|
|
local label = poolLabel(page, f.name)
|
|
label:SetPoint("TOPLEFT", x, y)
|
|
poolTipZone(page, f, label, x, y, 14)
|
|
local e = poolEditBox(page, w, f.set)
|
|
local v = f.get()
|
|
e:SetText(v ~= nil and tostring(v) or "")
|
|
e:SetPoint("TOPLEFT", x, y - lead)
|
|
return lead + 28
|
|
elseif f.type == "multiline" then
|
|
local label = poolLabel(page, f.name)
|
|
label:SetPoint("TOPLEFT", x, y)
|
|
poolTipZone(page, f, label, x, y, 14)
|
|
-- Wants far more width than a normal field's capped column; size to the
|
|
-- page rather than the passed `w`. Commits on focus lost, not per
|
|
-- keystroke -- a custom-trigger `set` recompiles the aura, and Enter is a
|
|
-- newline in a multi-line box, so mid-typing commits are wrong here.
|
|
local mw = (page:GetWidth() or 400) - x - 16
|
|
if mw < 120 then mw = 120 end
|
|
local mh = f.height or 150
|
|
local box = poolMultiline(page, mw, mh, f.get() or "", f.set)
|
|
box:SetPoint("TOPLEFT", x, y - lead)
|
|
return lead + mh + 8
|
|
elseif f.type == "description" then
|
|
-- Text only, no control: `name` is the whole body. Sized to the page
|
|
-- rather than the capped column, since it is prose and wraps.
|
|
local dw = f.half and w or ((page:GetWidth() or 400) - x - 16)
|
|
if dw < 120 then dw = 120 end
|
|
local fs = poolDescription(page, f.name, dw)
|
|
fs:SetPoint("TOPLEFT", x, y)
|
|
return (fs:GetHeight() or 12) + 8
|
|
elseif f.type == "code" then
|
|
local label = poolLabel(page, f.name)
|
|
label:SetPoint("TOPLEFT", x, y)
|
|
poolTipZone(page, f, label, x, y, 14)
|
|
-- Same page-width sizing as `multiline`: code wants far more room than a
|
|
-- normal field's capped column.
|
|
local mw = (page:GetWidth() or 400) - x - 16
|
|
if mw < 120 then mw = 120 end
|
|
local mh = f.height or 150
|
|
local box = poolCode(page, f, mw, mh)
|
|
-- The lead is deeper here than a plain label needs: it also has to clear
|
|
-- the widget's own Reset button, which sits above the box's top-right
|
|
-- corner.
|
|
box:SetPoint("TOPLEFT", x, y - lead)
|
|
-- The label/Reset row, then the box, then the widget's own error line
|
|
-- (14) -- which is always allotted, so the layout below doesn't shift as
|
|
-- errors come and go while typing.
|
|
return lead + mh + 8 + 14
|
|
elseif f.type == "spell" or f.type == "item" or f.type == "talent" or f.type == "icon" or f.type == "texture" then
|
|
local label = poolLabel(page, f.name)
|
|
label:SetPoint("TOPLEFT", x, y)
|
|
poolTipZone(page, f, label, x, y, 14)
|
|
local icon = poolPreviewIcon(page)
|
|
icon:SetPoint("TOPLEFT", x, y - lead)
|
|
-- `spell`/`item` store what the user typed and preview whatever it
|
|
-- resolves to; `icon` resolves at commit instead and stores the texture
|
|
-- path itself, so the region can SetTexture it blindly.
|
|
local function resolve(v)
|
|
if f.type == "spell" then
|
|
local id = WA.ResolveSpellID(v)
|
|
if not id then return nil end
|
|
local _, _, ic = GetSpellInfo(id)
|
|
return ic
|
|
elseif f.type == "item" then
|
|
local id = WA.ResolveItemID(v)
|
|
if not id then return nil end
|
|
-- C_Item.GetItemInfo's 18-value tuple, not the stock global's short
|
|
-- one: only the modern shape has the texture in slot 10.
|
|
local _, _, _, _, _, _, _, _, _, ic = C_Item.GetItemInfo(id)
|
|
return ic
|
|
elseif f.type == "talent" then
|
|
return f.resolve and f.resolve(v) or nil
|
|
end
|
|
local id = WA.ResolveSpellID(v)
|
|
if id then local _, _, ic = GetSpellInfo(id); return ic end
|
|
if v and v ~= "" then return v end
|
|
return nil
|
|
end
|
|
-- An `icon` field additionally gets a Browse button opening the picker;
|
|
-- typing a path by hand still works, it just stops being the only way.
|
|
local browsable = f.type == "icon" or f.type == "texture"
|
|
local browseW = browsable and 62 or 0
|
|
local e = poolEditBox(page, w - 24 - browseW, function(v)
|
|
local path = resolve(v)
|
|
if browsable then f.set(path or "") else f.set(v) end
|
|
icon:SetTexture(path or QUESTION_ICON)
|
|
if WA.RefreshList then WA.RefreshList() end
|
|
end)
|
|
-- Only an item field takes a shift-clicked link; a spell, talent, or icon field has
|
|
-- nothing to do with one.
|
|
e.acceptsLinks = (f.type == "item")
|
|
local v = f.get()
|
|
e:SetText(v ~= nil and tostring(v) or "")
|
|
e:SetPoint("TOPLEFT", x + 24, y - lead)
|
|
if browsable then
|
|
icon:SetTexture(WA.DrawableTexture(v) or QUESTION_ICON)
|
|
local b = poolButton(page, "Browse", browseW - 4, function()
|
|
local picker = f.type == "texture" and W.OpenTexturePicker or W.OpenIconPicker
|
|
picker(f.get(), function(path)
|
|
f.set(path or "")
|
|
if WA.RefreshList then WA.RefreshList() end
|
|
if WA.RefreshOptions then WA.RefreshOptions() end
|
|
end)
|
|
end)
|
|
b:SetPoint("TOPLEFT", x + 24 + (w - 24 - browseW) + 4, y - lead)
|
|
else
|
|
icon:SetTexture(resolve(v) or QUESTION_ICON)
|
|
end
|
|
return lead + 28
|
|
elseif f.type == "namelist" then
|
|
local label = poolLabel(page, f.name)
|
|
label:SetPoint("TOPLEFT", x, y)
|
|
poolTipZone(page, f, label, x, y, 14)
|
|
local n = #(f.get() or EMPTY)
|
|
local visibleRows = n < 2 and 2 or (n > 5 and 5 or n)
|
|
local rightInset = (page:GetWidth() or 400) - (x + w)
|
|
local frame = poolListEditor(page, f, x, y - lead, rightInset, visibleRows)
|
|
return lead + frame.editor.height + 6
|
|
elseif f.type == "range" then
|
|
local s = acquire(page, "spin", function()
|
|
-- The bind table *is* the widget's spec: NewSpinBox reads label/min/
|
|
-- max/step/onChange out of it at call time, so rebinding is assignment.
|
|
local bind
|
|
bind = {
|
|
min = 0, max = 1, step = 1, width = 220,
|
|
textureDir = W.LIBWIDGETS_TEXTURES,
|
|
onChange = function(v) if bind.set then bind.set(v) end end,
|
|
}
|
|
local sp = LibWidgets.NewSpinBox(page, bind)
|
|
sp.bind = bind
|
|
return sp
|
|
end)
|
|
-- Unbound across the rebind, because two things here can move the value:
|
|
-- clearing a box that still holds focus from the edit which caused this
|
|
-- repaint commits it, and a narrowed range clamps the carried-over number.
|
|
-- Either would write into the field being painted rather than the one the
|
|
-- user was actually editing.
|
|
s.bind.set = nil
|
|
s.edit:ClearFocus()
|
|
s.bind.label, s.bind.fmt, s.bind.decimals = f.name, f.fmt, f.decimals
|
|
s.bind.min, s.bind.max, s.bind.step = f.min, f.max, f.step
|
|
s.bind.softMin, s.bind.softMax, s.bind.bigStep = f.softMin, f.softMax, f.bigStep
|
|
s.setWidth(w)
|
|
s.setValue(f.get())
|
|
s.bind.set = f.set
|
|
s:SetPoint("TOPLEFT", x, y)
|
|
poolTipZone(page, f, s.label, x, y, 14)
|
|
return 38
|
|
elseif f.type == "disclosure" then
|
|
-- A settings fold: a gear plus a one-line label, the whole line clickable.
|
|
-- Deliberately not a `header` -- a header titles a region of the tab, this
|
|
-- hides settings that get set once and then only get in the way, so it
|
|
-- reads as a control rather than as a divider.
|
|
local btn = poolIconButton(page, GEAR_ICON, f.onToggle or function() end)
|
|
btn:SetPoint("TOPLEFT", x, y)
|
|
if f.collapsed then btn.icon:SetVertexColor(0.6, 0.6, 0.6) end
|
|
-- The summary says what the folded rows hold, so the closed state still
|
|
-- carries the settings rather than merely naming them.
|
|
local text = f.name or ""
|
|
if f.summary and f.summary ~= "" then text = text .. "|cff9d9d9d: " .. f.summary .. "|r" end
|
|
local label = poolLabel(page, text)
|
|
label:SetPoint("TOPLEFT", x + 20, y - 2)
|
|
local hit = poolHitArea(page, f.onToggle or function() end, f.tooltip)
|
|
hit:SetPoint("TOPLEFT", x + 20, y)
|
|
hit:SetPoint("TOPRIGHT", page, "TOPLEFT", x + 24 + (label:GetStringWidth() or 0), y)
|
|
return 22
|
|
elseif f.type == "select" then
|
|
local label = poolLabel(page, f.name)
|
|
label:SetPoint("TOPLEFT", x, y)
|
|
poolTipZone(page, f, label, x, y, 14)
|
|
local dw = w < 160 and w or 160
|
|
local actionCount = #(f.actions or {})
|
|
if actionCount > 0 then
|
|
local actionW = actionCount * 16 + (actionCount - 1) * 6
|
|
local available = w - actionW - 6
|
|
if available < 90 then available = 90 end
|
|
dw = available < 160 and available or 160
|
|
end
|
|
local d = poolDropdown(page, dw, f.values, f.labels, f.set, f.get, f.swatches, f.previews, f.onPreview)
|
|
d:SetPoint("TOPLEFT", x, y - lead)
|
|
for i = 1, actionCount do
|
|
local action = f.actions[i]
|
|
local button = poolActionButton(page, action)
|
|
button:SetPoint("TOPLEFT", x + dw + 6 + (i - 1) * 22, y - lead + 2)
|
|
end
|
|
return lead + 28
|
|
elseif f.type == "opnumber" then
|
|
local label = poolLabel(page, f.name)
|
|
label:SetPoint("TOPLEFT", x, y)
|
|
poolTipZone(page, f, label, x, y, 14)
|
|
local op = poolDropdown(page, 52, W.OPERATORS, nil, f.setOp, f.getOp)
|
|
op:SetPoint("TOPLEFT", x, y - lead)
|
|
local e = poolEditBox(page, 60, function(v) f.setVal(tonumber(v)) end)
|
|
local v = f.getVal()
|
|
e:SetText(v ~= nil and tostring(v) or "")
|
|
e:SetPoint("TOPLEFT", x + 58, y - lead)
|
|
return lead + 28
|
|
elseif f.type == "color" then
|
|
local label = poolLabel(page, f.name)
|
|
label:SetPoint("TOPLEFT", x, y - 3)
|
|
poolTipZone(page, f, label, x, y - 3, 16)
|
|
local sw = poolColor(page, f.get, f.set)
|
|
sw:SetPoint("TOPLEFT", x + 100, y)
|
|
return 26
|
|
elseif f.type == "button" then
|
|
local b = poolButton(page, f.name, f.width or w, f.onClick)
|
|
b:SetPoint("TOPLEFT", x, y)
|
|
return 28
|
|
elseif f.type == "menu" then
|
|
local d = poolMenuButton(page, f.width or w, f.name, f.values, f.labels, f.onSelect)
|
|
d:SetPoint("TOPLEFT", x, y)
|
|
return 28
|
|
elseif f.type == "anchorgrid" then
|
|
local label = poolLabel(page, f.name)
|
|
label:SetPoint("TOPLEFT", x, y)
|
|
poolTipZone(page, f, label, x, y, 14)
|
|
local gw = f.width or w
|
|
local grid = poolAnchorGrid(page, f, gw)
|
|
grid:SetPoint("TOPLEFT", x, y - lead)
|
|
return lead + (f.height or 50) + 8
|
|
elseif f.type == "anchorlayout" then
|
|
local grid = f.grid
|
|
local gridW = grid.width or 100
|
|
local gridH = grid.height or 50
|
|
local anchor = poolLabel(page, grid.name)
|
|
anchor:SetPoint("TOPLEFT", x, y)
|
|
local anchorGrid = poolAnchorGrid(page, grid, gridW)
|
|
anchorGrid:SetPoint("TOPLEFT", x, y - lead)
|
|
local sideX = x + gridW + 12
|
|
local sideW = w - gridW - 12
|
|
if sideW < 90 then sideW = 90 end
|
|
local sideY = y
|
|
local sideHeight = 0
|
|
for i = 1, #(f.sideFields or {}) do
|
|
local used = placeField(page, f.sideFields[i], sideX, sideY, sideW)
|
|
sideY = sideY - used
|
|
sideHeight = sideHeight + used
|
|
end
|
|
local gridHeight = lead + gridH + 8
|
|
return gridHeight > sideHeight and gridHeight or sideHeight
|
|
end
|
|
return 0
|
|
end
|
|
|
|
-- (Re)paints `page` from `fields`, reusing the widgets already on it (see the
|
|
-- widget pool above). Fields flagged `half` pack two-per-row; columns size to
|
|
-- the page's current width so the layout follows a resized options window.
|
|
function W.BuildOptions(page, fields)
|
|
page.widgetPools = page.widgetPools or {}
|
|
page.widgetUsed = page.widgetUsed or {}
|
|
-- A NewDropButton popup is parented to the window, not to its button or this
|
|
-- page, so the sweep below can't take it down: an open menu would survive the
|
|
-- repaint and float over the new controls. Closing it here rather than
|
|
-- relying on the caller is what lets the invariant below hold for *every*
|
|
-- widget this page owns. It also drops edit focus, which has to happen
|
|
-- before anything below re-seeds a pooled box -- a commit-on-blur editor
|
|
-- reached by the seed first would have the user's unsaved text overwritten
|
|
-- by the stored value and only then be asked to commit it.
|
|
LibWidgets.CloseAllMenus()
|
|
-- Everything on the page goes down before anything comes back up, and
|
|
-- `acquire` re-Shows only what this paint actually uses. Hiding up front
|
|
-- rather than sweeping the unused tail at the end is what makes "a control
|
|
-- from the previous tab is still on screen" structurally impossible: the
|
|
-- sweep only holds if the paint runs to completion, so any error partway
|
|
-- through (or an early return added later) stranded whatever the previous
|
|
-- paint had put there. This ordering has no such precondition.
|
|
for kind, pool in pairs(page.widgetPools) do
|
|
page.widgetUsed[kind] = 0
|
|
for i = 1, #pool do pool[i]:Hide() end
|
|
end
|
|
|
|
local avail = page:GetWidth()
|
|
if not avail or avail < 120 then avail = 400 end
|
|
local gap = 12
|
|
local colW = math.floor((avail - 16 - gap) / 2)
|
|
if colW < 90 then colW = 90 end
|
|
local leftX, rightX = 8, 8 + colW + gap
|
|
local fullW = avail - 16
|
|
if fullW > 240 then fullW = 240 end
|
|
|
|
local y = -8
|
|
-- A `half` field waits here for the partner it shares its row with, since
|
|
-- where it goes depends on that partner's shape. A header, a full-width
|
|
-- field, or the end of the list flushes it into the left column alone.
|
|
local pending, pendingY
|
|
|
|
local function flushPending()
|
|
if not pending then return end
|
|
y = pendingY - placeField(page, pending, leftX, pendingY, colW - 8)
|
|
pending = nil
|
|
end
|
|
|
|
for i = 1, #fields do
|
|
local f = fields[i]
|
|
if f.type == "header" then
|
|
flushPending()
|
|
y = y - 10
|
|
-- Centered gold label flanked by horizontal rules (the WeakAuras
|
|
-- section-divider look) -- plain size-differentiated text wasn't
|
|
-- separating areas clearly enough.
|
|
local h = poolHeaderLabel(page, f.name)
|
|
local headerIndent = (f.indent or 0) * OPTIONS_INDENT_W
|
|
h:SetPoint("TOP", page, "TOPLEFT", avail / 2 + headerIndent, y)
|
|
|
|
-- Header actions occupy the right edge in descriptor order; placing them
|
|
-- backwards keeps the first action leftmost.
|
|
local leftEdge, rightEdge = 8 + headerIndent, avail - 8
|
|
if f.onDelete then
|
|
local del = poolDeleteButton(page, f.onDelete)
|
|
del:SetPoint("TOPRIGHT", page, "TOPLEFT", rightEdge, y + 2)
|
|
rightEdge = rightEdge - 16 - 6
|
|
end
|
|
for actionIndex = #(f.actions or {}), 1, -1 do
|
|
local action = f.actions[actionIndex]
|
|
local button = poolActionButton(page, action)
|
|
button:SetPoint("TOPRIGHT", page, "TOPLEFT", rightEdge, y + 2)
|
|
rightEdge = rightEdge - 16 - 6
|
|
end
|
|
if f.collapsed ~= nil then
|
|
-- The arrow points the way the section will move on click: down to
|
|
-- unfold a collapsed one, up to fold an open one.
|
|
local arrow = poolIconButton(page,
|
|
W.LIBWIDGETS_TEXTURES .. (f.collapsed and "down" or "up"),
|
|
f.onToggle or function() end)
|
|
arrow:SetPoint("TOPLEFT", 8 + headerIndent, y + 2)
|
|
leftEdge = 8 + headerIndent + 16 + 6
|
|
end
|
|
-- The whole title line toggles too, not just the small arrow. It's a
|
|
-- bare mouse-enabled frame (no textures of its own) laid over the
|
|
-- label, and it stops at rightEdge so it can never sit on top of the
|
|
-- delete button and swallow its clicks.
|
|
if f.collapsed ~= nil then
|
|
local hit = poolHitArea(page, f.onToggle or function() end, f.tooltip)
|
|
hit:SetPoint("TOPLEFT", leftEdge, y + 2)
|
|
hit:SetPoint("TOPRIGHT", page, "TOPLEFT", rightEdge, y + 2)
|
|
end
|
|
|
|
local tw = h:GetStringWidth() or 0
|
|
local midY, cx, pad = y - 6, avail / 2, 8
|
|
-- Only draw the flanking rules when the label leaves room for them;
|
|
-- otherwise the texture ends would cross and render inverted.
|
|
if cx - tw / 2 - pad > leftEdge + 4 then
|
|
local ll = poolRule(page)
|
|
ll:SetPoint("LEFT", page, "TOPLEFT", leftEdge, midY)
|
|
ll:SetPoint("RIGHT", page, "TOPLEFT", cx - tw / 2 - pad, midY)
|
|
local rl = poolRule(page)
|
|
rl:SetPoint("LEFT", page, "TOPLEFT", cx + tw / 2 + pad, midY)
|
|
rl:SetPoint("RIGHT", page, "TOPLEFT", rightEdge, midY)
|
|
end
|
|
y = y - 18
|
|
elseif f.half then
|
|
if not pending then
|
|
pending, pendingY = f, y
|
|
else
|
|
-- Both controls hang from the deeper of the two caption lines, so
|
|
-- a one-line toggle lands beside the control of the labelled field
|
|
-- next to it rather than beside that field's caption. The row is
|
|
-- then as tall as the taller of the two bodies under that line.
|
|
local leadL, leadR = fieldLead(pending), fieldLead(f)
|
|
local top = leadL > leadR and leadL or leadR
|
|
local lh = (top - leadL) + placeField(page, pending, leftX, pendingY - (top - leadL), colW - 8)
|
|
local rh = (top - leadR) + placeField(page, f, rightX, pendingY - (top - leadR), colW - 8)
|
|
y = pendingY - (lh > rh and lh or rh)
|
|
pending = nil
|
|
end
|
|
else
|
|
flushPending()
|
|
y = y - placeField(page, f, 8, y, fullW)
|
|
end
|
|
end
|
|
flushPending()
|
|
|
|
-- Total laid-out height (y runs negative from the top), so a scroll-child
|
|
-- container can size itself to the content -- see OptionsFrame.lua's content
|
|
-- scroll frame.
|
|
page.contentHeight = -y + 8
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Context menu
|
|
--
|
|
-- W.ContextMenu(parent) -> a frame with Open(items, anchor) and Close(). The
|
|
-- item list is passed to every Open, so one menu instance serves any number of
|
|
-- differently-shaped menus:
|
|
-- { text = "Duplicate", onClick = function() end }
|
|
-- { text = "Copy settings", submenu = { ...items... } } -- one level only
|
|
-- { separator = true }
|
|
-- { text = "Delete", disabled = true } -- greyed, inert
|
|
-- { text = "Delete", confirm = true, onClick = ... } -- two-click morph
|
|
-- Item buttons come from the same per-kind pool BuildOptions uses, so contents
|
|
-- that change per opening cost no new frames after the first paint.
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local MENU_ITEM_H = 16
|
|
local MENU_PAD = 3
|
|
local MENU_MIN_W, MENU_MAX_W = 120, 260
|
|
|
|
local paintMenu
|
|
|
|
local function menuItemClicked(b)
|
|
local item = b.item
|
|
if not item or item.disabled then return end
|
|
-- A confirm item stays open across its first click: a menu that closed on
|
|
-- every click could not show an armed state at all.
|
|
if item.confirm and not b.confirming then
|
|
b.confirming = true
|
|
b.label:SetText("Confirm?")
|
|
C_Timer.After(3, function()
|
|
-- Buttons are pooled and rebound: only revert one still holding the
|
|
-- item that armed it.
|
|
if b.confirming and b.item == item then
|
|
b.confirming = nil
|
|
b.label:SetText(item.text or "")
|
|
end
|
|
end)
|
|
return
|
|
end
|
|
b.confirming = nil
|
|
b.menu.Root().Close()
|
|
if item.onClick then item.onClick() end
|
|
end
|
|
|
|
local function menuItemEntered(b)
|
|
local sub = b.menu.sub
|
|
if not sub then return end
|
|
-- Entering any item takes down whatever submenu the previous one opened, so
|
|
-- at most one is ever up.
|
|
sub:Hide()
|
|
local item = b.item
|
|
if not item or item.disabled or not item.submenu then return end
|
|
paintMenu(sub, item.submenu)
|
|
sub:ClearAllPoints()
|
|
local right = b:GetRight()
|
|
local screenW = UIParent:GetWidth()
|
|
if not screenW or screenW <= 0 then screenW = GetScreenWidth() end
|
|
if right and right + (sub:GetWidth() or 0) > screenW then
|
|
sub:SetPoint("TOPRIGHT", b, "TOPLEFT", -2, MENU_PAD)
|
|
else
|
|
sub:SetPoint("TOPLEFT", b, "TOPRIGHT", 2, MENU_PAD)
|
|
end
|
|
sub:Show()
|
|
end
|
|
|
|
local function newMenuItem(f)
|
|
local b = CreateFrame("Button", nil, f)
|
|
b:SetHeight(MENU_ITEM_H)
|
|
b.menu = f
|
|
|
|
local fs = b:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
|
|
fs:SetPoint("LEFT", 4, 0)
|
|
fs:SetJustifyH("LEFT")
|
|
b.label = fs
|
|
|
|
local arrow = b:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
|
|
arrow:SetPoint("RIGHT", -4, 0)
|
|
arrow:SetText(">")
|
|
arrow:Hide()
|
|
b.arrow = arrow
|
|
|
|
local hl = b:CreateTexture(nil, "HIGHLIGHT")
|
|
hl:SetAllPoints(b)
|
|
hl:SetTexture(0.3, 0.3, 0.8, 0.4)
|
|
b.hl = hl
|
|
|
|
b:SetScript("OnClick", function() menuItemClicked(b) end)
|
|
b:SetScript("OnEnter", function() menuItemEntered(b) end)
|
|
return b
|
|
end
|
|
|
|
function paintMenu(f, items)
|
|
-- Same hide-first ordering BuildOptions uses: everything goes down before
|
|
-- anything comes back up, so a paint that errors partway cannot leave an
|
|
-- item from the previous opening on screen.
|
|
for kind, pool in pairs(f.widgetPools) do
|
|
f.widgetUsed[kind] = 0
|
|
for i = 1, #pool do pool[i]:Hide() end
|
|
end
|
|
|
|
local width = MENU_MIN_W
|
|
local y = -MENU_PAD
|
|
for i = 1, #items do
|
|
local item = items[i]
|
|
if item.separator then
|
|
local t = acquire(f, "menusep", function()
|
|
local tex = f:CreateTexture(nil, "ARTWORK")
|
|
tex:SetHeight(1)
|
|
tex:SetTexture(0.4, 0.4, 0.4, 0.8)
|
|
return tex
|
|
end)
|
|
t:SetPoint("TOPLEFT", f, "TOPLEFT", MENU_PAD + 2, y - 3)
|
|
t:SetPoint("TOPRIGHT", f, "TOPRIGHT", -(MENU_PAD + 2), y - 3)
|
|
y = y - 7
|
|
else
|
|
local b = acquire(f, "menuitem", function() return newMenuItem(f) end)
|
|
b:SetPoint("TOPLEFT", f, "TOPLEFT", MENU_PAD, y)
|
|
b:SetPoint("TOPRIGHT", f, "TOPRIGHT", -MENU_PAD, y)
|
|
b.item = item
|
|
-- Disarm on reuse, or a pooled button armed by the last opening would
|
|
-- commit that click against whatever item it holds in this one.
|
|
b.confirming = nil
|
|
b.label:SetText(item.text or "")
|
|
if item.disabled then
|
|
b.label:SetTextColor(0.5, 0.5, 0.5)
|
|
b.hl:SetTexture(0, 0, 0, 0)
|
|
else
|
|
b.label:SetTextColor(1, 1, 1)
|
|
b.hl:SetTexture(0.3, 0.3, 0.8, 0.4)
|
|
end
|
|
if item.submenu then b.arrow:Show() else b.arrow:Hide() end
|
|
local w = (b.label:GetStringWidth() or 0) + 2 * MENU_PAD + 12
|
|
if item.submenu then w = w + 14 end
|
|
if w > width then width = w end
|
|
y = y - MENU_ITEM_H
|
|
end
|
|
end
|
|
if width > MENU_MAX_W then width = MENU_MAX_W end
|
|
f:SetWidth(width)
|
|
f:SetHeight(-y + MENU_PAD)
|
|
end
|
|
|
|
local function newMenuFrame(parent)
|
|
local f = CreateFrame("Frame", nil, parent)
|
|
f:SetBackdrop(W.EDITBOX_BACKDROP)
|
|
f:SetBackdropColor(0, 0, 0, 0.95)
|
|
f:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.9)
|
|
f:SetFrameStrata("FULLSCREEN_DIALOG")
|
|
f:SetToplevel(true)
|
|
-- Mouse-enabled so a click on the menu's own padding is swallowed here
|
|
-- rather than falling through to the click-away catcher underneath.
|
|
f:EnableMouse(true)
|
|
f:Hide()
|
|
f.widgetPools, f.widgetUsed = {}, {}
|
|
return f
|
|
end
|
|
|
|
-- Places the menu at the cursor, flipping left/up against whichever screen edge
|
|
-- it would otherwise cross. `anchor` is the fallback for a client that gives no
|
|
-- cursor position, opening to the right of the frame that was clicked.
|
|
local function placeMenu(f, anchor)
|
|
local x, y = GetCursorPosition()
|
|
local scale = UIParent:GetEffectiveScale()
|
|
f:ClearAllPoints()
|
|
if not x or not scale or scale == 0 then
|
|
f:SetPoint("TOPLEFT", anchor or UIParent, "TOPRIGHT", 4, 0)
|
|
return
|
|
end
|
|
x, y = x / scale, y / scale
|
|
local screenW = UIParent:GetWidth()
|
|
if not screenW or screenW <= 0 then screenW = GetScreenWidth() end
|
|
local horiz = (x + (f:GetWidth() or 0) > screenW) and "RIGHT" or "LEFT"
|
|
local vert = (y - (f:GetHeight() or 0) < 0) and "BOTTOM" or "TOP"
|
|
f:SetPoint(vert .. horiz, UIParent, "BOTTOMLEFT", x, y)
|
|
end
|
|
|
|
function W.ContextMenu(parent)
|
|
local f = newMenuFrame(parent)
|
|
f.sub = newMenuFrame(f)
|
|
f.sub:SetFrameLevel(f:GetFrameLevel() + 5)
|
|
f.Root = function() return f end
|
|
f.sub.Root = f.Root
|
|
|
|
-- Click-outside dismissal. LibWidgets.CloseAllMenus only reaches clicks that
|
|
-- land on a LibWidgets control, so this needs its own full-screen catcher one
|
|
-- strata below the menu to also close on a click into the aura list, the tab
|
|
-- pane or the world.
|
|
local catcher = CreateFrame("Frame", nil, UIParent)
|
|
catcher:SetAllPoints(UIParent)
|
|
catcher:EnableMouse(true)
|
|
catcher:SetFrameStrata("FULLSCREEN")
|
|
catcher:Hide()
|
|
catcher:SetScript("OnMouseDown", function() f.Close() end)
|
|
f.catcher = catcher
|
|
|
|
f.Close = function()
|
|
f.sub:Hide()
|
|
f:Hide()
|
|
catcher:Hide()
|
|
end
|
|
|
|
f.Open = function(items, anchor)
|
|
-- A drop-button popup and a context menu must never be up at once.
|
|
LibWidgets.CloseAllMenus()
|
|
f.sub:Hide()
|
|
paintMenu(f, items)
|
|
placeMenu(f, anchor)
|
|
catcher:Show()
|
|
f:Show()
|
|
end
|
|
|
|
-- The catcher is a UIParent child, so nothing takes it down when the menu's
|
|
-- own parent hides -- and a stranded catcher is a full-screen invisible
|
|
-- mouse blocker over the whole UI.
|
|
f:SetScript("OnHide", function()
|
|
f.sub:Hide()
|
|
catcher:Hide()
|
|
end)
|
|
return f
|
|
end
|