diff --git a/.pkgmeta b/.pkgmeta
index b21614da8..96d2c4498 100644
--- a/.pkgmeta
+++ b/.pkgmeta
@@ -2,6 +2,7 @@ package-as: EllesmereUI
move-folders:
EllesmereUI/EllesmereUIActionBars: EllesmereUIActionBars
+ EllesmereUI/EllesmereUIActionPalette: EllesmereUIActionPalette
EllesmereUI/EllesmereUIAuraBuffReminders: EllesmereUIAuraBuffReminders
EllesmereUI/EllesmereUIBags: EllesmereUIBags
EllesmereUI/EllesmereUIBasics: EllesmereUIBasics
diff --git a/.tools/palette-nest/README b/.tools/palette-nest/README
new file mode 100644
index 000000000..68390b822
--- /dev/null
+++ b/.tools/palette-nest/README
@@ -0,0 +1 @@
+cd EllesmereUIActionPalette && lua ../.tools/palette-nest/nest_agree.lua . 3
diff --git a/.tools/palette-nest/nest_agree.lua b/.tools/palette-nest/nest_agree.lua
new file mode 100644
index 000000000..b3515be0c
--- /dev/null
+++ b/.tools/palette-nest/nest_agree.lua
@@ -0,0 +1,2906 @@
+-- Does the release snippet choose the same entry the palette draws as selected?
+--
+-- Loads the REAL module against a stub client, then sweeps cursor positions
+-- comparing PaletteView:HitTest (radians, math.atan2) with the extracted
+-- SNIPPET_PRE (degrees, WoW's global atan2) fed from the very attributes
+-- PushPalette writes. Those two have disagreed before over exactly this kind of
+-- thing, and the symptom in game is "it fired the entry next to the one I aimed
+-- at" -- easy to miss and hard to attribute.
+
+local ADDON = arg[1] or "."
+local unpackedAtan = math.atan
+math.atan2 = math.atan2 or function(y, x) return unpackedAtan(y, x) end
+
+----------------------------------------------------------------------------
+-- Stub client
+----------------------------------------------------------------------------
+local CURSOR = { x = 0, y = 0 }
+local byName = {}
+
+local function NewFrame(kind, name)
+ local f = { _kind = kind, _name = name, _w = 0, _h = 0, _shown = true,
+ _attr = {}, _refs = {}, _cx = 0, _cy = 0 }
+
+ function f:SetSize(w, h) self._w, self._h = w, h end
+ function f:GetSize() return self._w, self._h end
+ function f:GetWidth() return self._w end
+ function f:GetHeight() return self._h end
+ function f:GetCenter() return self._cx, self._cy end
+ function f:SetCenter(x, y) self._cx, self._cy = x, y end
+ function f:GetEffectiveScale() return 1 end
+ function f:SetShown(v) self._shown = v and true or false end
+ function f:Show() self._shown = true end
+ function f:Hide() self._shown = false end
+ function f:IsShown() return self._shown end
+ function f:IsVisible() return self._shown end
+ function f:SetAttribute(k, v) self._attr[k] = v end
+ function f:GetAttribute(k) return self._attr[k] end
+ function f:GetName() return self._name end
+ function f:SetFrameRef(k, v) self._refs[k] = v end
+ function f:GetFrameRef(k) return self._refs[k] end
+ function f:CreateTexture() return NewFrame("texture") end
+ function f:CreateFontString() return NewFrame("fontstring") end
+
+ -- Real geometry and z-order, for the focus simulator below -- everything
+ -- else in this file works from view.claims directly and never asked a
+ -- frame handle where it actually sits, but the arming gates only exist as
+ -- real anchored, leveled frames, and topmost-wins focus needs both.
+ f._level = 0
+ function f:SetFrameLevel(n) self._level = n end
+ function f:GetFrameLevel() return self._level end
+ function f:SetFrameStrata(s) self._strata = s end
+ function f:GetFrameStrata() return self._strata end
+ f._motion = false
+ function f:SetMouseMotionEnabled(v) self._motion = v and true or false end
+ function f:IsMouseMotionEnabled() return self._motion end
+ function f:SetMouseClickEnabled(v) self._click = v and true or false end
+ function f:SetWidth(w) self._w = w end
+ function f:SetHeight(h) self._h = h end
+ -- Only the one anchor shape the module itself ever uses on a gate:
+ -- SetPoint("BOTTOMLEFT", rel, "BOTTOMLEFT", x, y). rel's own bottom-left
+ -- is read off its centre when it has one (UIParent does), or off its own
+ -- tracked position otherwise -- gates never anchor to one another, so one
+ -- level of indirection is all this needs.
+ function f:SetPoint(point, rel, relPoint, x, y)
+ local blx, bly = 0, 0
+ if rel then
+ if rel._cx then
+ blx, bly = rel._cx - rel._w * 0.5, rel._cy - rel._h * 0.5
+ else
+ blx, bly = rel._px or 0, rel._py or 0
+ end
+ end
+ self._px, self._py = blx + (x or 0), bly + (y or 0)
+ self._positioned = true
+ end
+ -- A frame with its points cleared and never re-anchored has no rect a
+ -- real cursor could ever be inside -- exactly the state a stale gate
+ -- ought to be in, and exactly what the hygiene fix restores it to.
+ function f:ClearAllPoints() self._positioned = false end
+ -- Normalised position within this frame, nil outside it -- the contract
+ -- RestrictedFrames.lua:307 gives the sandbox.
+ function f:GetMousePosition()
+ local w, h = self._w, self._h
+ local x = (CURSOR.x - (self._cx - w * 0.5)) / w
+ local y = (CURSOR.y - (self._cy - h * 0.5)) / h
+ if x < 0 or x > 1 or y < 0 or y > 1 then return nil end
+ return x, y
+ end
+
+ -- Anything else: a no-op METHOD only. Unknown lowercase keys stay nil, so a
+ -- field the module expects to have set (widget.baseSize, widget.icon) is not
+ -- silently answered with a function that then fails as a number.
+ setmetatable(f, { __index = function(_, k)
+ if type(k) == "string" and k:match("^%u") then return function() end end
+ return nil
+ end })
+
+ if name then byName[name] = f end
+ return f
+end
+
+_G.CreateFrame = function(kind, name) return NewFrame(kind, name) end
+_G.UIParent = NewFrame("Frame", "UIParent")
+UIParent:SetSize(1920, 1080)
+UIParent:SetCenter(960, 540)
+
+_G.GetCursorPosition = function() return CURSOR.x, CURSOR.y end
+_G.GetCursorInfo = function() return nil end
+_G.ClearCursor = function() end
+-- A secure button is built only for a palette that HAS a key, and every sweep
+-- reads the pushed attributes off one, so each palette answers a key here. What
+-- a palette needs a key FOR is not what this harness measures -- it reads the
+-- geometry the push writes, and an unbound palette is never pushed at all.
+_G.GetBindingKey = function(action)
+ local n = tostring(action or ""):match("^EUI_RADIAL(%d+)$")
+ return n and ("F" .. n) or nil
+end
+_G.InCombatLockdown = function() return false end
+_G.GetTime = function() return 0 end
+-- ns.Refresh coalesces its push behind a timer, so a sweep that pushed and
+-- then read the attributes back in the same breath would be reading the
+-- PREVIOUS push. Fired inline, which collapses the coalescing to nothing and
+-- leaves Refresh exactly as synchronous as it has to be here. There is no
+-- frame loop to drive a real one from anyway.
+_G.C_Timer = { After = function(_, fn) fn() end }
+_G.CooldownFrame_Set = function() end
+
+-- The sandbox's atan2 is WoW's GLOBAL one: degrees, same argument order as
+-- math.atan2 (Compat.lua:25). Getting this wrong here would hide the very bug
+-- the harness exists to catch, so it is spelled out rather than borrowed.
+-- Defined up here, ahead of the module load, because SecureHandlerWrapScript
+-- below needs it too: EnsureGates calls it for real, during the module's own
+-- OnEnable, so the gate snippets have to be compilable from the moment the
+-- module starts running rather than only once the click snippet is later
+-- extracted by hand.
+local sandbox = {
+ tonumber = tonumber,
+ floor = math.floor,
+ abs = math.abs,
+ atan2 = function(x, y) return math.deg(math.atan2(x, y)) end,
+}
+
+-- REAL SecureHandlerWrapScript, not an emulation of what it is supposed to
+-- do: this is the gap the rest of this file's own StepArmed function leaves
+-- open, and the one place a difference between "the file's comments describe
+-- this" and "the file's code does this" can hide from every sweep below.
+-- Mirrors SecureHandlers.lua's own Wrapped_OnEnter/Wrapped_OnLeave precisely,
+-- "_wrapentered" included: that flag is raised only from INSIDE a wrapped
+-- OnEnter, so a frame whose OnEnter was never wrapped can never satisfy
+-- Wrapped_OnLeave's own guard, and its OnLeave preBody silently never runs at
+-- all, however many times the frame's OnLeave itself fires. Getting this
+-- wrong here -- treating "OnLeave was wrapped" as "the leave body runs" --
+-- would hide exactly the bug this harness extension exists to catch.
+local WRAP_SIG = {
+ OnClick = { pre = "self,button,down", post = "self,message,button,down" },
+ OnEnter = { pre = "self", post = "self,message" },
+ OnLeave = { pre = "self", post = "self,message" },
+}
+local function CompileWrap(script, body)
+ if body == nil then return nil end
+ local sig = WRAP_SIG[script] or { pre = "self" }
+ return assert(load("local " .. sig.pre .. " = ...\n" .. body, script, "t", sandbox))
+end
+-- Validates preBody/postBody exactly as SecureHandlers.lua's own
+-- SecureHandlerWrapScript does (preBody a string always, postBody a string
+-- OR nil, nothing else) and error()s the same way it does when they are
+-- not -- deliberately NOT tolerant of a stray extra value here. One real
+-- caller (EnsureGates, wrapping a claim's region gate's OnLeave) builds its
+-- preBody with a chained ":gsub" and no parentheses around the return, so
+-- gsub's OWN second return value -- a substitution count -- rides along as
+-- an unintended fifth argument to THIS call, landing in postBody as a
+-- number. Silently tolerating that (treating a non-string postBody as "no
+-- postBody") would have hidden the very crash this bug causes in the real
+-- game: every real call shaped like that one throws "Invalid post-handler
+-- body" and aborts, right there, whatever EnsureGates was in the middle of
+-- building.
+_G.SecureHandlerWrapScript = function(frame, script, header, pre, post)
+ if type(pre) ~= "string" then error("Invalid pre-handler body") end
+ if post ~= nil and type(post) ~= "string" then error("Invalid post-handler body") end
+ frame._wrap = frame._wrap or {}
+ frame._wrap[script] = { pre = CompileWrap(script, pre), post = CompileWrap(script, post) }
+end
+-- self == frame that fired OnEnter/OnLeave; motion is always true here -- the
+-- harness only ever drives this off a genuine cursor move (see MoveCursor).
+local function FireOnEnter(frame, motion)
+ local w = frame._wrap and frame._wrap.OnEnter
+ if not w then return end
+ if motion then
+ frame._wrapentered = true
+ if w.pre then w.pre(frame) end
+ end
+end
+local function FireOnLeave(frame, motion)
+ local w = frame._wrap and frame._wrap.OnLeave
+ if not w then return end
+ if motion and frame._wrapentered then
+ frame._wrapentered = nil
+ if w.pre then w.pre(frame) end
+ end
+end
+_G.SecureHandlerSetFrameRef = function(f, k, v) f:SetFrameRef(k, v) end
+_G.SetOverrideBindingClick = function() end
+_G.ClearOverrideBindings = function() end
+_G.RAID_CLASS_COLORS = {}
+_G.UnitClass = function() return "Mage", "MAGE", 8 end
+_G.GetMacroInfo = function() return nil end
+-- The usability and count getters answer "nothing to say" rather than a state:
+-- what they return decides a tint and a corner number, neither of which this
+-- harness compares. What matters is that they EXIST, so PaintCell takes the
+-- same path it does in game.
+_G.C_Spell = { GetSpellInfo = function(id)
+ return { name = "Spell" .. tostring(id), iconID = 1 } end,
+ GetSpellCooldownDuration = function() return nil end,
+ GetSpellCharges = function() return nil end,
+ IsSpellUsable = function() return true end,
+ IsSpellInRange = function() return nil end }
+_G.C_Item = { GetItemInfoInstant = function() return nil end,
+ GetItemInfo = function() return nil end,
+ GetItemCount = function() return 0 end,
+ ItemHasRange = function() return false end,
+ IsItemInRange = function() return nil end,
+ IsUsableItem = function() return true end,
+ GetItemCooldown = function() return 0, 0, 0 end }
+_G.C_SpecializationInfo = {
+ GetNumSpecializationsForClassID = function() return 0 end,
+ GetSpecializationInfo = function() return nil end,
+ SetSpecialization = function() end,
+}
+_G.C_ToyBox = { GetToyInfo = function() return nil end }
+_G.C_MountJournal = { GetMountInfoByID = function() return nil end }
+_G.C_PetJournal = { GetPetInfoByPetID = function() return nil end }
+_G.SlashCmdList = {}
+
+local addonObj
+local function DeepCopy(t)
+ local out = {}
+ for k, v in pairs(t) do
+ out[k] = (type(v) == "table") and DeepCopy(v) or v
+ end
+ return out
+end
+
+_G.EllesmereUI = {
+ Lite = {
+ NewAddon = function()
+ addonObj = { RegisterEvent = function() end }
+ return addonObj
+ end,
+ NewDB = function(_, defaults) return { profile = DeepCopy(defaults.profile) } end,
+ },
+ Print = function() end,
+ MEDIA_PATH = "",
+}
+
+----------------------------------------------------------------------------
+-- Load and start the module
+----------------------------------------------------------------------------
+local ns = {}
+assert(loadfile(ADDON .. "/EllesmereUIActionPalette.lua"))("EUIActionPalette", ns)
+addonObj:OnInitialize()
+-- The module ships switched OFF and OnEnable does nothing at all until it is
+-- switched on, so this is part of the setup rather than part of the test.
+ns.Profile().enabled = true
+addonObj:OnEnable() -- builds the live view PushPalette measures against
+
+local p = ns.Profile()
+p.layout = "ARC"
+p.centerMode = "SCREEN" -- fixed centre: the snippet and the view agree on it
+p.posX, p.posY, p.scale = 0, 0, 1
+p.showNeedle = false
+
+----------------------------------------------------------------------------
+-- The snippet, compiled against its REAL signature
+----------------------------------------------------------------------------
+local src = io.open(ADDON .. "/EllesmereUIActionPalette.lua"):read("a")
+local body = src:match("local SNIPPET_PRE = %[==%[(.-)%]==%]")
+assert(body, "SNIPPET_PRE not found")
+-- The real file bakes REGION_MAX into the loop bound in the press branch's
+-- gate-placement loop by plain substitution rather than string.format (the
+-- body is full of the modulo operator, which format chokes on) -- done here
+-- too, so the extracted copy actually compiles.
+body = body:gsub("__REGION_MAX__", tostring(ns.REGION_MAX))
+-- The arm-effects fragment the real file interpolates into SNIPPET_PRE's press
+-- branch (and into both gate snippets, which EnsureGates builds for real during
+-- the module load above, so those already carry it). Extracted and substituted
+-- the same way and in the same order, because the press-time geometric pre-arm
+-- lives entirely inside it: an extracted copy without it would compile, run,
+-- and quietly never arm anything at a press.
+local armFragment = src:match("local ARM_CLAIM = %[==%[(.-)%]==%]")
+assert(armFragment, "ARM_CLAIM not found")
+armFragment = armFragment:gsub("__REGION_MAX__", tostring(ns.REGION_MAX))
+body = body:gsub("__ARM_CLAIM__", function() return armFragment end)
+
+-- sandbox is the one defined above, ahead of the module load -- reused here
+-- rather than rebuilt, so the click snippet and the gate snippets can never
+-- silently drift onto two different atan2 implementations.
+local snippet = assert(load("local self, button, down = ...\n" .. body,
+ "SNIPPET_PRE", "t", sandbox))
+
+----------------------------------------------------------------------------
+-- Gate emulation
+--
+-- A geometric stand-in for the parent and region gate frames every claim
+-- gets: StepArmed answers what eapArmed becomes after ONE more sample of
+-- the cursor, given what it was before. This stub client cannot fire a real
+-- OnEnter/OnLeave at all, so the rule the file's own EnsureGates/
+-- EnterSnippet/LeaveSnippet comments describe is worked out here instead:
+--
+-- Exclusive arming. While a claim is armed, every OTHER claim's parent
+-- gate is hidden (see EnterSnippet), so it cannot steal focus just
+-- because the cursor also happens to sit over it. The armed claim's own
+-- TRUE region is therefore tested FIRST, and only once the cursor has
+-- genuinely left it -- and the other parent gates are shown again -- do
+-- the other parent boxes get a look at all. One StepArmed call is one
+-- real cursor motion, so a call whose destination is simultaneously
+-- outside the old claim's region and inside a new claim's parent box is
+-- exactly what a single mouse-move landing there would do in game: it
+-- disarms and re-arms in the same step, which is what LeaveSnippet's own
+-- geometric re-arm makes true of the real thing as well. What this does
+-- NOT model is the press-time pre-arm -- it has no press in it at all --
+-- so the real-gate section at the bottom of this file is what holds that
+-- path to account.
+--
+-- Geometric union, not "did I leave the rect". A claim's true ground is
+-- ClaimContains below: the parent box, plus either the polar wedge (ARC)
+-- or the union of c.regions (every other layout) -- see CorridorBox and
+-- CellChildGeom/ChildGeom in the file itself for what builds those.
+----------------------------------------------------------------------------
+local function InBox(b, dx, dy)
+ return b ~= nil and math.abs(dx - b.x) <= b.hw and math.abs(dy - b.y) <= b.hh
+end
+
+-- LeaveSnippet's own ANGULAR ground test -- c.ground's beam and wedge, NOT
+-- the release's per-ring resolution: the ground a claim stays armed on has to
+-- include the sides of its parent's icon and the entry ring's own radius,
+-- which answer to no ring at all. Worked in radians here since this is plain
+-- Lua rather than the sandboxed snippet; ChildGeom builds c.ground in radians
+-- and PushPalette converts only when writing the eapC* attributes.
+local function InWedge(c, dx, dy)
+ if InBox(c.parentBox, dx, dy) then return true end
+ local g = c.ground
+ if not g then return false end
+ local u = dx * g.ax + dy * g.ay
+ if u < g.lo then return false end
+ local v = math.abs(dx * g.ay - dy * g.ax)
+ if v <= g.beam + u * g.slope then return true end
+ if (dx * dx + dy * dy) ^ 0.5 < g.edge then return false end
+ local ad = (math.atan2(dx, dy) - c.angle) % (2 * math.pi)
+ if ad > math.pi then ad = 2 * math.pi - ad end
+ return ad <= g.half
+end
+
+local function ClaimContains(view, c, dx, dy)
+ if view:LayoutMode() == "ARC" then return InWedge(c, dx, dy) end
+ for _, r in ipairs(c.regions or {}) do
+ if InBox(r, dx, dy) then return true end
+ end
+ return false
+end
+
+local function StepArmed(view, armed, dx, dy)
+ local claims = view.claims
+ if armed and claims and claims[armed]
+ and ClaimContains(view, claims[armed], dx, dy) then
+ return armed
+ end
+ for k = 1, (claims and #claims or 0) do
+ if InBox(claims[k].parentBox, dx, dy) then return k end
+ end
+ return nil
+end
+
+-- What eapArmed is after a cursor that starts nowhere near any claim, passes
+-- through claim k's own parent entry, and travels on to (dx, dy) -- the
+-- shortest path that actually exercises the pass-through rule. Answers k only
+-- if (dx, dy) is still somewhere in k's own region once it gets there;
+-- answers whatever OTHER claim's parent box (dx, dy) itself lands on instead,
+-- topmost-wins, same as a real cursor walking there would.
+local function ArmedViaParent(view, k, dx, dy)
+ local c = view.claims and view.claims[k]
+ if not c or not c.parentBox then return nil end
+ local armed = StepArmed(view, nil, 1e6, 1e6)
+ armed = StepArmed(view, armed, c.parentBox.x, c.parentBox.y)
+ return StepArmed(view, armed, dx, dy)
+end
+
+----------------------------------------------------------------------------
+-- Sweep one configuration
+----------------------------------------------------------------------------
+local function Sweep(label, setup, step)
+ setup()
+ ns.Refresh()
+
+ local btn = byName["EUIActionPaletteButton1"]
+ assert(btn, "no secure button")
+ btn:SetFrameRef("ui", UIParent)
+
+ -- A view of our own, laid out from the same profile: the live one is a file
+ -- local. Same class, same data, so its hit test is the live palette's.
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ view._steered = true
+
+ -- Press edge, far from the palette, so the "cursor never moved" gate can
+ -- never trip during the sweep itself.
+ CURSOR.x, CURSOR.y = 20, 20
+ snippet(btn, "LeftButton", true)
+
+ local checked, mismatch, hard, first, nested = 0, 0, 0, nil, 0
+ local reach = 420
+ for dx = -reach, reach, step do
+ for dy = -reach, reach, step do
+ CURSOR.x, CURSOR.y = 960 + dx, 540 + dy
+ -- Unarmed: this sample never passed through any claim's own
+ -- parent entry to get here, so neither side may answer with one
+ -- of its children -- the pass-through rule this whole harness
+ -- extension exists to hold both sides to. See SweepArmed below
+ -- for the path that DOES arm a claim.
+ btn:SetAttribute("eapArmed", nil)
+ local want = view:HitTest()
+ snippet(btn, "LeftButton", false)
+ local why = btn:GetAttribute("eapWhy")
+ local got = (why ~= "deadzone" and why ~= "noidx" and why ~= "unmoved")
+ and btn:GetAttribute("eapIdx") or nil
+ checked = checked + 1
+ if want and want > view.shownCount then nested = nested + 1 end
+ if want ~= got then
+ -- A disagreement exactly ON a sector edge is float noise: the
+ -- two do the same arithmetic in different units, and the file
+ -- already documents that they land either side of the rounding
+ -- there. One that survives a nudge in every direction is a real
+ -- difference in the RULE, which is what this is hunting.
+ local structural = true
+ for _, d in ipairs({ {1,0}, {-1,0}, {0,1}, {0,-1} }) do
+ CURSOR.x, CURSOR.y = 960 + dx + d[1], 540 + dy + d[2]
+ local w2 = view:HitTest()
+ snippet(btn, "LeftButton", false)
+ local y2 = btn:GetAttribute("eapWhy")
+ local g2 = (y2 ~= "deadzone" and y2 ~= "noidx" and y2 ~= "unmoved")
+ and btn:GetAttribute("eapIdx") or nil
+ if w2 == g2 then structural = false break end
+ end
+ CURSOR.x, CURSOR.y = 960 + dx, 540 + dy
+ mismatch = mismatch + 1
+ if structural then
+ hard = hard + 1
+ first = first or (" STRUCTURAL at dx=%d dy=%d view=%s snippet=%s why=%s")
+ :format(dx, dy, tostring(want), tostring(got), tostring(why))
+ end
+ end
+ end
+ end
+ -- Unarmed, a nest is no longer merely undrawn -- it is UNREACHABLE. Every
+ -- one of these samples arrived with eapArmed cleared, so a claim that
+ -- still answered here would mean the pass-through rule leaked: the
+ -- release fired a child the cursor never earned by going through its
+ -- parent first. That is a hard failure now, not a vacuous-sweep warning.
+ local leaked = (nested > 0) and " <-- ARMED WHILE UNARMED" or ""
+ print(("%-34s %8d checked %5d on an edge %5d structural%s%s"):format(
+ label, checked, mismatch - hard, hard, hard > 0 and " <-- FAIL" or "", leaked))
+ if first then print(first) end
+ return hard + ((nested > 0) and 1 or 0)
+end
+
+local function Palette(i) return ns.EnsurePalette(i) end
+
+local bad = 0
+local step = tonumber(arg[2]) or 3
+
+-- Palette 1 holds four spells; slot 3 opens palette 2, which holds five.
+local function Base(nParent, nestAt, nChild)
+ return function()
+ local a = Palette(1)
+ a.slots = {}
+ for i = 1, nParent do a.slots[i] = { kind = "spell", id = 100 + i } end
+ if nestAt then a.slots[nestAt] = { kind = "palette", palette = 2 } end
+ local b = Palette(2)
+ b.slots = {}
+ for i = 1, nChild do b.slots[i] = { kind = "spell", id = 200 + i } end
+ p.paletteCount = 2
+ end
+end
+
+bad = bad + Sweep("flat, no nest", Base(4, nil, 0), step)
+
+bad = bad + Sweep("full circle, contained", function()
+ Base(4, 3, 5)()
+ p.arcSpan, p.arcChildOverflow = 360, "NONE"
+end, step)
+
+bad = bad + Sweep("full circle, overflowing", function()
+ Base(4, 3, 5)()
+ p.arcSpan, p.arcChildOverflow, p.arcChildMaxSpan = 360, "MIDPOINT", 90
+end, step)
+
+bad = bad + Sweep("open arc 180, contained", function()
+ Base(5, 2, 4)()
+ p.arcSpan, p.arcRotation, p.arcChildOverflow = 180, 0, "NONE"
+end, step)
+
+bad = bad + Sweep("open arc 120 rotated, overflow", function()
+ Base(5, 4, 8)()
+ p.arcSpan, p.arcRotation = 120, -60
+ p.arcChildOverflow, p.arcChildMaxSpan = "MIDPOINT", 120
+end, step)
+
+bad = bad + Sweep("two nests, adjacent", function()
+ Base(4, 2, 6)()
+ Palette(1).slots[3] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 3 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.arcSpan, p.arcChildOverflow = 360, "MIDPOINT"
+end, step)
+
+bad = bad + Sweep("nest of one entry", function()
+ Base(3, 2, 1)()
+ p.arcSpan, p.arcChildOverflow = 360, "NONE"
+end, step)
+
+bad = bad + Sweep("twelve entries, eight children", function()
+ Base(12, 7, 8)()
+ p.arcSpan, p.arcChildOverflow = 360, "NONE"
+end, step)
+
+-- The parent sits at angle 0 -- straight up -- rather than off to a side.
+-- Nothing in ChildGeom is supposed to treat that angle differently from any
+-- other, but it is exactly where atan2's wrap from just-under-360 back to 0
+-- would show up if it ever did.
+bad = bad + Sweep("nest at top middle", function()
+ Base(3, 1, 5)()
+ p.arcSpan, p.arcChildOverflow = 360, "NONE"
+end, step)
+
+-- Eight children, a small radius and full-size child icons packed into a
+-- sixty-degree sector: row 1 only has room for a handful of them, so this
+-- claim spills into a second and third ring rather than growing the first
+-- ring's radius without bound.
+bad = bad + Sweep("crowded claim, spills into extra rings", function()
+ Base(6, 1, 8)()
+ p.arcSpan, p.arcChildOverflow = 360, "NONE"
+ p.radius, p.iconSize, p.nestScale, p.nestBand = 60, 44, 1.0, 10
+end, step)
+-- Every other config in this file leans on the coded fallbacks (`p.radius or
+-- 96` and the like) rather than setting these fields, so leaving them here
+-- would silently shrink every ring in every config that follows -- including
+-- the Crowding checks further down, which do not set them either.
+p.radius, p.nestScale, p.nestBand = nil, nil, nil
+
+----------------------------------------------------------------------------
+-- Block layouts steer in AdvanceGrid rather than HitTest, so they get their
+-- own sweep. Same question: does the release pick what the palette drew?
+----------------------------------------------------------------------------
+local function SweepCells(label, setup, step)
+ setup()
+ ns.Refresh()
+
+ local btn = byName["EUIActionPaletteButton1"]
+ btn:SetFrameRef("ui", UIParent)
+
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ view._steered = true
+ view._gateX, view._gateY = 20, 20
+
+ CURSOR.x, CURSOR.y = 20, 20
+ snippet(btn, "LeftButton", true)
+
+ local checked, edge, hard, first, nested = 0, 0, 0, nil, 0
+ local reach = 380
+ local function Pair(dx, dy)
+ CURSOR.x, CURSOR.y = 960 + dx, 540 + dy
+ -- Unarmed: see the same note in Sweep above. Every block-layout claim
+ -- goes through ArmedClaim now, so a cold sample must never answer with
+ -- one of its cells on either side.
+ btn:SetAttribute("eapArmed", nil)
+ view:AdvanceGrid()
+ local want = view:GetSelection()
+ snippet(btn, "LeftButton", false)
+ local why = btn:GetAttribute("eapWhy")
+ local got = (why ~= "outofreach" and why ~= "noidx" and why ~= "unmoved")
+ and btn:GetAttribute("eapIdx") or nil
+ return want, got, why
+ end
+
+ for dx = -reach, reach, step do
+ for dy = -reach, reach, step do
+ local want, got, why = Pair(dx, dy)
+ checked = checked + 1
+ if want and want > view.shownCount then nested = nested + 1 end
+ if want ~= got then
+ local structural = true
+ for _, d in ipairs({ {1,0}, {-1,0}, {0,1}, {0,-1} }) do
+ local w2, g2 = Pair(dx + d[1], dy + d[2])
+ if w2 == g2 then structural = false break end
+ end
+ if structural then
+ hard = hard + 1
+ first = first or (" STRUCTURAL at dx=%d dy=%d view=%s snippet=%s why=%s")
+ :format(dx, dy, tostring(want), tostring(got), tostring(why))
+ else
+ edge = edge + 1
+ end
+ end
+ end
+ end
+ -- Unarmed, a claim's cells are UNREACHABLE, not merely undrawn -- see the
+ -- matching note in Sweep. A hit here means the pass-through rule leaked.
+ local leaked = (nested > 0) and " <-- ARMED WHILE UNARMED" or ""
+ print(("%-34s %8d checked %5d on an edge %5d structural%s%s"):format(
+ label, checked, edge, hard, hard > 0 and " <-- FAIL" or "", leaked))
+ if first then print(first) end
+ return hard + ((nested > 0) and 1 or 0)
+end
+
+bad = bad + SweepCells("grid 3x3, nest on a corner", function()
+ Base(9, 1, 5)()
+ p.layout, p.gridAutoColumns = "GRID", true
+end, step)
+
+bad = bad + SweepCells("grid 3x3, nest in the middle", function()
+ Base(9, 5, 4)()
+ p.layout, p.gridAutoColumns = "GRID", true
+end, step)
+
+bad = bad + SweepCells("grid, two nests", function()
+ Base(8, 1, 6)()
+ Palette(1).slots[8] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 5 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout, p.gridAutoColumns = "GRID", true
+end, step)
+
+bad = bad + SweepCells("grid, adjacent nests compete", function()
+ Base(6, 1, 8)()
+ Palette(1).slots[2] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 8 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout, p.gridAutoColumns = "GRID", true
+end, step)
+
+bad = bad + SweepCells("grid halo, nest in the middle", function()
+ Base(9, 5, 8)()
+ p.layout, p.gridAutoColumns = "GRID", true
+ p.gridNestStyle = "HALO"
+end, step)
+
+bad = bad + SweepCells("grid halo, two of them adjacent", function()
+ Base(9, 4, 8)()
+ Palette(1).slots[5] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 8 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout, p.gridAutoColumns = "GRID", true
+ p.gridNestStyle = "HALO"
+end, step)
+
+-- The retired POPOUT style: a stored profile may still carry the value, and it
+-- has to read as PERIMETER everywhere -- NestMetrics folds it there, and this
+-- sweep holds the fold to the same agreement as a lane asked for by name.
+bad = bad + SweepCells("grid legacy popout value, reads as lane", function()
+ Base(9, 1, 6)()
+ p.layout, p.gridAutoColumns = "GRID", true
+ p.gridNestStyle = "POPOUT"
+end, step)
+
+-- A nested palette's full MAX_SLOTS through a lane, which seats them whole --
+-- the cap that stops at MAX_CHILDREN is for the nests bounded by their
+-- parent's own region. See NestChildCap.
+bad = bad + SweepCells("grid lane, nested palette of twelve", function()
+ Base(9, 5, ns.MAX_SLOTS or 12)()
+ p.layout, p.gridAutoColumns = "GRID", true
+end, step)
+
+bad = bad + SweepCells("grid lane, side flipped", function()
+ Base(9, 5, 4)()
+ p.layout, p.gridAutoColumns = "GRID", true
+ p.nestSide = "NEGATIVE"
+end, step)
+
+-- A GRID reaches NestMetrics' forced STRIP style whenever it comes out one row
+-- or one column deep, same as a pointer fan -- pinning gridColumns is the
+-- other way in, and it is worth its own check because a GRID palette gets
+-- there without ever touching FanHoriz.
+bad = bad + SweepCells("grid single row, nest in the middle", function()
+ Base(5, 3, 7)()
+ p.layout, p.gridAutoColumns, p.gridColumns = "GRID", false, 5
+end, step)
+p.gridColumns = nil
+
+bad = bad + SweepCells("grid single column, nest near an end", function()
+ Base(5, 4, 6)()
+ p.layout, p.gridAutoColumns, p.gridColumns = "GRID", false, 1
+end, step)
+p.gridColumns = nil
+
+-- Two nests whose parents are neighbours, with eight children each: their two
+-- runs lie over each other along the lane for most of their length, which is
+-- allowed -- only one of them is ever drawn and only one is ever armed. What
+-- this asks is that the two sides still agree about which cell is where when
+-- they do. Small children on purpose, so both runs are long. Whether each run
+-- gets its full length, and whether it stays in ONE row, is the lane placement
+-- section's question further down.
+bad = bad + SweepCells("grid lane, two nests share the lane", function()
+ Base(9, 1, 8)()
+ Palette(1).slots[2] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 8 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout, p.gridAutoColumns = "GRID", true
+ p.nestScale, p.nestBand = 0.6, 14
+end, step)
+p.nestScale, p.nestBand = nil, nil
+
+-- A strip ignores gridNestStyle: one entry deep, it has only ever the one
+-- answer. Set to something else here so that claim is under test rather than
+-- merely asserted in a comment.
+bad = bad + SweepCells("pointer fan, horizontal", function()
+ Base(5, 3, 6)()
+ p.layout, p.fanInput, p.fanOrientation = "FAN", "CURSOR", "HORIZONTAL"
+ p.gridNestStyle = "HALO"
+end, step)
+
+bad = bad + SweepCells("pointer fan, vertical", function()
+ Base(5, 2, 6)()
+ p.layout, p.fanInput, p.fanOrientation = "FAN", "CURSOR", "VERTICAL"
+end, step)
+
+bad = bad + SweepCells("pointer fan, nest side flipped", function()
+ Base(5, 2, 6)()
+ p.layout, p.fanInput, p.fanOrientation = "FAN", "CURSOR", "VERTICAL"
+ p.nestSide = "NEGATIVE"
+end, step)
+
+----------------------------------------------------------------------------
+-- A scroll-steered strip. The wheel picks the entry, so the sweep pins the
+-- accumulator and moves only the cursor -- which in this layout says two
+-- things and two only: which child of a nest, and whether the strip has been
+-- left. Both are what this is checking.
+----------------------------------------------------------------------------
+local function SweepStrip(label, setup, target, step)
+ setup()
+ ns.Refresh()
+
+ local btn = byName["EUIActionPaletteButton1"]
+ btn:SetFrameRef("ui", UIParent)
+
+ local view = ns.CreatePaletteView(UIParent, { live = false })
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+
+ CURSOR.x, CURSOR.y = 960, 540
+ snippet(btn, "LeftButton", true)
+ -- Both ends steered to the same entry: the wheel snippet keeps this on the
+ -- catcher, and the view reads it from there for a LIVE strip only.
+ local catcher = btn:GetFrameRef("catcher")
+ catcher:SetAttribute("eapFanTarget", target)
+ view.fanTarget = target
+ view._gateX, view._gateY = 960, 540
+
+ local checked, edge, hard, first, nested = 0, 0, 0, nil, 0
+ local reach = 260
+ local function Pair(dx, dy)
+ CURSOR.x, CURSOR.y = 960 + dx, 540 + dy
+ view:AdvanceFan(0)
+ local want = view:GetSelection()
+ snippet(btn, "LeftButton", false)
+ local why = btn:GetAttribute("eapWhy")
+ local got = (why ~= "thrownclear" and why ~= "noidx" and why ~= "unmoved")
+ and btn:GetAttribute("eapIdx") or nil
+ return want, got, why
+ end
+
+ for dx = -reach, reach, step do
+ for dy = -reach, reach, step do
+ local want, got, why = Pair(dx, dy)
+ checked = checked + 1
+ if want and want > view.shownCount then nested = nested + 1 end
+ if want ~= got then
+ local structural = true
+ for _, d in ipairs({ {1,0}, {-1,0}, {0,1}, {0,-1} }) do
+ local w2, g2 = Pair(dx + d[1], dy + d[2])
+ if w2 == g2 then structural = false break end
+ end
+ if structural then
+ hard = hard + 1
+ first = first or (" STRUCTURAL at dx=%d dy=%d view=%s snippet=%s why=%s")
+ :format(dx, dy, tostring(want), tostring(got), tostring(why))
+ else
+ edge = edge + 1
+ end
+ end
+ end
+ end
+ local vacuous = (nested == 0) and " <-- VACUOUS" or ""
+ print(("%-34s %8d checked %5d on an edge %5d structural%s%s"):format(
+ label, checked, edge, hard, hard > 0 and " <-- FAIL" or "", vacuous))
+ if first then print(first) end
+ return hard + ((nested == 0) and 1 or 0)
+end
+
+bad = bad + SweepStrip("scroll fan horizontal, on the nest", function()
+ Base(5, 3, 6)()
+ p.layout, p.fanInput, p.fanOrientation = "FAN", "SCROLL", "HORIZONTAL"
+end, 3, step)
+
+-- A nested palette's full MAX_SLOTS: the strip's nest is one row that spreads
+-- as wide as it needs to, so it seats them whole -- see NestChildCap.
+bad = bad + SweepStrip("scroll fan vertical, nest of twelve", function()
+ Base(5, 2, ns.MAX_SLOTS or 12)()
+ p.layout, p.fanInput, p.fanOrientation = "FAN", "SCROLL", "VERTICAL"
+end, 2, step)
+
+bad = bad + SweepStrip("scroll fan, side flipped", function()
+ Base(5, 2, 4)()
+ p.layout, p.fanInput, p.fanOrientation = "FAN", "SCROLL", "HORIZONTAL"
+ p.nestSide = "NEGATIVE"
+end, 2, step)
+
+----------------------------------------------------------------------------
+-- Appearance is PER PALETTE: a palette's own override is what both the
+-- drawing and the push must read, not the profile's value. These two sweeps
+-- set the profile to one layout and the palette to the other, so a read that
+-- went to the profile on either side would draw one layout and fire from the
+-- geometry of a completely different one -- which is not an off-by-one the
+-- sweeps above could ever produce, and would be silent everywhere else.
+----------------------------------------------------------------------------
+bad = bad + Sweep("override: profile FAN, palette ARC", function()
+ Base(5, 3, 5)()
+ p.layout, p.fanInput = "FAN", "CURSOR"
+ Palette(1).appearance = { layout = "ARC", arcSpan = 360 }
+ p.arcChildOverflow = "NONE"
+end, step)
+Palette(1).appearance = nil
+
+bad = bad + SweepCells("override: profile ARC, palette GRID", function()
+ Base(6, 2, 4)()
+ p.layout = "ARC"
+ Palette(1).appearance = { layout = "GRID", gridAutoColumns = false,
+ gridColumns = 3 }
+end, step)
+Palette(1).appearance = nil
+
+-- Back to the arc for the checks below.
+p.layout, p.fanInput, p.nestSide = "ARC", "SCROLL", "POSITIVE"
+p.gridNestStyle = "PERIMETER"
+
+----------------------------------------------------------------------------
+-- PushPalette measures through the LIVE view, which is laid out for whatever
+-- was drawn last -- palette 1 here, and never palette 2. So every number it
+-- writes onto palette 2's button has to come from palette 2's appearance
+-- while the view still holds palette 1's, which is the whole job of
+-- liveView.appIndex and of PushPalette reading PA(index) rather than P().
+--
+-- This is the one place either of those can be caught. The sweeps only ever
+-- drive palette 1, where the view's own index already equals the pushed one
+-- and both are no-ops.
+----------------------------------------------------------------------------
+do
+ local a = Palette(1)
+ a.slots = {}
+ for i = 1, 4 do a.slots[i] = { kind = "spell", id = 400 + i } end
+ -- An OPEN arc, so its own step is nothing like the full circle palette 2
+ -- inherits. eapStepDeg is pushed from the view for every layout, grid
+ -- included, so this is what catches a push that measured through the view
+ -- while the view was still holding the other palette.
+ a.appearance = { layout = "ARC", arcSpan = 120, arcRotation = 0, scale = 1,
+ centerMode = "SCREEN", posX = 0, posY = 0 }
+ local b = Palette(2)
+ b.slots = {}
+ for i = 1, 4 do b.slots[i] = { kind = "spell", id = 500 + i } end
+ -- Four columns where auto-columns would pick two, so the block is one row
+ -- rather than a square. Palette 1 does not override the column settings,
+ -- so a view left on palette 1 answers the auto shape instead.
+ b.appearance = { layout = "GRID", gridAutoColumns = false, gridColumns = 4,
+ scale = 1.5, centerMode = "SCREEN", posX = 120, posY = -80,
+ fanGap = 20 }
+ -- The profile says something different from BOTH of them, so a read that
+ -- fell back to it is a wrong answer rather than an accidentally right one.
+ p.paletteCount = 2
+ p.layout, p.scale, p.centerMode = "FAN", 0.5, "CURSOR"
+ p.posX, p.posY, p.fanGap = -999, -999, 3
+ p.arcSpan, p.arcRotation, p.gridAutoColumns = 360, 0, true
+ ns.Refresh()
+
+ local wrong = 0
+ local function Want(btn, key, want)
+ local got = btn:GetAttribute(key)
+ if got ~= want then
+ wrong = wrong + 1
+ print((" %s %s: pushed=%s wanted=%s"):format(
+ btn:GetName(), key, tostring(got), tostring(want)))
+ end
+ end
+
+ local b1 = byName["EUIActionPaletteButton1"]
+ local b2 = byName["EUIActionPaletteButton2"]
+ assert(b1 and b2, "both palettes need a secure button")
+
+ Want(b1, "eapMode", "ANGULAR")
+ -- An open arc of four entries divides by count MINUS ONE; a full circle
+ -- divides by the count. 120/3 against 360/4.
+ Want(b1, "eapStepDeg", 40)
+ Want(b1, "eapFull", false)
+ Want(b2, "eapStepDeg", 90)
+ Want(b2, "eapFull", true)
+ Want(b1, "eapScale", 1)
+ Want(b1, "eapPosX", 0)
+ Want(b2, "eapMode", "POINTER")
+ Want(b2, "eapScale", 1.5)
+ Want(b2, "eapFixed", true)
+ Want(b2, "eapPosX", 120)
+ Want(b2, "eapPosY", -80)
+
+ -- And the cell centres, which are the numbers the release actually
+ -- resolves against: a push that measured through palette 1's arc would
+ -- write no eapBX at all, and one that measured a grid at the profile's own
+ -- gap would write the right shape at the wrong pitch.
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(2)
+ local cols, rows = view:GridDims(4)
+ if cols ~= 4 or rows ~= 1 then
+ wrong = wrong + 1
+ print((" palette 2 draws %dx%d, wanted 4x1"):format(cols, rows))
+ end
+ local _, iconSize = view:Geom()
+ local pitch = iconSize + 20
+ for i = 1, 4 do
+ local bx, by = view:GridBase(i, cols, rows, pitch, 4)
+ Want(b2, "eapBX" .. i, bx)
+ Want(b2, "eapBY" .. i, by)
+ end
+ Want(b2, "eapPitch", pitch)
+
+ print(("push reads the pushed palette's own appearance %5d wrong%s"):format(
+ wrong, wrong > 0 and " <-- FAIL" or ""))
+ bad = bad + wrong
+
+ Palette(1).appearance, Palette(2).appearance = nil, nil
+ p.layout, p.scale, p.centerMode = "ARC", 1, "SCREEN"
+ p.posX, p.posY, p.fanGap = 0, 0, nil
+ p.gridAutoColumns = nil
+ ns.Refresh()
+end
+
+----------------------------------------------------------------------------
+-- The cell a hit test answers with must name the SAME action the button
+-- would fire from that index. An off-by-one between the view's flattening
+-- and the push would leave both sides agreeing on a number and firing the
+-- wrong thing, which the sweep above cannot see.
+----------------------------------------------------------------------------
+do
+ local a = Palette(1)
+ a.slots = { { kind = "spell", id = 11 }, { kind = "palette", palette = 2 },
+ { kind = "spell", id = 13 } }
+ local b = Palette(2)
+ b.slots = {}
+ for i = 1, 5 do b.slots[i] = { kind = "spell", id = 20 + i } end
+ p.paletteCount = 2
+ p.arcSpan, p.arcChildOverflow = 360, "NONE"
+ ns.Refresh()
+
+ local btn = byName["EUIActionPaletteButton1"]
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+
+ local wrong = 0
+ for idx = 1, (btn:GetAttribute("eapTotal") or 0) do
+ local slot = view:CellSlot(idx)
+ local pushed = btn:GetAttribute("eapV" .. idx)
+ local want = slot and select(3, ns.ResolveAction(slot)) or nil
+ local isPal = btn:GetAttribute("eapPal" .. idx)
+ if pushed ~= want then
+ wrong = wrong + 1
+ print((" cell %d: view=%s pushed=%s"):format(idx, tostring(want), tostring(pushed)))
+ end
+ if (slot and slot.kind == "palette") ~= (isPal == true) then
+ wrong = wrong + 1
+ print((" cell %d: palette marker disagrees"):format(idx))
+ end
+ end
+ print(("cell -> action mapping %8d cells %5d wrong%s"):format(
+ btn:GetAttribute("eapTotal") or 0, wrong, wrong > 0 and " <-- FAIL" or ""))
+ bad = bad + wrong
+end
+
+----------------------------------------------------------------------------
+-- No two cells may sit on top of each other. The sweep cannot see this: both
+-- sides run the SAME nearest-cell rule, so they would agree happily about an
+-- overlap that leaves the palette unreadable and one of the two entries
+-- unreachable.
+----------------------------------------------------------------------------
+do
+ local fails = 0
+ local function Crowding(label, setup)
+ setup()
+ ns.Refresh()
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+
+ local pitch = view:Pitch()
+ local cells = {}
+ local shown = view:ShownCount()
+ if view:LayoutMode() == "ARC" then
+ local radius = select(1, view:Geom())
+ local st, a0 = view:ArcGeom(shown)
+ for i = 1, shown do
+ local ang = a0 + (i - 1) * st
+ cells[#cells + 1] = { x = radius * math.sin(ang),
+ y = radius * math.cos(ang),
+ what = "entry " .. i }
+ end
+ else
+ local cols, rows = view:GridDims()
+ for i = 1, shown do
+ local x, y = view:GridBase(i, cols, rows, pitch)
+ cells[#cells + 1] = { x = x, y = y, what = "entry " .. i }
+ end
+ end
+ for ci, c in ipairs(view.claims or {}) do
+ for j = 1, c.n do
+ local x, y
+ if c.cells then
+ x, y = c.cells[j].x, c.cells[j].y
+ else
+ -- An arc claim is polar, and now possibly several rings
+ -- deep; the same crowding question needs it in the
+ -- frame's own units like everything else.
+ local r, ang = view:ChildRingPos(c, j)
+ x, y = r * math.sin(ang), r * math.cos(ang)
+ end
+ cells[#cells + 1] = { x = x, y = y, claim = ci,
+ what = "nest of " .. c.parent .. " #" .. j }
+ end
+ end
+
+ -- Two icons may not sit closer than the smaller of them is wide.
+ --
+ -- Two DIFFERENT claims' children are exempt: exactly one nest is drawn
+ -- and exactly one is armed at any moment, so two runs sharing ground is
+ -- not a crowd -- the other one is not on the screen to crowd anything.
+ -- Two nests near each other used to divide the lane between them, which
+ -- kept them apart here and cost them their runs: neighbouring claims got
+ -- room for one cell each and stacked the rest outward into rows.
+ local floorGap = view.claims and view.claims[1]
+ and math.min(pitch, view.claims[1].icon) or pitch
+ local worst, worstPair = math.huge, nil
+ for i = 1, #cells do
+ for j = i + 1, #cells do
+ local sameClaim = cells[i].claim == cells[j].claim
+ if sameClaim or not cells[i].claim or not cells[j].claim then
+ local dx = cells[i].x - cells[j].x
+ local dy = cells[i].y - cells[j].y
+ local d = (dx * dx + dy * dy) ^ 0.5
+ if d < worst then
+ worst, worstPair = d, cells[i].what .. " / " .. cells[j].what
+ end
+ end
+ end
+ end
+ local ok = worst >= floorGap * 0.99
+ if not ok then
+ fails = fails + 1
+ print((" %s: closest pair %.1f (floor %.1f) -- %s")
+ :format(label, worst, floorGap, worstPair))
+ end
+ end
+
+ Crowding("grid, adjacent nests", function()
+ Base(6, 1, 8)()
+ Palette(1).slots[2] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 8 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout, p.gridAutoColumns = "GRID", true
+ end)
+ Crowding("grid, three nests in a row", function()
+ Base(9, 1, 6)()
+ Palette(1).slots[2] = { kind = "palette", palette = 3 }
+ Palette(1).slots[3] = { kind = "palette", palette = 4 }
+ for _, i in ipairs({ 3, 4 }) do
+ local c = Palette(i)
+ c.slots = {}
+ for j = 1, 6 do c.slots[j] = { kind = "spell", id = 400 + j } end
+ end
+ p.paletteCount = 4
+ p.layout, p.gridAutoColumns = "GRID", true
+ end)
+ Crowding("pointer fan, two nests", function()
+ Base(6, 2, 6)()
+ Palette(1).slots[5] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 6 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout, p.fanInput = "FAN", "CURSOR"
+ end)
+ Crowding("grid single row, two nests", function()
+ Base(6, 3, 6)()
+ Palette(1).slots[4] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 6 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout, p.gridAutoColumns, p.gridColumns = "GRID", false, 6
+ end)
+ p.gridColumns = nil
+ Crowding("arc, two nests", function()
+ Base(4, 2, 6)()
+ Palette(1).slots[3] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 6 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout = "ARC"
+ end)
+
+ print(("no two cells overlap %5d wrong%s"):format(
+ fails, fails > 0 and " <-- FAIL" or ""))
+ bad = bad + fails
+ p.layout, p.fanInput = "ARC", "SCROLL"
+end
+
+----------------------------------------------------------------------------
+-- Lane placement. The sweeps above prove the two sides agree about where a
+-- lane's cells are; they cannot see whether that is where the cells BELONG,
+-- both of them reading the one table. These check the three reads the style
+-- exists for -- a run centred on the point of the perimeter nearest the entry
+-- that opens it, hugging the block, extending ALONG the lane rather than
+-- stacking outward while there is lane still to be had -- and the regions a
+-- run that wrapped a corner comes out with.
+----------------------------------------------------------------------------
+do
+ local fails = 0
+ local function Bad(label, what)
+ fails = fails + 1
+ print((" %s: %s"):format(label, what))
+ end
+
+ -- Everything a lane check measures against, laid out from the same profile
+ -- the live palette reads.
+ local function Lane(setup)
+ setup()
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, "PERIMETER"
+ ns.Refresh()
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ local cols, rows = view:GridDims()
+ local pitch = view:Pitch()
+ local centres = {}
+ for i = 1, view:ShownCount() do
+ local x, y = view:GridBase(i, cols, rows, pitch)
+ centres[i] = { x = x, y = y }
+ end
+ return view, view:NestMetrics(view:ShownCount()), centres
+ end
+
+ -- How far outside the block's own outer edge a cell's box begins, measured
+ -- ACROSS the run it sits on -- along the run the same subtraction says only
+ -- how far round the block the cell has travelled. A cell in the FIRST row of
+ -- the lane reaches back to that edge exactly -- one round a corner stops a
+ -- few units short of it, the turn cutting in -- while a second row sits a
+ -- whole child pitch further out. That is what tells the two apart here
+ -- without keeping a second copy of the placement maths.
+ local function Standoff(b, axis, m)
+ if axis == "X" then
+ return (math.abs(b.y) - b.hh) - (m.halfY + m.icon * 0.5)
+ end
+ return (math.abs(b.x) - b.hw) - (m.halfX + m.icon * 0.5)
+ end
+
+ -- No nested cell's box may hold one of the palette's OWN cell centres: a
+ -- lane hugs the block, and a box that reached over an entry's centre would
+ -- leave that entry unselectable for as long as the nest was up. Reported as
+ -- the tightest margin over every case below rather than as a bare pass, the
+ -- number being the whole question.
+ local tightest, tightWhere = math.huge, "nothing"
+ local function ClearOfCentres(label, view, centres)
+ for _, c in ipairs(view.claims) do
+ for j = 1, c.n do
+ local b = c.cells[j]
+ for i, q in ipairs(centres) do
+ local margin = math.max(math.abs(q.x - b.x) - b.hw,
+ math.abs(q.y - b.y) - b.hh)
+ if margin < tightest then
+ tightest, tightWhere =
+ margin, ("%s, cell %d of %d over entry %d")
+ :format(label, j, c.parent, i)
+ end
+ if margin <= 0 then
+ Bad(label, ("cell %d of claim on %d covers entry %d's centre")
+ :format(j, c.parent, i))
+ end
+ end
+ end
+ end
+ end
+
+ local function SingleRow(label, view, m)
+ for _, c in ipairs(view.claims) do
+ for _, g in ipairs(c.groups) do
+ for j, b in ipairs(g.cells) do
+ local out = Standoff(b, g.axis, m)
+ if out > (m.childIcon + m.gap) * 0.5 then
+ Bad(label, ("cell %d on the %s%+d side of the claim on %d sits in a second row (%.1f out)")
+ :format(j, g.axis, g.sign, c.parent, out))
+ end
+ end
+ end
+ end
+ end
+
+ -- Snug: a first-row child ICON stands one gap outside the block's own outer
+ -- edge, which is what makes the lane read as a halo on the grid rather than
+ -- a second block floating off it. Nest Distance is honoured on top of that,
+ -- so what to expect is the gap plus whatever extra was asked for. Read at
+ -- the cells FURTHEST out -- the ones on the straight stretches -- because a
+ -- cell round a corner comes nearer than that, the turn cutting in; what
+ -- those have to answer for is only that they never come inside the block.
+ local function Snug(label, view, m)
+ for _, c in ipairs(view.claims) do
+ local lo, hi = math.huge, -math.huge
+ for _, g in ipairs(c.groups) do
+ for _, b in ipairs(g.cells) do
+ if Standoff(b, g.axis, m) <= (m.childIcon + m.gap) * 0.5 then
+ local across = (g.axis == "X") and b.y or b.x
+ local half = (g.axis == "X") and m.halfY or m.halfX
+ local d = (math.abs(across) - m.childIcon * 0.5)
+ - (half + m.icon * 0.5)
+ lo, hi = math.min(lo, d), math.max(hi, d)
+ end
+ end
+ end
+ local want = m.gap + m.bandExtra
+ if math.abs(hi - want) > 0.01 or lo < 0 then
+ Bad(label, ("claim on %d stands %.1f..%.1f off the block, want %.1f at the furthest and nothing inside")
+ :format(c.parent, lo, hi, want))
+ end
+ end
+ end
+
+ local function MeanX(c)
+ local sum = 0
+ for j = 1, c.n do sum = sum + c.cells[j].x end
+ return sum / c.n
+ end
+
+ -- 1. A parent in the middle of the block is the same distance from all four
+ -- edges, so there is no lean to read and nestSide answers: the middle of the
+ -- POSITIVE edge, which is the top.
+ do
+ local label = "centre parent"
+ local view, m, centres = Lane(Base(9, 5, 4))
+ local c = view.claims[1]
+ if c.axis ~= "X" or c.sign ~= 1 or #c.groups ~= 1 then
+ Bad(label, ("run on %s%+d in %d pieces, want one on X+1")
+ :format(tostring(c.axis), c.sign, #c.groups))
+ end
+ if math.abs(MeanX(c)) > 0.01 then
+ Bad(label, ("run centred on x=%.1f, want the middle of the edge")
+ :format(MeanX(c)))
+ end
+ SingleRow(label, view, m)
+ Snug(label, view, m)
+ ClearOfCentres(label, view, centres)
+ end
+
+ -- 2. A parent on an edge, and NOT on the middle of it: the run centres just
+ -- outside that parent's own cell rather than on the edge it lies on. Slot 10
+ -- of a 4x3 grid is on the bottom edge, one half pitch left of centre.
+ do
+ local label = "edge parent"
+ local view, m, centres = Lane(Base(12, 10, 3))
+ local c = view.claims[1]
+ if c.axis ~= "X" or c.sign ~= -1 or #c.groups ~= 1 then
+ Bad(label, ("run on %s%+d in %d pieces, want one on X-1")
+ :format(tostring(c.axis), c.sign, #c.groups))
+ end
+ if math.abs(MeanX(c) - c.parentBox.x) > 0.01 then
+ Bad(label, ("run centred on x=%.1f, parent cell at x=%.1f")
+ :format(MeanX(c), c.parentBox.x))
+ end
+ SingleRow(label, view, m)
+ Snug(label, view, m)
+ ClearOfCentres(label, view, centres)
+ end
+
+ -- 3. A CORNER parent is exactly as far from both of the edges that meet at
+ -- its corner, so the run centres on the corner itself and wraps its L around
+ -- it: half the children down each of the two sides, and one box per side --
+ -- never one box across the pair, which would swallow the block's own corner
+ -- ground and bring the dim-never-backs-out complaint straight back.
+ do
+ local label = "corner parent"
+ local view, m, centres = Lane(Base(9, 1, 6))
+ local cols = view:GridDims()
+ local c = view.claims[1]
+ local sides = {}
+ for _, g in ipairs(c.groups) do sides[g.axis .. g.sign] = #g.cells end
+ -- Slot 1 of a 3x3 is the top-left cell: the top edge and the left one.
+ if #c.groups ~= 2 or sides["X1"] ~= 3 or sides["Y-1"] ~= 3 then
+ local shape = ""
+ for _, g in ipairs(c.groups) do
+ shape = shape .. (" %s%+d x%d"):format(g.axis, g.sign, #g.cells)
+ end
+ Bad(label, "run came out as" .. shape .. ", want three on X+1 and three on Y-1")
+ end
+ if #c.regions > ns.REGION_MAX then
+ Bad(label, ("%d regions, only %d gates to put them in")
+ :format(#c.regions, ns.REGION_MAX))
+ end
+ -- Every region past the parent's own cell is a side of the run with the
+ -- parent's own cell folded in (see RunReach), so it sweeps the ground
+ -- between the two: a parent on the block's edge sweeps along its own row
+ -- or its own column, and nothing else. Whatever entries stand in that
+ -- sweep stay selectable, they simply do not back the nest out -- but an
+ -- entry OFF it, the block's whole diagonal interior included, must be
+ -- outside every region, which is exactly what one box across the L
+ -- would not leave it.
+ local pr, pc = math.floor((c.parent - 1) / cols), (c.parent - 1) % cols
+ for r = 2, #c.regions do
+ local b = c.regions[r]
+ for i, q in ipairs(centres) do
+ local ir, ic = math.floor((i - 1) / cols), (i - 1) % cols
+ if InBox(b, q.x, q.y) and ir ~= pr and ic ~= pc then
+ Bad(label, ("region %d covers entry %d's centre, off the parent's own row and column")
+ :format(r, i))
+ end
+ end
+ end
+ SingleRow(label, view, m)
+ Snug(label, view, m)
+ ClearOfCentres(label, view, centres)
+ end
+
+ -- 4. Two nests on the SAME lane, and neighbours at that: each still gets the
+ -- whole run it asked for, laid along the lane in one row. The lane used to be
+ -- divided up between the claims on it -- out to the midpoint with the nearest
+ -- other one -- which left two claims in adjacent cells with three quarters of
+ -- a pitch each: room for a single cell per row, so eight children came out as
+ -- eight rows stacked outward from the block. Nothing needed the division:
+ -- only one nest is ever drawn and only one is ever armed, so two runs may lie
+ -- over each other on the lane without either becoming ambiguous.
+ --
+ -- Full runs are the whole point of the check, so it asks for MAX_CHILDREN of
+ -- them on both claims, and asks each claim for as many cells along the lane
+ -- as it has children -- which is the number the old rule could not deliver.
+ for _, case in ipairs({
+ { label = "two nests side by side on the lane", at = { 7, 8 } },
+ { label = "two nests, a corner and its neighbour", at = { 1, 2 } },
+ { label = "two nests facing each other", at = { 2, 8 } },
+ }) do
+ local label = case.label
+ local view, m, centres = Lane(function()
+ Base(9, case.at[1], ns.MAX_CHILDREN)()
+ Palette(1).slots[case.at[2]] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, ns.MAX_CHILDREN do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ end)
+ SingleRow(label, view, m)
+ Snug(label, view, m)
+ ClearOfCentres(label, view, centres)
+ for _, c in ipairs(view.claims) do
+ -- How far the run REACHES along the lane, side by side: a run that
+ -- stacked outward into rows holds the same cells at a handful of
+ -- along positions, so its reach collapses to a couple of pitches
+ -- however many children it has. Measured a side at a time, since a
+ -- run wrapped round a corner travels on two axes. The allowance is
+ -- for the corners themselves: a turn advances the along coordinate
+ -- less than it advances the perimeter the cells were spaced on.
+ local along = 0
+ for _, g in ipairs(c.groups) do
+ local lo, hi = math.huge, -math.huge
+ for _, b in ipairs(g.cells) do
+ local v = (g.axis == "X") and b.x or b.y
+ local h = (g.axis == "X") and b.hw or b.hh
+ lo, hi = math.min(lo, v - h), math.max(hi, v + h)
+ end
+ along = along + (hi - lo)
+ end
+ if along < c.n * m.childPitch * 0.85 then
+ Bad(label, ("the claim on %d runs %.1f along the lane, %d children want %.1f")
+ :format(c.parent, along, c.n, c.n * m.childPitch))
+ end
+ if #c.regions > ns.REGION_MAX then
+ Bad(label, ("the claim on %d wants %d regions, only %d gates to put them in")
+ :format(c.parent, #c.regions, ns.REGION_MAX))
+ end
+ end
+ end
+
+ -- 5. Nest Distance below the value the profile ships with buys a lane
+ -- nothing: it already hugs the block, and the slider only ever adds. So
+ -- moving it there must change NOTHING -- not the cells, and not the arming
+ -- slack around them either, which is invisible and would otherwise be the
+ -- one thing the bottom quarter of that slider still moved. Above it, the
+ -- geometry has to answer.
+ do
+ local label = "nest distance below the default"
+ local function Shape(band)
+ local view = Lane(function()
+ Base(9, 1, 6)()
+ p.nestBand = band
+ end)
+ local c = view.claims[1]
+ local out = {}
+ for _, set in ipairs({ c.cells, c.regions }) do
+ for _, b in ipairs(set) do
+ out[#out + 1] = ("%.3f,%.3f,%.3f,%.3f"):format(b.x, b.y, b.hw, b.hh)
+ end
+ end
+ return table.concat(out, " ")
+ end
+ local at0, at20, at40, at120 = Shape(0), Shape(20), Shape(40), Shape(120)
+ p.nestBand = nil
+ if at0 ~= at40 or at20 ~= at40 then
+ Bad(label, "0, 20 and 40 do not all lay the same claim out")
+ end
+ if at120 == at40 then
+ Bad(label, "120 lays out exactly as 40 does, so the slider does nothing at all")
+ end
+ end
+
+ print(("lane placement %5d wrong%s"):format(
+ fails, fails > 0 and " <-- FAIL" or ""))
+ print((" nearest a lane cell comes to an entry's centre: %.1f (%s)")
+ :format(tightest, tightWhere))
+ bad = bad + fails
+ p.layout, p.gridNestStyle = "ARC", "PERIMETER"
+end
+
+----------------------------------------------------------------------------
+-- Icon overlap. The crowding check further up measures CENTRES against a
+-- floor, which is the right question for two round-ish things but not for
+-- what actually gets drawn: a child is a SQUARE of c.icon on a side, and two
+-- squares clear each other only when one axis alone separates them by a whole
+-- icon. Around a lane's rounded corner the two readings part company -- the
+-- straight-line distance holds up while the axis-by-axis one collapses, so a
+-- pair that passes the crowding floor still overlaps on screen by a quarter
+-- of an icon.
+--
+-- Swept over block shapes, over EVERY parent position in each of them, over
+-- child counts and over nest scales, since which pairs land either side of a
+-- corner depends on all four. Only the block styles: an arc nest is polar and
+-- its children are not rows of squares at all, which is the crowding check's
+-- business rather than this one's.
+----------------------------------------------------------------------------
+do
+ local fails = 0
+ local worst, worstWhere = math.huge, "nothing"
+
+ local function Icons(label, view)
+ for _, c in ipairs(view.claims or {}) do
+ for a = 1, (c.cells and c.n or 0) - 1 do
+ for b = a + 1, c.n do
+ local pa, pb = c.cells[a], c.cells[b]
+ local sep = math.max(math.abs(pa.x - pb.x),
+ math.abs(pa.y - pb.y))
+ local margin = sep - c.icon
+ if margin < worst then
+ worst, worstWhere = margin,
+ ("%s, children %d and %d of the claim on %d")
+ :format(label, a, b, c.parent)
+ end
+ -- Exact touching is allowed; the tolerance is for the
+ -- float noise a turn's sin/cos leaves behind.
+ if margin < -0.01 then
+ fails = fails + 1
+ print((" %s: children %d and %d of the claim on %d overlap by %.1f of a %.1f icon")
+ :format(label, a, b, c.parent, -margin, c.icon))
+ end
+ end
+ end
+ end
+ end
+
+ for _, style in ipairs({ "PERIMETER", "HALO" }) do
+ for _, shape in ipairs({ { n = 9 }, { n = 12 }, { n = 6 },
+ { n = 5, cols = 5 }, { n = 4, cols = 1 } }) do
+ -- MAX_SLOTS children only reach a lane whole -- a halo's cap trims
+ -- them back to MAX_CHILDREN (see NestChildCap), so that pass runs
+ -- the halo at its own full house rather than not at all.
+ for _, nChild in ipairs({ 2, 5, ns.MAX_CHILDREN, ns.MAX_SLOTS }) do
+ for _, scale in ipairs({ 0.4, 0.6, 0.8, 1.0 }) do
+ for at = 1, shape.n do
+ Base(shape.n, at, nChild)()
+ p.layout, p.gridNestStyle = "GRID", style
+ p.gridAutoColumns = (shape.cols == nil)
+ p.gridColumns = shape.cols
+ p.nestScale = scale
+ ns.Refresh()
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ Icons(("%s %d slots%s, nest of %d on %d at %.1f"):format(
+ style, shape.n,
+ shape.cols and (" in " .. shape.cols .. " columns") or "",
+ nChild, at, scale), view)
+ end
+ end
+ end
+ end
+ end
+ p.gridColumns, p.nestScale = nil, nil
+
+ print(("no two icons of one nest overlap %5d wrong%s"):format(
+ fails, fails > 0 and " <-- FAIL" or ""))
+ print((" tightest pair, icon widths clear: %.1f (%s)")
+ :format(worst, worstWhere))
+ bad = bad + fails
+ p.layout, p.gridNestStyle = "ARC", "PERIMETER"
+end
+
+----------------------------------------------------------------------------
+-- Armed sweeps: the sweeps above prove UNARMED never answers with a nested
+-- cell. This proves the other half -- that ARMED does, and that it is
+-- specifically the claim the cursor walked through that answers, at the
+-- SAME screen point a moment ago proved unreachable cold. Two configs, both
+-- chosen to overlap ground: two arc nests sharing an overflowed sector, and
+-- two grid halos sitting next to each other -- the exact shape reported
+-- in-game as "the drawn nest swaps back and forth".
+----------------------------------------------------------------------------
+do
+ local fails = 0
+
+ local function CheckClaim(label, view, btn, isBlock)
+ local c = view.claims[1]
+ local dx, dy
+ if c.cells then
+ dx, dy = c.cells[1].x, c.cells[1].y
+ else
+ local r, a = view:ChildRingPos(c, 1)
+ dx, dy = r * math.sin(a), r * math.cos(a)
+ end
+ local wantIdx = c.base + 1
+
+ local function Selection(armed)
+ btn:SetAttribute("eapArmed", armed)
+ CURSOR.x, CURSOR.y = 960 + dx, 540 + dy
+ local want
+ if isBlock then
+ view:AdvanceGrid()
+ want = view:GetSelection()
+ else
+ want = view:HitTest()
+ end
+ snippet(btn, "LeftButton", false)
+ local why = btn:GetAttribute("eapWhy")
+ local got = (why ~= "deadzone" and why ~= "outofreach"
+ and why ~= "noidx" and why ~= "unmoved")
+ and btn:GetAttribute("eapIdx") or nil
+ return want, got
+ end
+
+ -- Walked through claim 1's own parent: both sides land on its first
+ -- child.
+ local armed = ArmedViaParent(view, 1, dx, dy)
+ local want, got = Selection(armed)
+ if armed ~= 1 or want ~= wantIdx or got ~= wantIdx then
+ fails = fails + 1
+ print((" %s (armed): armed=%s want=%s got=%s wantIdx=%s"):format(
+ label, tostring(armed), tostring(want), tostring(got), tostring(wantIdx)))
+ end
+
+ -- The IDENTICAL screen point, never armed: neither side may answer
+ -- with that child -- proof that arming, not proximity, is what
+ -- decided the case just above.
+ want, got = Selection(nil)
+ if want == wantIdx or got == wantIdx then
+ fails = fails + 1
+ print((" %s (unarmed): want=%s got=%s (must not be %s)"):format(
+ label, tostring(want), tostring(got), tostring(wantIdx)))
+ end
+ end
+
+ local function Run(label, setup, isBlock)
+ setup()
+ ns.Refresh()
+ local btn = byName["EUIActionPaletteButton1"]
+ btn:SetFrameRef("ui", UIParent)
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ view._steered = true
+ view._gateX, view._gateY = 20, 20
+ CURSOR.x, CURSOR.y = 20, 20
+ snippet(btn, "LeftButton", true)
+ CheckClaim(label, view, btn, isBlock)
+ end
+
+ -- ARC: two adjacent nests with room borrowed from the plain entries
+ -- between them, the same overflow shape used above to sweep for
+ -- structural disagreement -- now used to prove one claim's arming does
+ -- not bleed into the other's.
+ Run("arc, two nests adjacent", function()
+ Base(4, 2, 6)()
+ Palette(1).slots[3] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 6 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout, p.arcSpan, p.arcChildOverflow = "ARC", 360, "MIDPOINT"
+ end, false)
+
+ -- GRID HALO: two adjacent halos -- the in-game report this whole feature
+ -- answers, where two parents' rings occupied the same ground.
+ Run("grid halo, two adjacent", function()
+ Base(9, 4, 8)()
+ Palette(1).slots[5] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 8 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, "HALO"
+ end, true)
+
+ print(("armed pass-through actually reaches through %5d wrong%s"):format(
+ fails, fails > 0 and " <-- FAIL" or ""))
+ bad = bad + fails
+ p.layout, p.gridNestStyle = "ARC", "PERIMETER"
+end
+
+----------------------------------------------------------------------------
+-- Exclusive arming and true-shape regions: the two live complaints these
+-- fixes answer. Each check drives StepArmed through a short path of real
+-- cursor samples -- exactly what a hand actually crossing this ground would
+-- produce -- and, at the point that matters, cross-checks the live view and
+-- the real snippet against each other too, so a bug that only shows up in
+-- the SELECTION (rather than in eapArmed) cannot hide behind an armed-state
+-- assertion that happens to pass.
+----------------------------------------------------------------------------
+do
+ local fails = 0
+
+ local function Prep(setup)
+ setup()
+ ns.Refresh()
+ local btn = byName["EUIActionPaletteButton1"]
+ btn:SetFrameRef("ui", UIParent)
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ view._steered = true
+ view._gateX, view._gateY = 20, 20
+ CURSOR.x, CURSOR.y = 20, 20
+ snippet(btn, "LeftButton", true)
+ return view, btn
+ end
+
+ -- Both sides told the SAME armed claim and the SAME cursor point: do they
+ -- pick the same entry? isBlock chooses AdvanceGrid (block layouts) over
+ -- HitTest (ARC).
+ local function CheckSelection(label, view, btn, isBlock, armed, dx, dy, wantIdx)
+ btn:SetAttribute("eapArmed", armed)
+ CURSOR.x, CURSOR.y = 960 + dx, 540 + dy
+ local want
+ if isBlock then
+ view:AdvanceGrid()
+ want = view:GetSelection()
+ else
+ want = view:HitTest()
+ end
+ snippet(btn, "LeftButton", false)
+ local why = btn:GetAttribute("eapWhy")
+ local got = (why ~= "deadzone" and why ~= "outofreach"
+ and why ~= "noidx" and why ~= "unmoved")
+ and btn:GetAttribute("eapIdx") or nil
+ if want ~= wantIdx or got ~= wantIdx then
+ fails = fails + 1
+ print((" %s: want=%s got=%s wantIdx=%s"):format(
+ label, tostring(want), tostring(got), tostring(wantIdx)))
+ end
+ end
+
+ -- 1. Exclusive arming: a HALO 3x3 with two ADJACENT sub-palettes, slots 4
+ -- and 5, exactly the shape reported in-game as "the drawn nest swaps back
+ -- and forth". Claim 1's ring reaches past the midpoint into claim 2's own
+ -- cell -- HaloNest only promises the NEIGHBOUR's CENTRE stays clear of
+ -- it, not its whole cell -- so brushing that overlap used to re-arm
+ -- claim 2 outright. It must not any more: claim 2's parent gate is
+ -- hidden the whole time claim 1 is armed.
+ do
+ local view, btn = Prep(function()
+ Base(9, 4, 8)()
+ Palette(1).slots[5] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 8 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, "HALO"
+ end)
+ local c1, c2 = view.claims[1], view.claims[2]
+
+ -- The one ring cell of claim 1 nearest claim 2's own parent box --
+ -- found rather than assumed, since HALO_DIRS' screen orientation is
+ -- an implementation detail this test has no business knowing.
+ local overlap, overlapJ, bestD
+ for j = 1, c1.n do
+ local cell = c1.cells[j]
+ local d = (cell.x - c2.parentBox.x) ^ 2 + (cell.y - c2.parentBox.y) ^ 2
+ if not bestD or d < bestD then overlap, overlapJ, bestD = cell, j, d end
+ end
+
+ local armed = StepArmed(view, nil, 1e6, 1e6)
+ armed = StepArmed(view, armed, c1.parentBox.x, c1.parentBox.y)
+ if armed ~= 1 then
+ fails = fails + 1
+ print(" halo exclusive arming: walking onto claim 1's parent did not arm it")
+ end
+
+ -- Brush the overlap. Still armed 1 -- claim 2's parent gate is dark,
+ -- so it cannot steal focus no matter how close the cursor sits to it.
+ local stillA = StepArmed(view, armed, overlap.x, overlap.y)
+ if stillA ~= 1 then
+ fails = fails + 1
+ print((" halo exclusive arming: brushing claim 2's ground while armed 1 gave %s, want 1")
+ :format(tostring(stillA)))
+ end
+ CheckSelection("halo exclusive arming (still on claim 1's ring)", view, btn, true,
+ stillA, overlap.x, overlap.y, c1.base + overlapJ)
+
+ -- Now go all the way onto claim 2's own parent cell -- past claim 1's
+ -- true region (its own single bounding box stops short of a
+ -- neighbour's CENTRE by construction). This is one continuous
+ -- cursor move, so it disarms 1 and arms 2 in the one step, same as a
+ -- real mouse motion landing there would.
+ local b = StepArmed(view, stillA, c2.parentBox.x, c2.parentBox.y)
+ if b ~= 2 then
+ fails = fails + 1
+ print((" halo exclusive arming: reaching claim 2's own parent gave %s, want 2")
+ :format(tostring(b)))
+ end
+ CheckSelection("halo exclusive arming (on claim 2's own parent)", view, btn, true,
+ b, c2.parentBox.x, c2.parentBox.y, c2.parent)
+ end
+
+ -- 2. True-shape regions, HALO: arm the one nest in a 3x3, then move onto
+ -- a PLAIN neighbouring entry a full pitch away -- outside even the old
+ -- single bounding box, which only ever reached a little past the
+ -- half-pitch mark. Disarmed, and the plain entry is what is selected.
+ do
+ local view, btn = Prep(function()
+ Base(9, 4, 8)()
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, "HALO"
+ end)
+ local c1 = view.claims[1]
+ local cols, rows = view:GridDims()
+ local pitch = view:Pitch()
+ -- Slot 1 sits directly above slot 4 in a 3-wide grid -- a full pitch
+ -- away, axis-aligned, and no part of any nest.
+ local nx, ny = view:GridBase(1, cols, rows, pitch)
+
+ local armed = StepArmed(view, nil, 1e6, 1e6)
+ armed = StepArmed(view, armed, c1.parentBox.x, c1.parentBox.y)
+ local disarmed = StepArmed(view, armed, nx, ny)
+ if armed ~= 1 or disarmed ~= nil then
+ fails = fails + 1
+ print((" halo plain neighbour: armed=%s then %s, want 1 then nil")
+ :format(tostring(armed), tostring(disarmed)))
+ end
+ CheckSelection("halo plain neighbour (disarmed, on slot 1)", view, btn, true,
+ disarmed, nx, ny, 1)
+ end
+
+ -- 3. True-shape regions: a corner parent, so its nest breaks out through
+ -- the block's own edge with no interior cell in the way -- the clean case
+ -- for telling "on the claim's ground" apart from "elsewhere in the
+ -- block". Wandering onto a plain entry on the far side of the block
+ -- disarms; wandering out across the gap to a child keeps the nest live
+ -- the whole way. POPOUT is the retired style value a stored profile may
+ -- still carry -- it reads as PERIMETER now, and this holds its arming to
+ -- the same answers as the lane asked for by name.
+ for _, style in ipairs({ "POPOUT", "PERIMETER" }) do
+ local view, btn = Prep(function()
+ Base(9, 1, 6)()
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, style
+ end)
+ local c1 = view.claims[1]
+ local cols, rows = view:GridDims()
+ local pitch = view:Pitch()
+ -- Slot 9, the opposite corner: clear of both the parent (slot 1) and
+ -- whichever edge this style broke the nest out through.
+ local farX, farY = view:GridBase(9, cols, rows, pitch)
+
+ local armed = StepArmed(view, nil, 1e6, 1e6)
+ armed = StepArmed(view, armed, c1.parentBox.x, c1.parentBox.y)
+ local offCorridor = StepArmed(view, armed, farX, farY)
+ if armed ~= 1 or offCorridor ~= nil then
+ fails = fails + 1
+ print((" %s off corridor: armed=%s then %s, want 1 then nil")
+ :format(style, tostring(armed), tostring(offCorridor)))
+ end
+ CheckSelection(style .. " off corridor (disarmed, on slot 9)", view, btn, true,
+ offCorridor, farX, farY, 9)
+
+ -- Back through the parent, out across the ground between it and the
+ -- nest, and on to the nest's first child -- three samples, never
+ -- leaving claim 1's true ground. The midpoint rather than a named
+ -- region: the lane folds the way back to the parent into the run's
+ -- own rect (see RunReach), and the reach is what has to answer.
+ local a2 = StepArmed(view, armed,
+ (c1.parentBox.x + c1.cells[1].x) * 0.5,
+ (c1.parentBox.y + c1.cells[1].y) * 0.5)
+ local a3 = StepArmed(view, a2, c1.cells[1].x, c1.cells[1].y)
+ if a2 ~= 1 or a3 ~= 1 then
+ fails = fails + 1
+ print((" %s along corridor: got %s then %s, want 1 throughout")
+ :format(style, tostring(a2), tostring(a3)))
+ end
+ CheckSelection(style .. " along corridor (child selected)", view, btn, true,
+ a3, c1.cells[1].x, c1.cells[1].y, c1.base + 1)
+ end
+
+ print(("exclusive arming / true-shape regions %5d wrong%s"):format(
+ fails, fails > 0 and " <-- FAIL" or ""))
+ bad = bad + fails
+ p.layout, p.gridNestStyle = "ARC", "PERIMETER"
+end
+
+----------------------------------------------------------------------------
+-- Cycle guard
+----------------------------------------------------------------------------
+do
+ local fails = 0
+ local function Check(what, got, want)
+ if got ~= want then
+ fails = fails + 1
+ print((" %s: got %s want %s"):format(what, tostring(got), tostring(want)))
+ end
+ end
+
+ p.paletteCount = 4
+ for i = 1, 4 do Palette(i).slots = {} end
+ Check("a palette inside itself", ns.CanNest(1, 1), false)
+ Check("a fresh palette", ns.CanNest(1, 2), true)
+
+ -- 2 holds 3: nesting 2 inside 1 is fine, nesting 1 inside 3 closes a loop.
+ Palette(2).slots = { { kind = "palette", palette = 3 } }
+ Check("through one level", ns.CanNest(1, 2), true)
+ Palette(3).slots = { { kind = "palette", palette = 1 } }
+ -- 1 -> 2 -> 3 -> 1 would close the loop.
+ Check("two-step loop", ns.CanNest(1, 2), false)
+ -- 3 already holds 1, and 1 holds nothing, so 3 -> 1 closes nothing: a
+ -- duplicate is not a cycle, and refusing it would be wrong.
+ Check("duplicate is not a loop", ns.CanNest(3, 1), true)
+ -- The direct case: 1 holds 2, so 2 may not hold 1.
+ Palette(1).slots = { { kind = "palette", palette = 2 } }
+ Check("direct loop", ns.CanNest(2, 1), false)
+ Palette(1).slots = {}
+
+ -- Data that is ALREADY cyclic must not hang the walk.
+ Palette(4).slots = { { kind = "palette", palette = 4 } }
+ Check("walking existing cycle", ns.CanNest(1, 4), true)
+
+ print(("cycle guard %5d wrong%s"):format(
+ fails, fails > 0 and " <-- FAIL" or ""))
+ bad = bad + fails
+end
+
+----------------------------------------------------------------------------
+-- Real gates: everything above drives StepArmed, a geometric stand-in for
+-- what the file's own comments say EnsureGates/EnterSnippet/LeaveSnippet
+-- do. This section instead executes those THREE THINGS -- the real
+-- compiled snippet bodies, wrapped exactly as SecureHandlers.lua wraps
+-- them, on real frame handles with real anchored rects and real frame
+-- levels. SecureHandlerWrapScript (see near the top of this file) and the
+-- frame stub's SetFrameLevel/SetPoint/ClearAllPoints family are what make
+-- that possible; FireOnEnter/FireOnLeave there are Wrapped_OnEnter/
+-- Wrapped_OnLeave, "_wrapentered" included.
+--
+-- A cursor PATH is a list of points. MoveCursor re-evaluates topmost focus
+-- after every one of them -- exclusive, topmost SHOWN motion-enabled frame
+-- wins, pgates (level 20) over rgates (level 10) -- and fires the losing
+-- frame's OnLeave before the gaining frame's OnEnter, exactly the order the
+-- task's own focus rules describe. Nothing here consults view.claims for
+-- what eapArmed OUGHT to be; it only reads what the button says it IS.
+----------------------------------------------------------------------------
+local function GateFrames(index)
+ local list = {}
+ for k = 1, ns.MAX_SLOTS do
+ local pg = byName["EUIActionPaletteButton" .. index .. "PGate" .. k]
+ if pg then list[#list + 1] = pg end
+ for r = 1, ns.REGION_MAX do
+ local rg = byName["EUIActionPaletteButton" .. index .. "RGate" .. k .. "_" .. r]
+ if rg then list[#list + 1] = rg end
+ end
+ end
+ return list
+end
+
+local function TopmostAt(frames, x, y)
+ local best, bestLevel
+ for _, f in ipairs(frames) do
+ if f._shown and f._motion and f._positioned
+ and x >= f._px and x <= f._px + f._w
+ and y >= f._py and y <= f._py + f._h then
+ local lvl = f._level or 0
+ if not best or lvl > bestLevel then best, bestLevel = f, lvl end
+ end
+ end
+ return best
+end
+
+-- One mover per open palette: it owns "who has focus right now", which is
+-- state that belongs to the whole hold, not to any one sample.
+local function NewMover(index)
+ local frames = GateFrames(index)
+ local focus
+ return function(x, y)
+ CURSOR.x, CURSOR.y = x, y
+ local top = TopmostAt(frames, x, y)
+ if top ~= focus then
+ if focus then FireOnLeave(focus, true) end
+ focus = top
+ if focus then FireOnEnter(focus, true) end
+ end
+ return focus
+ end
+end
+
+do
+ local fails, offline = 0, {}
+
+ local function OpenAt(index, x, y)
+ local btn = byName["EUIActionPaletteButton" .. index]
+ btn:SetFrameRef("ui", UIParent)
+ -- The gate transcript several checks below read is kept only while the
+ -- button carries this flag -- "/euiap gates" is what raises it in game,
+ -- and the appends are skipped entirely without it.
+ btn:SetAttribute("eapGDebug", 1)
+ CURSOR.x, CURSOR.y = x, y
+ snippet(btn, "LeftButton", true)
+ return btn, NewMover(index)
+ end
+
+ local function ReleaseAt(btn, mover, x, y)
+ mover(x, y)
+ snippet(btn, "LeftButton", false)
+ local why = btn:GetAttribute("eapWhy")
+ local got = (why ~= "deadzone" and why ~= "outofreach"
+ and why ~= "noidx" and why ~= "unmoved")
+ and btn:GetAttribute("eapIdx") or nil
+ return got, why
+ end
+
+ -- An opening offset, diagonal from the palette's centre, that lands on
+ -- NO claim's parent cell. The checks that walk a cursor onto a claim are
+ -- about what the walk does, and a press that already stands on a claim's
+ -- entry now arms it there and then (the press branch's geometric
+ -- pre-arm) -- which would answer the question before the walk started,
+ -- and in one config here really did: a 3x3 grid whose middle column
+ -- nests puts a claim's own cell over the old fixed offset of 20,20.
+ -- Check 5 below is where the pre-arm is under test on purpose.
+ local function ClearOpen(view)
+ for off = 20, 4000, 7 do
+ local clear = true
+ for _, c in ipairs(view.claims or {}) do
+ local b = c.parentBox
+ if b and math.abs(off - b.x) <= b.hw
+ and math.abs(off - b.y) <= b.hh then
+ clear = false
+ break
+ end
+ end
+ if clear then return off end
+ end
+ error("no opening offset clear of every claim")
+ end
+
+ -- Walk a straight line from (x0,y0) to (x1,y1) in a handful of real
+ -- motion samples, each one re-evaluating focus -- a diagonal reach
+ -- crosses a narrow corridor's own side for real this way, rather than
+ -- landing past it in a single StepArmed jump.
+ local function Walk(mover, x0, y0, x1, y1, steps)
+ for i = 0, steps do
+ local t = i / steps
+ mover(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t)
+ end
+ end
+
+ -- Does a straight reach from claim k's parent to (x1,y1) leave the claim's
+ -- own coverage over ANOTHER claim's entry? Other claims' cells are taken out
+ -- of a claim's regions on purpose (see ParentHoles), so a reach across one of
+ -- those holes is a HANDOFF: the leave test answers "outside", the claim
+ -- disarms, and the claim the cursor has arrived at arms in its place. That is
+ -- the swap the whole carve exists for, so a walk across one has to be held to
+ -- a different promise than a walk that stays covered.
+ --
+ -- Asked of the claim's own rects rather than of the grid: not every
+ -- neighbouring claim's cell IS a hole -- one standing between a parent and its
+ -- own run keeps the nest reachable instead (see BlocksReach) -- and a check
+ -- that assumed otherwise would demand a handoff where the module rightly
+ -- refuses one. Sampled rather than solved, since samples are what the gates
+ -- see.
+ local function HandsOffAlong(view, k, x1, y1)
+ local c = view.claims[k]
+ local x0, y0 = c.parentBox.x, c.parentBox.y
+ for i = 0, 48 do
+ local t = i / 48
+ local x, y = x0 + (x1 - x0) * t, y0 + (y1 - y0) * t
+ if not ClaimContains(view, c, x, y) then
+ for j, o in ipairs(view.claims) do
+ if j ~= k and InBox(o.parentBox, x, y) then return j end
+ end
+ end
+ end
+ return nil
+ end
+
+ -- 1. HALO: open, onto the parent, outward onto a child, release -- want
+ -- the child. This is the harness's own claims[1], which the crash this
+ -- session found (LeaveSnippet handing SecureHandlerWrapScript a stray
+ -- second return value as postBody) never actually touches, since it
+ -- fires on claim 1's own FIRST region gate; a HALO nest further down a
+ -- palette's slot order is where that crash would have mattered, so this
+ -- path alone under-tests it -- see check 4 below for that shape instead.
+ do
+ local view, btn = nil, nil
+ Base(9, 4, 8)()
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, "HALO"
+ ns.Refresh()
+ view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ local off = ClearOpen(view)
+ view._steered, view._gateX, view._gateY = true, off + 960, off + 540
+ local c1 = view.claims[1]
+ local mover
+ btn, mover = OpenAt(1, off + 960, off + 540)
+ mover(c1.parentBox.x + 960, c1.parentBox.y + 540)
+ local armedAfterParent = tonumber(btn:GetAttribute("eapArmed"))
+ Walk(mover, c1.parentBox.x + 960, c1.parentBox.y + 540,
+ c1.cells[1].x + 960, c1.cells[1].y + 540, 6)
+ local got, why = ReleaseAt(btn, mover, c1.cells[1].x + 960, c1.cells[1].y + 540)
+ local want = c1.base + 1
+ if armedAfterParent ~= 1 or got ~= want then
+ fails = fails + 1
+ offline[#offline + 1] = (" HALO onto a child: armed-after-parent=%s got=%s want=%s why=%s")
+ :format(tostring(armedAfterParent), tostring(got), tostring(want), tostring(why))
+ end
+ end
+
+ -- 2. Diagonal reach onto a child from a MIDDLE parent: open, onto the
+ -- parent, then a straight diagonal line toward the nest's own centre
+ -- rather than along either axis -- the reach RunReach's convex rect
+ -- exists for. Wants the nest still live at the far end. POPOUT is the
+ -- retired style value, held to the same answers as the lane.
+ for _, style in ipairs({ "POPOUT", "PERIMETER" }) do
+ Base(9, 5, 8)()
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, style
+ ns.Refresh()
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ local off = ClearOpen(view)
+ view._steered, view._gateX, view._gateY = true, off + 960, off + 540
+ local c1 = view.claims[1]
+ local btn, mover = OpenAt(1, off + 960, off + 540)
+ mover(c1.parentBox.x + 960, c1.parentBox.y + 540)
+ local armedAfterParent = tonumber(btn:GetAttribute("eapArmed"))
+ -- Straight line, parent to child 1's own centre -- diagonal unless
+ -- they happen to share an axis, which PerimeterNest does not promise
+ -- and this does not assume.
+ Walk(mover, c1.parentBox.x + 960, c1.parentBox.y + 540,
+ c1.cells[1].x + 960, c1.cells[1].y + 540, 10)
+ local got, why = ReleaseAt(btn, mover, c1.cells[1].x + 960, c1.cells[1].y + 540)
+ local want = c1.base + 1
+ if armedAfterParent ~= 1 or got ~= want then
+ fails = fails + 1
+ offline[#offline + 1] = (" %s diagonal reach: armed-after-parent=%s got=%s want=%s why=%s")
+ :format(style, tostring(armedAfterParent), tostring(got), tostring(want), tostring(why))
+ end
+ end
+
+ -- 3. Dim clears the same frame it should: arm, then wander onto a PLAIN
+ -- neighbour a full pitch away -- clear of every claim's ground -- and
+ -- read the live drawing's own idea of what is open, from the SAME
+ -- AdvanceGrid call a real frame update would make. No release: this is
+ -- about what is drawn while the button is still held.
+ do
+ Base(9, 4, 8)()
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, "HALO"
+ ns.Refresh()
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ view._steered = true
+ local c1 = view.claims[1]
+ local cols, rows = view:GridDims()
+ local pitch = view:Pitch()
+ local nx, ny = view:GridBase(1, cols, rows, pitch) -- slot 1: clear of claim 1's own ring
+ local off = ClearOpen(view)
+ local btn, mover = OpenAt(1, off + 960, off + 540)
+ view._gateX, view._gateY = off + 960, off + 540
+ mover(c1.parentBox.x + 960, c1.parentBox.y + 540)
+ local armedAtParent = tonumber(btn:GetAttribute("eapArmed"))
+ view:AdvanceGrid()
+ local openAtParent = view._openClaim
+ Walk(mover, c1.parentBox.x + 960, c1.parentBox.y + 540, nx + 960, ny + 540, 8)
+ view:AdvanceGrid()
+ local armedAfter = tonumber(btn:GetAttribute("eapArmed"))
+ local openAfter = view._openClaim
+ if armedAtParent ~= 1 or openAtParent ~= c1 or armedAfter ~= nil or openAfter ~= nil then
+ fails = fails + 1
+ offline[#offline + 1] = (" dim clears: armed %s->%s open %s->%s (want 1->nil, claim->nil)")
+ :format(tostring(armedAtParent), tostring(armedAfter),
+ tostring(openAtParent and "claim") or "nil", tostring(openAfter and "claim") or "nil")
+ end
+ snippet(btn, "LeftButton", false)
+ end
+
+ -- 4. Every claim, not only the first: a palette with THREE separate
+ -- nested slots. EnsureGates builds every claim's gates in one pass the
+ -- first time this palette is ever pushed -- a Lua error partway through
+ -- that pass (the crash this session found) would abort it, leaving every
+ -- claim after the one it died on with no pgate at all, however far from
+ -- angle zero or however many nests came before it. This is the direct
+ -- test for that: arm and select from claim 2 and claim 3 in the SAME
+ -- palette that already proved claim 1 fine above.
+ do
+ Base(9, 2, 5)()
+ Palette(1).slots[5] = { kind = "palette", palette = 3 }
+ Palette(1).slots[8] = { kind = "palette", palette = 4 }
+ local c3, c4 = Palette(3), Palette(4)
+ c3.slots, c4.slots = {}, {}
+ for i = 1, 4 do c3.slots[i] = { kind = "spell", id = 500 + i } end
+ for i = 1, 4 do c4.slots[i] = { kind = "spell", id = 600 + i } end
+ p.paletteCount = 4
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, "PERIMETER"
+ ns.Refresh()
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ view._steered = true
+ local off = ClearOpen(view)
+ for _, claimIdx in ipairs({ 2, 3 }) do
+ local c = view.claims[claimIdx]
+ -- A child this claim can be reached WITHOUT crossing another
+ -- claim's entry: three claims on one block leave some of each
+ -- run's children behind a neighbour's cell, and reaching across
+ -- one of those hands the claim over by design. What is under test
+ -- here is that claim 2 and claim 3 have working gates at all, so
+ -- it asks the question where the answer is not a handoff.
+ local target
+ for j = 1, c.n do
+ if not HandsOffAlong(view, claimIdx,
+ c.cells[j].x, c.cells[j].y) then
+ target = j
+ break
+ end
+ end
+ local btn, mover = OpenAt(1, off + 960, off + 540)
+ view._gateX, view._gateY = off + 960, off + 540
+ mover(c.parentBox.x + 960, c.parentBox.y + 540)
+ local armed = tonumber(btn:GetAttribute("eapArmed"))
+ local cell = c.cells[target or 1]
+ Walk(mover, c.parentBox.x + 960, c.parentBox.y + 540,
+ cell.x + 960, cell.y + 540, 6)
+ local got, why = ReleaseAt(btn, mover, cell.x + 960, cell.y + 540)
+ local want = c.base + (target or 1)
+ if armed ~= claimIdx or not target or got ~= want then
+ fails = fails + 1
+ offline[#offline + 1] = (" claim %d of 3: armed=%s target=%s got=%s want=%s why=%s")
+ :format(claimIdx, tostring(armed), tostring(target),
+ tostring(got), tostring(want), tostring(why))
+ end
+ end
+ end
+
+ -- 5. Press-time pre-arm. Cursor mode opens the palette centred on the
+ -- pointer, so a 3x3 grid whose MIDDLE slot nests has that claim's own
+ -- parent gate placed exactly under the cursor and shown there -- with no
+ -- OnEnter ever raised, since the cursor was already inside it. The claim
+ -- used to be drawn with dead children for the whole hold. The armed state
+ -- is read BEFORE any cursor motion at all, so nothing but the press
+ -- itself can account for it, and the trace is read there too: "P1;" and
+ -- nothing else.
+ do
+ Base(9, 5, 6)()
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, "POPOUT"
+ p.centerMode = "CURSOR"
+ ns.Refresh()
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ view._steered, view._gateX, view._gateY = true, 960, 540
+ local c1 = view.claims[1]
+ local btn, mover = OpenAt(1, 960, 540)
+ local armedAtPress = tonumber(btn:GetAttribute("eapArmed"))
+ local trace = btn:GetAttribute("eapGTrace")
+ -- The other half of arming, which the live view never reads but every
+ -- later cursor sample depends on: this claim's region gates are up,
+ -- so there is something to fire the leave test when it is left.
+ local rg = byName["EUIActionPaletteButton1RGate1_2"]
+ local rgUp = rg and rg:IsShown() and rg._positioned
+ -- And the payoff: the nest's first child actually fires.
+ Walk(mover, 960, 540, c1.cells[1].x + 960, c1.cells[1].y + 540, 8)
+ local got, why = ReleaseAt(btn, mover, c1.cells[1].x + 960, c1.cells[1].y + 540)
+ local want = c1.base + 1
+ if armedAtPress ~= 1 or trace ~= "P1;" or not rgUp or got ~= want then
+ fails = fails + 1
+ offline[#offline + 1] = (" press pre-arm: armed=%s trace=%s regions-up=%s got=%s want=%s why=%s")
+ :format(tostring(armedAtPress), tostring(trace), tostring(rgUp),
+ tostring(got), tostring(want), tostring(why))
+ end
+ p.centerMode = "SCREEN"
+ end
+
+ -- 6. Disarm-time re-arm. Two claims on adjacent cells: walk onto claim
+ -- 1's entry, which arms it through its own gate's OnEnter, then move in
+ -- ONE sample onto claim 2's entry. Claim 2's parent gate is dark for as
+ -- long as claim 1 is armed, so it raises no OnEnter of its own when the
+ -- disarm puts it back up under the cursor -- claim 1's own leave test,
+ -- asking geometrically after it re-shows the gates, is the only thing
+ -- that can arm claim 2 here. Without it the user has to move off that
+ -- entry and back on.
+ do
+ Base(9, 4, 6)()
+ Palette(1).slots[5] = { kind = "palette", palette = 3 }
+ local c3 = Palette(3)
+ c3.slots = {}
+ for i = 1, 6 do c3.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, "POPOUT"
+ ns.Refresh()
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ view._steered = true
+ local c1, c2 = view.claims[1], view.claims[2]
+ local off = ClearOpen(view)
+ local btn, mover = OpenAt(1, off + 960, off + 540)
+ view._gateX, view._gateY = off + 960, off + 540
+ mover(c1.parentBox.x + 960, c1.parentBox.y + 540)
+ local armed1 = tonumber(btn:GetAttribute("eapArmed"))
+ mover(c2.parentBox.x + 960, c2.parentBox.y + 540)
+ local armed2 = tonumber(btn:GetAttribute("eapArmed"))
+ if armed1 ~= 1 or armed2 ~= 2 then
+ fails = fails + 1
+ offline[#offline + 1] = (" leave re-arm: armed %s then %s, want 1 then 2 (trace %s)")
+ :format(tostring(armed1), tostring(armed2),
+ tostring(btn:GetAttribute("eapGTrace")))
+ end
+ snippet(btn, "LeftButton", false)
+ end
+
+ -- 7. Overshoot grace. Reaching fast for a small child icon overruns the
+ -- nest's own edge, and one sample past it used to disarm the claim and
+ -- take the nest off the screen mid-reach. CellChildGeom builds the grace
+ -- into the nest RECT, so the gate frames sized from it carry the same
+ -- slack the leave test does. Probed either side of it, on the outward
+ -- side where the grace covers empty screen: within the grace the claim
+ -- is still armed, past the grace the disarm still happens.
+ local function TightNest(cells)
+ local x0, x1 = cells[1].x - cells[1].hw, cells[1].x + cells[1].hw
+ local y0, y1 = cells[1].y - cells[1].hh, cells[1].y + cells[1].hh
+ for j = 2, #cells do
+ local b = cells[j]
+ x0, x1 = math.min(x0, b.x - b.hw), math.max(x1, b.x + b.hw)
+ y0, y1 = math.min(y0, b.y - b.hh), math.max(y1, b.y + b.hh)
+ end
+ return { x = (x0 + x1) * 0.5, y = (y0 + y1) * 0.5,
+ hw = (x1 - x0) * 0.5, hh = (y1 - y0) * 0.5 }
+ end
+
+ for _, case in ipairs({
+ -- A bottom-edge parent with a short run, so the nest is one group on
+ -- one side and the outward edge under the probe is the run's own.
+ { label = "lane 3x3", setup = function()
+ Base(9, 8, 3)()
+ p.layout, p.gridAutoColumns = "GRID", true
+ end },
+ -- A 1xN strip, the shape the complaint was reported on: its nest is
+ -- one small block broken out across the strip, so the reach for it is
+ -- short and the overshoot is most of it.
+ { label = "strip 1x5", setup = function()
+ Base(5, 3, 6)()
+ p.layout, p.gridAutoColumns, p.gridColumns = "GRID", false, 5
+ end },
+ }) do
+ case.setup()
+ ns.Refresh()
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ view._steered = true
+ local c1 = view.claims[1]
+ local tight = TightNest(c1.cells)
+ -- The grace CellChildGeom is supposed to have applied, worked out
+ -- from the same metrics rather than read back off the region: the
+ -- probe points have to be the SAME two screen points whether the
+ -- region carries the grace or not, or a missing inflation would only
+ -- move the probes in with it and prove nothing. c.standoff is the
+ -- lane's own answer, exactly as CellChildGeom reads it.
+ local m = view:NestMetrics(view:ShownCount())
+ local grace = math.max(c1.standoff or m.band, 0.75 * m.childPitch)
+ -- The away axis is the one the nest lies OUT along; c.axis is the one
+ -- its cells spread along.
+ local away, hAway = "y", "hh"
+ if c1.axis ~= "X" then away, hAway = "x", "hw" end
+ -- Measured at the OUTWARD edge, which every region shape moves by
+ -- exactly the grace: a corridor style's rect is the nest box itself,
+ -- and a lane's folds the parent cell in on the INWARD side (see
+ -- RunReach), so a half-extent difference would read the fold as grace.
+ local r2 = c1.regions[2]
+ local applied
+ if c1.sign > 0 then
+ applied = (r2[away] + r2[hAway]) - (tight[away] + tight[hAway])
+ else
+ applied = (tight[away] - tight[hAway]) - (r2[away] - r2[hAway])
+ end
+ local function Probe(mult)
+ local pt = { x = tight.x, y = tight.y }
+ pt[away] = tight[away] + c1.sign * (tight[hAway] + grace * mult)
+ return pt
+ end
+ local within, past = Probe(0.5), Probe(1.5)
+
+ local off = ClearOpen(view)
+ local btn, mover = OpenAt(1, off + 960, off + 540)
+ view._gateX, view._gateY = off + 960, off + 540
+ mover(c1.parentBox.x + 960, c1.parentBox.y + 540)
+ local armed = tonumber(btn:GetAttribute("eapArmed"))
+ Walk(mover, c1.parentBox.x + 960, c1.parentBox.y + 540,
+ tight.x + 960, tight.y + 540, 6)
+ mover(within.x + 960, within.y + 540)
+ local stillArmed = tonumber(btn:GetAttribute("eapArmed"))
+ mover(past.x + 960, past.y + 540)
+ local nowClear = tonumber(btn:GetAttribute("eapArmed"))
+ if grace <= 1 or math.abs(applied - grace) > 0.01
+ or armed ~= 1 or stillArmed ~= 1 or nowClear ~= nil then
+ fails = fails + 1
+ offline[#offline + 1] = (" %s grace: grace=%.1f applied=%.1f armed=%s within=%s past=%s (want 1, 1, nil)")
+ :format(case.label, grace, applied, tostring(armed),
+ tostring(stillArmed), tostring(nowClear))
+ end
+ snippet(btn, "LeftButton", false)
+ end
+ p.gridColumns, p.gridAutoColumns = nil, true
+
+ -- 8. EVERY child of a lane, not just the first, and by a slow deliberate
+ -- reach rather than a jump: open, walk onto the parent, then a straight line
+ -- of twelve samples to that child's own centre and release there. A run
+ -- wrapped around a corner is the case this exists for -- its far ends sit
+ -- back beside the parent rather than out in front of it, so the reach for one
+ -- of them leaves the parent's cell through an edge no corridor was laid
+ -- across, and the claim used to disarm a unit or two short of the run's own
+ -- rect. That is not a cancel: the release goes on to fire whichever PLAIN
+ -- entry the cursor came to rest over, so the palette silently casts the wrong
+ -- spell. Samples matter here -- a two-sample jump steps clean over the gap
+ -- and passes.
+ --
+ -- Where another claim's entry stands in the way the promise changes rather
+ -- than lapses: that cell is a hole in this claim's coverage on purpose, so
+ -- the reach across it must HAND OVER -- disarm this claim and arm the one the
+ -- cursor is on -- and the check holds it to that instead. Every case here has
+ -- at least one child of each kind, which is what makes both halves real.
+ for _, case in ipairs({
+ { label = "lane corner parent, 8 children", setup = Base(9, 1, 8) },
+ { label = "lane corner parent, 6 children", setup = Base(9, 1, 6) },
+ { label = "lane edge parent, 6 children", setup = Base(12, 10, 6) },
+ { label = "lane centre parent, 8 children", setup = Base(9, 5, 8) },
+ -- Two claims on adjacent cells of the bottom row, both with a full run
+ -- along the same lane. The children out past the neighbour's own cell are
+ -- the handoff half; the ones over the parent's own end of the lane are
+ -- the half that must survive the whole reach, neighbour or no neighbour.
+ { label = "lane, two adjacent claims", handoff = true, setup = function()
+ Base(9, 7, 8)()
+ Palette(1).slots[8] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 8 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ end },
+ }) do
+ case.setup()
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, "PERIMETER"
+ ns.Refresh()
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ local off = ClearOpen(view)
+ view._steered, view._gateX, view._gateY = true, off + 960, off + 540
+ local c1 = view.claims[1]
+ local unreachable, misfired, handedOff = {}, {}, {}
+ for j = 1, c1.n do
+ local across = HandsOffAlong(view, 1, c1.cells[j].x, c1.cells[j].y)
+ local btn, mover = OpenAt(1, off + 960, off + 540)
+ mover(c1.parentBox.x + 960, c1.parentBox.y + 540)
+ local armed = tonumber(btn:GetAttribute("eapArmed"))
+ Walk(mover, c1.parentBox.x + 960, c1.parentBox.y + 540,
+ c1.cells[j].x + 960, c1.cells[j].y + 540, 12)
+ local heldOn = tonumber(btn:GetAttribute("eapArmed"))
+ local got = ReleaseAt(btn, mover, c1.cells[j].x + 960, c1.cells[j].y + 540)
+ if armed ~= 1 then
+ unreachable[#unreachable + 1] = j
+ elseif across then
+ -- Handed over to SOME other claim, not necessarily the one the
+ -- line crossed: past the hole the cursor goes on travelling, and
+ -- whichever claim it ends on is the one that answers. What must
+ -- not happen is this claim staying armed over ground it does not
+ -- hold.
+ if heldOn == 1 then
+ handedOff[#handedOff + 1] = ("%d stayed armed across claim %d")
+ :format(j, across)
+ end
+ else
+ if heldOn ~= 1 then unreachable[#unreachable + 1] = j end
+ if got ~= c1.base + j then
+ misfired[#misfired + 1] = ("%d fired %s"):format(j, tostring(got))
+ end
+ end
+ end
+ -- Vacuous either way is a failure of the case, not a pass: a config where
+ -- nothing crosses a neighbour proves nothing about the handoff, and one
+ -- where everything does proves nothing about the reach.
+ local crossings = 0
+ for j = 1, c1.n do
+ if HandsOffAlong(view, 1, c1.cells[j].x, c1.cells[j].y) then
+ crossings = crossings + 1
+ end
+ end
+ local shape = (case.handoff and (crossings == 0 or crossings == c1.n))
+ and ("only %d of %d children cross a neighbour"):format(crossings, c1.n)
+ or (not case.handoff and crossings > 0)
+ and ("%d children cross a neighbour in a one-claim config"):format(crossings)
+ or nil
+ if #unreachable > 0 or #misfired > 0 or #handedOff > 0 or shape then
+ fails = fails + 1
+ offline[#offline + 1] = (" %s: reach broke on children [%s]; releases wrong [%s]; %s%s")
+ :format(case.label, table.concat(unreachable, ","),
+ table.concat(misfired, "; "),
+ table.concat(handedOff, "; "), shape and ("; " .. shape) or "")
+ end
+ end
+
+ -- 9. Swapping between two claims WITHOUT leaving the row. A lane's region
+ -- sweeps its parent's own row (see RunReach), so while claim 1 is armed its
+ -- coverage used to stand over claim 2's entry as well -- and claim 2's own
+ -- parent gate is dark for as long as claim 1 is armed. Gliding from one entry
+ -- straight onto the other therefore left no gate of claim 1's, fired no
+ -- OnLeave, disarmed nothing, and armed nothing: the user had to leave the
+ -- whole row and come back before the second nest would open. Claim 2's cell
+ -- is now a hole in claim 1's coverage, so the glide crosses a real boundary.
+ -- The trace is read too, because "armed 2" alone would also be satisfied by a
+ -- press-time pre-arm or by never having armed 1 in the first place: L1:out is
+ -- claim 1's own leave test answering, and R2 is its re-arm handing over.
+ for _, style in ipairs({ "PERIMETER", "POPOUT", "HALO" }) do
+ Base(9, 7, 8)()
+ Palette(1).slots[8] = { kind = "palette", palette = 3 }
+ local c3 = Palette(3)
+ c3.slots = {}
+ for i = 1, 8 do c3.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.layout, p.gridAutoColumns, p.gridNestStyle = "GRID", true, style
+ ns.Refresh()
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ view._steered = true
+ local c1, c2 = view.claims[1], view.claims[2]
+ local off = ClearOpen(view)
+ local btn, mover = OpenAt(1, off + 960, off + 540)
+ view._gateX, view._gateY = off + 960, off + 540
+ mover(c1.parentBox.x + 960, c1.parentBox.y + 540)
+ local armed1 = tonumber(btn:GetAttribute("eapArmed"))
+ -- A glide, not a jump: the samples between the two entries are where the
+ -- boundary has to be, and a two-sample hop over it would pass either way.
+ Walk(mover, c1.parentBox.x + 960, c1.parentBox.y + 540,
+ c2.parentBox.x + 960, c2.parentBox.y + 540, 10)
+ local armed2 = tonumber(btn:GetAttribute("eapArmed"))
+ local trace = btn:GetAttribute("eapGTrace") or ""
+ -- And the second nest is really open: its own first child fires from
+ -- where the swap left the cursor.
+ Walk(mover, c2.parentBox.x + 960, c2.parentBox.y + 540,
+ c2.cells[1].x + 960, c2.cells[1].y + 540, 8)
+ local got, why = ReleaseAt(btn, mover, c2.cells[1].x + 960, c2.cells[1].y + 540)
+ if armed1 ~= 1 or armed2 ~= 2 or got ~= c2.base + 1
+ or not trace:find("L1:out;", 1, true) or not trace:find("R2;", 1, true) then
+ fails = fails + 1
+ offline[#offline + 1] = (" %s swap along the row: armed %s->%s trace=%s got=%s want=%s why=%s")
+ :format(style, tostring(armed1), tostring(armed2), trace,
+ tostring(got), tostring(c2.base + 1), tostring(why))
+ end
+ end
+
+ -- 10. THE ARC, through the same real gates. Every check above it is a
+ -- block layout, and the arc's gate path had no offline check at all -- so
+ -- the one thing no block layout has went untested: the polar ground test
+ -- LeaveSnippet makes for an ANGULAR claim. A rect layout's regions cover
+ -- their own children by construction, but an arc's do not, and the ground
+ -- between a parent's icon and its rings used to belong to nothing at all:
+ -- the pgate's OnLeave fired a few units into every reach for a child, the
+ -- test answered "outside", the claim disarmed, and the nest went dim with
+ -- dead children for the rest of the hold. In game that reads as "the
+ -- nested entries are drawn but cannot be picked" -- the release goes on to
+ -- fire whichever plain entry the angle lands on.
+ --
+ -- The walks are SAMPLED, twelve at a time: the ground that used to be
+ -- missing is a band a few units wide, and a two-sample jump steps clean
+ -- over it.
+ local function ArcClaimAngleOffsets(view, c)
+ local mx = 0
+ for j = 1, c.n do
+ local _, a = view:ChildRingPos(c, j)
+ mx = math.max(mx, math.abs(a - c.angle))
+ end
+ return mx
+ end
+
+ for _, case in ipairs({
+ -- Children that fit in one ring, and a parent sector wide enough that
+ -- the ring stays inside it: the plainest shape there is.
+ { label = "arc, one ring inside its sector", rings = 1,
+ setup = Base(6, 1, 5) },
+ -- Eight children on a twelve-entry palette: 30 degrees of parent
+ -- sector, and a first ring that now spreads to 90 -- so the reach for
+ -- an edge child leaves the parent's icon already well outside the
+ -- sector the entry itself owns, and spills a second ring on top.
+ { label = "arc, ring wider than its sector", rings = 2, widened = true,
+ setup = Base(12, 1, 8) },
+ -- A small radius, full-size child icons and a hair of nest distance:
+ -- the rings come out crowded and close together, and the band between
+ -- the parent's icon and the first of them is five units wide.
+ { label = "arc, crowded rings, thin band", rings = 2,
+ setup = function()
+ Base(6, 1, 8)()
+ p.radius, p.iconSize, p.nestScale, p.nestBand = 60, 44, 1.0, 10
+ end },
+ -- Two claims two entries apart, each spread wider than its own sector,
+ -- so their grounds meet over the plain entry between them. Only one of
+ -- them is ever armed, which is what makes that legal -- and the entry
+ -- between them still fires when neither is.
+ { label = "arc, two widened nests", rings = 2, overlap = true,
+ setup = function()
+ Base(8, 1, 8)()
+ Palette(1).slots[3] = { kind = "palette", palette = 3 }
+ local c = Palette(3)
+ c.slots = {}
+ for i = 1, 8 do c.slots[i] = { kind = "spell", id = 300 + i } end
+ p.paletteCount = 3
+ p.arcChildOverflow = "MIDPOINT"
+ end },
+ }) do
+ case.setup()
+ -- Spelled out rather than inherited: an earlier sweep leaves this at
+ -- whatever it last needed, and every ring count asserted below is a
+ -- count at THIS span.
+ p.layout, p.arcSpan, p.arcChildMaxSpan = "ARC", 360, 90
+ ns.Refresh()
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ local off = ClearOpen(view)
+ view._steered, view._gateX, view._gateY = true, off + 960, off + 540
+ local shown = view:ShownCount()
+ local step = view:ArcGeom(shown)
+ local c1 = view.claims[1]
+ local report = {}
+
+ -- (a) The press branch's own geometric pre-arm, on an arc: a press
+ -- with the cursor already standing on the claim's entry raises no
+ -- OnEnter of its own, so nothing but the press can account for the
+ -- claim being armed. Read before any cursor motion at all.
+ do
+ local btn = OpenAt(1, c1.parentBox.x + 960, c1.parentBox.y + 540)
+ local armed = tonumber(btn:GetAttribute("eapArmed"))
+ local trace = btn:GetAttribute("eapGTrace")
+ if armed ~= 1 or trace ~= "P1;" then
+ report[#report + 1] = ("press pre-arm armed=%s trace=%s (want 1, P1;)")
+ :format(tostring(armed), tostring(trace))
+ end
+ snippet(btn, "LeftButton", false)
+ end
+
+ -- (b) Out to EVERY child, one straight reach each, and the claim has
+ -- to survive every sample of it. "Survive" is asked of the model's own
+ -- idea of the claim's ground rather than assumed: a sample the ground
+ -- does not cover is a sample the claim is entitled to disarm on, and
+ -- the count of those is reported too, because a reach that leaves the
+ -- ground it was widened to cover is itself the bug.
+ local strayed, lost, misfired, sawLeave = {}, {}, {}, 0
+ for j = 1, c1.n do
+ local r, a = view:ChildRingPos(c1, j)
+ local cx, cy = r * math.sin(a), r * math.cos(a)
+ local btn, mover = OpenAt(1, off + 960, off + 540)
+ mover(c1.parentBox.x + 960, c1.parentBox.y + 540)
+ if tonumber(btn:GetAttribute("eapArmed")) ~= 1 then
+ lost[#lost + 1] = ("%d never armed"):format(j)
+ end
+ local x0, y0 = c1.parentBox.x, c1.parentBox.y
+ for i = 1, 12 do
+ local t = i / 12
+ local x, y = x0 + (cx - x0) * t, y0 + (cy - y0) * t
+ mover(x + 960, y + 540)
+ local armed = tonumber(btn:GetAttribute("eapArmed"))
+ if not ClaimContains(view, c1, x, y) then
+ strayed[#strayed + 1] = ("%d@%.2f"):format(j, t)
+ elseif armed ~= 1 then
+ lost[#lost + 1] = ("%d@%.2f armed=%s"):format(j, t, tostring(armed))
+ end
+ end
+ if (btn:GetAttribute("eapGTrace") or ""):find("L1:in;", 1, true) then
+ sawLeave = sawLeave + 1
+ end
+ local got, why = ReleaseAt(btn, mover, cx + 960, cy + 540)
+ if got ~= c1.base + j then
+ misfired[#misfired + 1] = ("%d fired %s (%s)")
+ :format(j, tostring(got), tostring(why))
+ end
+ end
+ -- A reach that never left a gate never asked the leave test anything,
+ -- and a check that only ever exercised the arming half would pass
+ -- with the ground test deleted. At least one of these reaches has to
+ -- have crossed a real gate boundary and been answered "still inside".
+ if sawLeave == 0 then
+ report[#report + 1] = "no reach ever put the leave test to the question"
+ end
+ if #strayed > 0 or #lost > 0 or #misfired > 0 then
+ report[#report + 1] = ("reaches off the claim's ground [%s]; disarmed [%s]; wrong release [%s]")
+ :format(table.concat(strayed, ","), table.concat(lost, "; "),
+ table.concat(misfired, "; "))
+ end
+
+ -- (c) Sideways instead, onto the PLAIN entry next door. The entry ring
+ -- belongs to the entries however wide a nest's children spread over
+ -- it, so both sides must answer with that entry and neither with a
+ -- child of the nest -- and the leave test must have been asked, which
+ -- is what says the claim's ground was measured rather than missed.
+ --
+ -- The claim is NOT required to have disarmed by the last sample. A
+ -- plain entry carries no gate of its own, so once the cursor is off
+ -- every rect of the claim there is nothing left to fire the leave test
+ -- again, and the claim can stay armed over an entry that is not its
+ -- own -- which costs the drawing (the nest stays open) and nothing
+ -- else, since the release below never consults it inside the ring. The
+ -- claim-to-claim glide, where it WOULD cost something, is check (d).
+ do
+ local radius = select(1, view:Geom())
+ local na = c1.angle + step
+ local nx, ny = radius * math.sin(na), radius * math.cos(na)
+ local btn, mover = OpenAt(1, off + 960, off + 540)
+ mover(c1.parentBox.x + 960, c1.parentBox.y + 540)
+ local armed = tonumber(btn:GetAttribute("eapArmed"))
+ Walk(mover, c1.parentBox.x + 960, c1.parentBox.y + 540,
+ nx + 960, ny + 540, 12)
+ local trace = btn:GetAttribute("eapGTrace") or ""
+ CURSOR.x, CURSOR.y = nx + 960, ny + 540
+ local want = view:HitTest()
+ local got, why = ReleaseAt(btn, mover, nx + 960, ny + 540)
+ if armed ~= 1 or got ~= c1.parent + 1 or want ~= c1.parent + 1
+ or got > shown or not trace:find("L1", 1, true) then
+ report[#report + 1] = ("sideways onto entry %d: armed=%s trace=%s view=%s got=%s why=%s")
+ :format(c1.parent + 1, tostring(armed), trace,
+ tostring(want), tostring(got), tostring(why))
+ end
+ end
+
+ -- (d) The same glide, but onto ANOTHER CLAIM's entry: that one has to
+ -- hand over, or its own nest is unreachable for the rest of the hold.
+ -- An arc claim's ground never covers a neighbouring entry's centre, so
+ -- the leave test answers "outside" there -- and the neighbour's parent
+ -- gate is left alight on the arc precisely so that something fires it.
+ if case.overlap then
+ local c2 = view.claims[2]
+ local btn, mover = OpenAt(1, off + 960, off + 540)
+ mover(c1.parentBox.x + 960, c1.parentBox.y + 540)
+ local armed = tonumber(btn:GetAttribute("eapArmed"))
+ Walk(mover, c1.parentBox.x + 960, c1.parentBox.y + 540,
+ c2.parentBox.x + 960, c2.parentBox.y + 540, 12)
+ local after = tonumber(btn:GetAttribute("eapArmed"))
+ local trace = btn:GetAttribute("eapGTrace") or ""
+ -- And the second nest really is live: its own first child fires
+ -- from a reach that starts where the swap left the cursor.
+ local r, a = view:ChildRingPos(c2, 1)
+ local cx, cy = r * math.sin(a), r * math.cos(a)
+ Walk(mover, c2.parentBox.x + 960, c2.parentBox.y + 540,
+ cx + 960, cy + 540, 12)
+ local got, why = ReleaseAt(btn, mover, cx + 960, cy + 540)
+ if armed ~= 1 or after ~= 2 or got ~= c2.base + 1
+ or not trace:find("L1:out;", 1, true) then
+ report[#report + 1] = ("glide onto claim 2: armed %s->%s trace=%s got=%s want=%s why=%s")
+ :format(tostring(armed), tostring(after), trace,
+ tostring(got), tostring(c2.base + 1), tostring(why))
+ end
+ end
+
+ -- Shape guards. Each case is here for a geometry, and a change that
+ -- quietly stopped producing it would leave the case passing about
+ -- nothing: the ring count is what makes the multi-ring cases multi-ring,
+ -- and the angular spread is what makes the widened ones widened.
+ if #c1.rows ~= case.rings then
+ report[#report + 1] = ("%d rings, expected %d"):format(#c1.rows, case.rings)
+ end
+ local spread = ArcClaimAngleOffsets(view, c1)
+ if case.widened and spread <= step * 0.5 then
+ report[#report + 1] = ("children span %.1f deg, inside the entry's own %.1f")
+ :format(spread * 180 / math.pi, step * 0.5 * 180 / math.pi)
+ end
+ -- Two claims whose grounds do not actually meet would prove nothing
+ -- about overlap being safe.
+ if case.overlap then
+ local c2 = view.claims[2]
+ local mid = (c1.angle + c2.angle) * 0.5
+ local rr = c1.rows[1].radius
+ local mx, my = rr * math.sin(mid), rr * math.cos(mid)
+ if not (ClaimContains(view, c1, mx, my)
+ and ClaimContains(view, c2, mx, my)) then
+ report[#report + 1] = "the two nests' grounds do not overlap"
+ end
+ end
+
+ if #report > 0 then
+ fails = fails + 1
+ offline[#offline + 1] = (" %s: %s"):format(case.label,
+ table.concat(report, "; "))
+ end
+ p.radius, p.iconSize, p.nestScale, p.nestBand = nil, nil, nil, nil
+ p.arcChildOverflow = "NONE"
+ end
+
+ print(("real-gate focus paths %5d wrong%s"):format(
+ fails, fails > 0 and " <-- FAIL" or ""))
+ for _, line in ipairs(offline) do print(line) end
+ bad = bad + fails
+ p.layout, p.gridNestStyle = "ARC", "PERIMETER"
+end
+
+----------------------------------------------------------------------------
+-- Cycling marker entries
+--
+-- The position one of these has reached is stepped inside the snippet, so
+-- only a real press can say what the next release fires. Ten presses round an
+-- eight-step cycle: every marker in its turn, and the wrap back to the first.
+--
+-- The world numbers below are stated outright rather than read back out of
+-- the module. They ARE the assertion: Blizzard's WORLD_RAID_MARKER_ORDER runs
+-- skull to star, and a copy of it read the other way round places the marker
+-- mirrored about the middle -- star puts down the skull -- which is a bug no
+-- amount of internal agreement can see.
+----------------------------------------------------------------------------
+do
+ local fails = 0
+
+ local function RunCycle(label, kind, wantText, wantName)
+ local a = Palette(1)
+ a.slots = { { kind = kind } }
+ p.paletteCount = 1
+ p.layout, p.arcSpan, p.arcChildOverflow = "ARC", 360, "NONE"
+ ns.Refresh()
+
+ local btn = byName["EUIActionPaletteButton1"]
+ btn:SetFrameRef("ui", UIParent)
+ local view = ns.CreatePaletteView(UIParent, {})
+ view:Layout(1)
+ view:GetFrame():SetCenter(960, 540)
+ view._steered = true
+
+ local report = {}
+ for press = 1, #wantText do
+ -- What the entry ADVERTISES, read before the press that spends it:
+ -- in a radial the icon is what the hand picks by, so the name it
+ -- draws has to be the marker that then lands.
+ local _, name = ns.SlotDisplay(a.slots[1])
+ CURSOR.x, CURSOR.y = 20, 20
+ snippet(btn, "LeftButton", true)
+ -- One entry in a full circle: every angle outside the dead zone
+ -- resolves to it, so straight up needs no geometry of its own.
+ CURSOR.x, CURSOR.y = 960, 540 + 200
+ snippet(btn, "LeftButton", false)
+
+ local why = btn:GetAttribute("eapWhy")
+ local text = btn:GetAttribute("macrotext")
+ if why ~= "fire" then
+ report[#report + 1] = ("press %d: %s, not a fire"):format(press, tostring(why))
+ elseif text ~= wantText[press] then
+ report[#report + 1] = ("press %d: fired %s, wanted %s"):format(
+ press, tostring(text), wantText[press])
+ elseif name ~= wantName[press] then
+ report[#report + 1] = ("press %d: drawn as %s, fired %s"):format(
+ press, tostring(name), wantText[press])
+ end
+
+ -- OnPostClick's mirror-back, which is what carries the snippet's
+ -- answer into the next push and into every icon drawn before it.
+ a.slots[1].cyclePos = tonumber(btn:GetAttribute("eapCycPos1"))
+ ns.Refresh()
+ end
+
+ if #report > 0 then
+ fails = fails + 1
+ print((" %s: %s"):format(label, table.concat(report, "; ")))
+ end
+ end
+
+ local ORDER = { "Star", "Circle", "Diamond", "Triangle",
+ "Moon", "Square", "Cross", "Skull" }
+ -- Icon position -> the number /wm takes. Blue, green, purple, red, yellow,
+ -- orange, silver, white is the engine's own run.
+ local WM = { 5, 6, 3, 2, 7, 1, 4, 8 }
+
+ local tmText, tmName, wmText, wmName = {}, {}, {}, {}
+ for press = 1, 10 do
+ local i = (press - 1) % 8 + 1
+ tmText[press] = "/tm " .. i
+ tmName[press] = "Cycle Target Marker: " .. ORDER[i]
+ wmText[press] = "/wm " .. WM[i]
+ wmName[press] = "Cycle World Marker: " .. ORDER[i]
+ end
+
+ RunCycle("cycle target marker", "cycleraidtarget", tmText, tmName)
+ RunCycle("cycle world marker", "cycleworldmarker", wmText, wmName)
+
+ print(("marker cycles step and wrap %5d wrong%s"):format(
+ fails, fails > 0 and " <-- FAIL" or ""))
+ bad = bad + fails
+end
+
+----------------------------------------------------------------------------
+-- One palette at a time owns the shared secure state
+--
+-- The scroll catcher, the cancel button and the ESCAPE binding belong to
+-- every palette at once, so a second key pressed while the first is still
+-- HELD must change none of them -- and its own release must put none of them
+-- away. The Lua PreClick that refuses the second key runs before the snippet
+-- and cannot stop it, so the test that matters is the one inside SNIPPET_PRE
+-- and SNIPPET_POST, which is what this executes: both real bodies, on the
+-- real buttons, in the order a real pair of keys would run them.
+----------------------------------------------------------------------------
+do
+ local fails, report = 0, {}
+
+ local postBody = src:match("local SNIPPET_POST = %[==%[(.-)%]==%]")
+ assert(postBody, "SNIPPET_POST not found")
+ postBody = postBody:gsub("__REGION_MAX__", tostring(ns.REGION_MAX))
+ -- Wrapped_Click builds the post-body against "self,message,button,down",
+ -- one argument more than the pre-body -- see SecureHandlers.lua.
+ local post = assert(load("local self, message, button, down = ...\n" .. postBody,
+ "SNIPPET_POST", "t", sandbox))
+
+ local function Want(label, got, want)
+ if got ~= want then
+ report[#report + 1] = ("%s: got %s, wanted %s"):format(
+ label, tostring(got), tostring(want))
+ end
+ end
+
+ -- A scroll fan, so the shared catcher is actually in play: it is the frame
+ -- the strip is steered through, and the one a stray release would hide.
+ Base(4, nil, 0)()
+ p.layout, p.fanInput = "FAN", "SCROLL"
+ ns.Refresh()
+
+ local b1 = byName["EUIActionPaletteButton1"]
+ local b2 = byName["EUIActionPaletteButton2"]
+ local catcher = byName["EUIActionPaletteScrollCatcher"]
+ local cancel = byName["EUIActionPaletteCancel"]
+ assert(b1 and b2 and catcher and cancel, "both buttons, the catcher and the cancel button")
+ -- Nothing holds the screen at this point: every earlier check presses
+ -- through the pre-body only, which leaves the stamp standing.
+ cancel:SetAttribute("eapOwner", nil)
+
+ CURSOR.x, CURSOR.y = 20, 20
+ snippet(b1, "LeftButton", true)
+ Want("palette 1 steers a scroll strip", b1:GetAttribute("eapMode"), "SCROLL")
+ Want("palette 1 owns the screen", cancel:GetAttribute("eapOwner"), 1)
+ Want("the catcher is open", catcher:GetAttribute("eapOpen"), 1)
+ -- Somewhere along the strip, so a re-seed by the second key would show.
+ catcher:SetAttribute("eapFanTarget", 3)
+
+ snippet(b2, "LeftButton", true)
+ Want("palette 2's press is refused", b2:GetAttribute("eapWhy"), "taken")
+ Want("the strip is where palette 1 left it", catcher:GetAttribute("eapFanTarget"), 3)
+ Want("the owner is unchanged", cancel:GetAttribute("eapOwner"), 1)
+
+ post(b2, nil, "LeftButton", false)
+ Want("palette 2's release leaves the catcher open", catcher:GetAttribute("eapOpen"), 1)
+ Want("palette 2's release leaves the catcher shown", catcher:IsShown(), true)
+ Want("palette 2's release leaves the owner alone", cancel:GetAttribute("eapOwner"), 1)
+
+ post(b1, nil, "LeftButton", false)
+ Want("palette 1's own release closes the catcher", catcher:GetAttribute("eapOpen"), nil)
+ Want("palette 1's own release hides the catcher", catcher:IsShown(), false)
+ Want("palette 1's own release frees the screen", cancel:GetAttribute("eapOwner"), nil)
+
+ fails = #report
+ print(("one palette at a time owns the shared state %5d wrong%s"):format(
+ fails, fails > 0 and " <-- FAIL" or ""))
+ for _, line in ipairs(report) do print(" " .. line) end
+ bad = bad + fails
+ p.layout, p.fanInput = "ARC", nil
+end
+
+print(bad == 0 and "\nALL AGREE" or ("\n" .. bad .. " DISAGREEMENTS"))
+os.exit(bad == 0 and 0 or 1)
diff --git a/EUI__General_Options.lua b/EUI__General_Options.lua
index f86a6f188..7c8c85a14 100644
--- a/EUI__General_Options.lua
+++ b/EUI__General_Options.lua
@@ -4118,6 +4118,7 @@ initFrame:SetScript("OnEvent", function(self)
EllesmereUIDamageMeters = "Lightweight damage meters with simple but powerful customization.",
EllesmereUIChat = "Modern chat enhancements with useful utilities.",
EllesmereUIBags = "A beautiful visual refresh of Blizzard Bags with intuitive organization.",
+ EllesmereUIActionPalette = "Hold a key to open a palette of actions; point or scroll to choose, release to fire.",
}
local iy = -30
@@ -6551,6 +6552,7 @@ initFrame:SetScript("OnEvent", function(self)
EllesmereUIDamageMeters = "Lightweight damage meters with simple but powerful customization.",
EllesmereUIChat = "Modern chat enhancements with useful utilities.",
EllesmereUIBags = "A beautiful visual refresh of Blizzard Bags with intuitive organization.",
+ EllesmereUIActionPalette = "Hold a key to open a palette of actions; point or scroll to choose, release to fire.",
}
-- Pre-compute scroll height for background panel sizing
diff --git a/EllesmereUI.lua b/EllesmereUI.lua
index 6ef282c33..8cb6cdc8b 100644
--- a/EllesmereUI.lua
+++ b/EllesmereUI.lua
@@ -334,6 +334,7 @@ local ADDON_ROSTER = {
{ folder = "EllesmereUIDamageMeters", display = "Damage Meters", search_name = "EllesmereUI Damage Meters" },
{ folder = "EllesmereUIBags", display = "Bags", search_name = "EllesmereUI Bags" },
{ folder = "EllesmereUIDataBars", display = "DataBars", search_name = "EllesmereUI DataBars" },
+ { folder = "EllesmereUIActionPalette", display = "Action Palette", search_name = "EllesmereUI Action Palette" },
{ folder = "EllesmereUIPartyMode", display = "Party Mode", search_name = "EllesmereUI Party Mode", alwaysLoaded = true },
}
@@ -367,6 +368,7 @@ EllesmereUI.ADDON_GROUPS = {
"EllesmereUIQoL",
"EllesmereUIAuraBuffReminders",
"EllesmereUIDataBars",
+ "EllesmereUIActionPalette",
"EllesmereUIPartyMode",
},
},
@@ -4125,23 +4127,24 @@ EllesmereUI.ResolveFontName = ResolveFontName
-- Per-module font overrides: all state stored on the EllesmereUI table
-- to stay under the 200-local / 60-upvalue Lua 5.1 caps.
EllesmereUI._addonKeyToFolder = {
- actionBars = "EllesmereUIActionBars",
- nameplates = "EllesmereUINameplates",
- unitFrames = "EllesmereUIUnitFrames",
- cdm = "EllesmereUICooldownManager",
- resourceBars = "EllesmereUIResourceBars",
- auraBuff = "EllesmereUIAuraBuffReminders",
- extras = "EllesmereUIQoL",
- friends = "EllesmereUIFriends",
- minimap = "EllesmereUIMinimap",
- chat = "EllesmereUIChat",
- questTracker = "EllesmereUIQuestTracker",
- mythicTimer = "EllesmereUIMythicTimer",
- blizzardSkin = "EllesmereUIBlizzardSkin",
- damageMeters = "EllesmereUIDamageMeters",
- dataBars = "EllesmereUIDataBars",
- raidFrames = "EllesmereUIRaidFrames",
- bags = "EllesmereUIBags",
+ actionBars = "EllesmereUIActionBars",
+ nameplates = "EllesmereUINameplates",
+ unitFrames = "EllesmereUIUnitFrames",
+ cdm = "EllesmereUICooldownManager",
+ resourceBars = "EllesmereUIResourceBars",
+ auraBuff = "EllesmereUIAuraBuffReminders",
+ extras = "EllesmereUIQoL",
+ friends = "EllesmereUIFriends",
+ minimap = "EllesmereUIMinimap",
+ chat = "EllesmereUIChat",
+ questTracker = "EllesmereUIQuestTracker",
+ mythicTimer = "EllesmereUIMythicTimer",
+ blizzardSkin = "EllesmereUIBlizzardSkin",
+ damageMeters = "EllesmereUIDamageMeters",
+ dataBars = "EllesmereUIDataBars",
+ raidFrames = "EllesmereUIRaidFrames",
+ bags = "EllesmereUIBags",
+ actionPalette = "EllesmereUIActionPalette",
}
EllesmereUI._moduleFontCache = {}
EllesmereUI._moduleFontCacheVer = 0
@@ -10615,6 +10618,7 @@ function EllesmereUI:RegisterModule(folderName, config)
EllesmereUIDamageMeters = true,
EllesmereUIBags = true,
EllesmereUIDataBars = true,
+ EllesmereUIActionPalette = true,
}
if callerFolder and not ALLOWED[callerFolder] then return end
-- Suite-core marker (module key is a suite folder): gates the toolbar
diff --git a/EllesmereUIActionPalette/Bindings.xml b/EllesmereUIActionPalette/Bindings.xml
new file mode 100644
index 000000000..41681b4fd
--- /dev/null
+++ b/EllesmereUIActionPalette/Bindings.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EllesmereUIActionPalette/EUI_ActionPalette_Options.lua b/EllesmereUIActionPalette/EUI_ActionPalette_Options.lua
new file mode 100644
index 000000000..29f0475d8
--- /dev/null
+++ b/EllesmereUIActionPalette/EUI_ActionPalette_Options.lua
@@ -0,0 +1,2793 @@
+-------------------------------------------------------------------------------
+-- EUI_ActionPalette_Options.lua -- Settings page for the Action Palette
+-------------------------------------------------------------------------------
+local ADDON_NAME, ns = ...
+
+local PAGE_DISPLAY = "Action Palette"
+local BINDING_PREFIX = "EUI_RADIAL"
+-- Every palette has a entry of its own, so the keybind row below
+-- applies to all of them. Read from the module rather than restated, so the two
+-- can never disagree about how far the binding actions run.
+local MAX_PALETTES = ns.MAX_PALETTES or 16
+local MAX_SLOTS = ns.MAX_SLOTS or 12
+
+local initFrame = CreateFrame("Frame")
+initFrame:RegisterEvent("PLAYER_LOGIN")
+initFrame:SetScript("OnEvent", function(self)
+ self:UnregisterEvent("PLAYER_LOGIN")
+
+ if not EllesmereUI or not EllesmereUI.RegisterModule then return end
+
+ local db
+
+ -- Through ns.Profile, not db.profile: the module converts a profile's
+ -- retired key names on first touch, and reading the table directly here
+ -- would skip that whenever the panel is the first to see a profile the user
+ -- has just switched to. The db local is still kept for the reset handler.
+ local function DB()
+ if not db then db = _G._EAP_AceDB end
+ if ns.Profile then return ns.Profile() end
+ return db and db.profile
+ end
+
+ local function Cfg(key)
+ local p = DB()
+ return p and p[key]
+ end
+
+ local function Set(key, val)
+ local p = DB()
+ if p then p[key] = val end
+ end
+
+ local function Refresh()
+ if _G._EAP_Apply then _G._EAP_Apply() end
+ if EllesmereUI.RefreshPage then EllesmereUI:RefreshPage() end
+ end
+
+ local function RebuildPage()
+ if _G._EAP_Apply then _G._EAP_Apply() end
+ if EllesmereUI.RefreshPage then EllesmereUI:RefreshPage(true) end
+ end
+
+ -- Which palette the PALETTE SETUP section is editing. Transient: the editor
+ -- is a panel-session concept, not a saved setting.
+ local editPalette = 1
+
+ local function PaletteCount()
+ return math.min(MAX_PALETTES, math.max(1, Cfg("paletteCount") or 1))
+ end
+
+ local function Palette(index)
+ return ns.EnsurePalette and ns.EnsurePalette(index or editPalette)
+ end
+
+ ---------------------------------------------------------------------------
+ -- Per-palette appearance
+ --
+ -- The settings that describe a palette's SHAPE and PLACE -- its layout,
+ -- where it opens, its scale, and every knob that only means anything
+ -- inside one layout -- are read and written against the palette the
+ -- Editing Palette selector above is pointed at. The module holds the list
+ -- (ns.APPEARANCE_KEYS) and the fallback: a palette with no override of its
+ -- own reads the profile's value, so a profile written before any of this
+ -- existed draws exactly as it did.
+ --
+ -- Everything else on the page -- colors, hub art, labels, nesting
+ -- geometry, flick-ahead -- stays profile-wide and keeps using Cfg/Set.
+ --
+ -- Every row that writes through ASet carries noCapture, for the same
+ -- reason the name and icon boxes do: these live inside p.palettes[n]
+ -- rather than under a flat key, and a per-spec override banked against
+ -- whichever palette happened to be on screen at capture time would rewrite
+ -- a palette the user was not looking at.
+ ---------------------------------------------------------------------------
+ local function ACfg(key)
+ local pal = ns.EnsurePalette and ns.EnsurePalette(editPalette)
+ local a = pal and pal.appearance
+ local v = a and a[key]
+ if v ~= nil then return v end
+ return Cfg(key)
+ end
+
+ local function ASet(key, val)
+ local pal = ns.EnsurePalette and ns.EnsurePalette(editPalette)
+ if not pal then return end
+ pal.appearance = pal.appearance or {}
+ pal.appearance[key] = val
+ end
+
+ -- From the module, so the name an emptied box reverts to is the same string
+ -- the module hands a fresh palette.
+ local function AutoName(index)
+ if ns.AutoPaletteName then return ns.AutoPaletteName(index) end
+ return "Palette " .. index
+ end
+
+ -- A palette icon is stored as a plain icon file ID, so it needs no lookup at
+ -- draw time. The box takes whatever number the user has to hand: a spell ID
+ -- and an item ID are both far easier to find than an icon's own ID, so each
+ -- is tried first and only an unrecognised number is taken literally.
+ local function ResolveIconInput(text)
+ local id = tonumber(strtrim(text or ""))
+ if not id then return nil end
+ local spellIcon = C_Spell.GetSpellTexture(id)
+ if spellIcon then return spellIcon end
+ local itemIcon = C_Item.GetItemIconByID(id)
+ if itemIcon then return itemIcon end
+ return id
+ end
+
+ ---------------------------------------------------------------------------
+ -- Inline keybind picker
+ --
+ -- Binds the real EUI_RADIAL action declared in Bindings.xml via
+ -- SetBinding/SaveBindings, rather than storing a key of our own and
+ -- routing it separately. That keeps ONE source of truth: this picker and
+ -- Blizzard's Keybindings page edit the same binding, GetBindingKey keeps
+ -- driving ns.UpdateBindings, and the SaveBindings call fires
+ -- UPDATE_BINDINGS so the palette re-routes its override binding by itself.
+ ---------------------------------------------------------------------------
+ local listenPalette = nil
+ local captureFrame = nil
+
+ -- Combat-safe teardown. SetPropagateKeyboardInput carries restrictions and
+ -- is pcall'd elsewhere in the suite for exactly this reason
+ -- (EllesmereUIChat.lua:2552) -- and this runs in combat by construction,
+ -- because EllesmereUI hides its options window on PLAYER_REGEN_DISABLED
+ -- and that OnHide calls us.
+ local function StopListening()
+ local wasListening = listenPalette ~= nil
+ listenPalette = nil
+ if captureFrame then
+ pcall(captureFrame.SetPropagateKeyboardInput, captureFrame, true)
+ captureFrame:EnableKeyboard(false)
+ captureFrame:Hide()
+ end
+ return wasListening
+ end
+
+ -- The combat rejections below are the one case where EllesmereUI.Print is
+ -- the wrong channel: it deliberately stays silent in raid combat and in an
+ -- active M+ (EllesmereUI.lua:1519), which is precisely when a user would
+ -- hit them and see nothing happen.
+ local function Complain(msg)
+ if _G.UIErrorsFrame then
+ UIErrorsFrame:AddMessage(msg, 1.0, 0.3, 0.3, 1.0)
+ else
+ EllesmereUI.Print("|cff0cd29fAction Palette:|r " .. msg)
+ end
+ end
+
+ -- What the user calls the action a chord is currently bound to. The binding
+ -- system stores an action string; BINDING_NAME_ is the label every
+ -- keybind UI shows, and our own palettes declare one each.
+ local function BindingLabel(action)
+ return _G["BINDING_NAME_" .. action] or action
+ end
+
+ -- Both halves of a commit, past the point of asking. Split out so the
+ -- confirmation below can defer exactly this and nothing else. stolenFrom is
+ -- the action the chord was taken from, for the record it leaves in chat --
+ -- which is what the no-dialog fallback below relies on, and is still worth
+ -- having once the dialog has been agreed to.
+ local function ApplyKey(palette, chord, stolenFrom)
+ local action = BINDING_PREFIX .. palette
+ local oldK1, oldK2 = GetBindingKey(action)
+
+ if chord then
+ -- Replace the PRIMARY key only and leave a secondary binding
+ -- alone. Blizzard's own panel allows two keys per action, and
+ -- clearing both here silently destroyed the second one with no
+ -- message (and no way to see it, since the label shows key1).
+ if oldK1 then SetBinding(oldK1, nil) end
+ if not SetBinding(chord, action) then
+ -- Put the primary back. oldK2 was never cleared, so there is
+ -- nothing to restore for it.
+ if oldK1 then SetBinding(oldK1, action) end
+ Complain("Action Palette: " .. (GetBindingText(chord) or chord)
+ .. " could not be bound.")
+ elseif stolenFrom then
+ EllesmereUI.Print("|cff0cd29fAction Palette:|r took "
+ .. (GetBindingText(chord) or chord) .. " from |cffffd100"
+ .. BindingLabel(stolenFrom) .. "|r.")
+ end
+ else
+ -- Unbind: this one clears every key the palette holds, which is what
+ -- "unbind" means.
+ if oldK1 then SetBinding(oldK1, nil) end
+ if oldK2 then SetBinding(oldK2, nil) end
+ end
+
+ SaveBindings(GetCurrentBindingSet())
+ RebuildPage()
+ end
+
+ -- chord = a binding string to assign, or nil to leave the palette unbound.
+ local function CommitKey(chord)
+ local palette = listenPalette
+ StopListening()
+ if not palette then return end
+
+ -- SaveBindings raises "can't be done in combat" and SetBinding would
+ -- then be half-applied, so nothing is touched until combat drops.
+ if InCombatLockdown() then
+ Complain("Action Palette: keybinds can't be changed in combat.")
+ RebuildPage()
+ return
+ end
+
+ local action = BINDING_PREFIX .. palette
+ -- Only a real theft is worth either a dialog or a notice: an unbound
+ -- chord takes nothing, and rebinding a palette to the key it already
+ -- holds takes it from itself. A key held by ANOTHER palette of ours
+ -- does count -- that one would be left with no way to open at all.
+ local stolenFrom = chord and GetBindingAction(chord) or nil
+ if stolenFrom == "" or stolenFrom == action then stolenFrom = nil end
+
+ -- SetBinding takes a key from whatever holds it without a word, and the
+ -- displaced binding is often something the user cares about and will
+ -- not miss until they reach for it mid-fight. Asking first is the only
+ -- point at which they can still say no -- a message after the fact
+ -- tells them what to go and put back, which is not the same thing.
+ if stolenFrom and EllesmereUI.ShowConfirmPopup then
+ local keyText = GetBindingText(chord) or chord
+ EllesmereUI:ShowConfirmPopup({
+ title = "Key Already Bound",
+ message = keyText .. " is bound to \""
+ .. BindingLabel(stolenFrom) .. "\". Binding it to \""
+ .. BindingLabel(action) .. "\" will leave that unbound.",
+ confirmText = "Rebind",
+ cancelText = "Keep",
+ -- Re-checked rather than trusted: the dialog sits open for as
+ -- long as the user leaves it, and a fight can start under it.
+ onConfirm = function()
+ if InCombatLockdown() then
+ Complain("Action Palette: keybinds can't be changed in combat.")
+ RebuildPage()
+ return
+ end
+ ApplyKey(palette, chord, stolenFrom)
+ end,
+ -- The keybind button's label was drawn as "Press a key..." for
+ -- the length of the listen; the rebuild puts the old key back
+ -- on it, so declining reads as nothing having happened.
+ onCancel = function() RebuildPage() end,
+ })
+ return
+ end
+
+ ApplyKey(palette, chord, stolenFrom)
+ end
+
+ local function EnsureCaptureFrame()
+ if captureFrame then return captureFrame end
+
+ local f = CreateFrame("Frame", nil, UIParent)
+ f:SetAllPoints(UIParent)
+ f:SetFrameStrata("FULLSCREEN_DIALOG")
+ -- Explicit high level: the suite has other FULLSCREEN_DIALOG frames
+ -- (EllesmereUI.lua:1008, :6139, :11113) that would otherwise cover the
+ -- prompt text.
+ f:SetFrameLevel(1000)
+ f:EnableMouse(true)
+ f:EnableMouseWheel(true)
+ f:Hide()
+
+ local bg = f:CreateTexture(nil, "BACKGROUND")
+ bg:SetAllPoints()
+ bg:SetColorTexture(0, 0, 0, 0.45)
+
+ f.msg = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
+ f.msg:SetPoint("CENTER")
+ f.msg:SetJustifyH("CENTER")
+
+ f:SetScript("OnKeyDown", function(self, key)
+ -- Re-asserted per event: propagation is reset by the engine on
+ -- some paths, and if it leaks through, Escape closes the options
+ -- window out from under the picker.
+ self:SetPropagateKeyboardInput(false)
+ key = GetConvertedKeyOrButton(key)
+ if key == "ESCAPE" then
+ StopListening()
+ RebuildPage()
+ elseif key == "DELETE" or key == "BACKSPACE" then
+ CommitKey(nil)
+ elseif not IsKeyPressIgnoredForBinding(key) then
+ CommitKey(CreateKeyChordStringUsingMetaKeyState(key))
+ end
+ end)
+
+ f:SetScript("OnMouseDown", function(self, button)
+ local key = GetConvertedKeyOrButton(button)
+ -- A BARE left/right click cancels -- those two are needed to
+ -- operate the UI at all, and it doubles as
+ -- click-outside-to-dismiss. Held with a modifier they are
+ -- ordinary bindable chords (ALT-BUTTON1 and friends), so the
+ -- cancel only applies when no modifier is down.
+ local modified = IsAltKeyDown() or IsControlKeyDown()
+ or IsShiftKeyDown() or (IsMetaKeyDown and IsMetaKeyDown())
+ if (key == "BUTTON1" or key == "BUTTON2") and not modified then
+ StopListening()
+ RebuildPage()
+ else
+ CommitKey(CreateKeyChordStringUsingMetaKeyState(key))
+ end
+ end)
+
+ f:SetScript("OnMouseWheel", function(self, delta)
+ CommitKey(CreateKeyChordStringUsingMetaKeyState(
+ delta > 0 and "MOUSEWHEELUP" or "MOUSEWHEELDOWN"))
+ end)
+
+ captureFrame = f
+ return f
+ end
+
+ local function StartListening(palette)
+ if InCombatLockdown() then
+ Complain("Action Palette: keybinds can't be changed in combat.")
+ return
+ end
+ listenPalette = palette
+
+ local f = EnsureCaptureFrame()
+ f.msg:SetText("Press a key or mouse button for |cff0cd29fAction Palette "
+ .. palette .. "|r\n|cff888888Modifiers work with any button "
+ .. "(Alt + Left Click, Shift + Wheel, ...)\n"
+ .. "Esc cancels \194\183 Delete unbinds"
+ .. " \194\183 plain left/right click cancels|r")
+ f:EnableKeyboard(true)
+ f:SetPropagateKeyboardInput(false)
+ f:Show()
+ RebuildPage()
+ end
+
+ -- The picker holds a modal overlay, so it has to come down when the user
+ -- closes the options window -- a /reload never fires OnHide, and the listen
+ -- state is not persisted.
+ local function _installPickerAutoOff()
+ local mf = _G.EllesmereUIFrame
+ if not mf or mf._eRWEditorHook then return end
+ mf._eRWEditorHook = true
+ mf:HookScript("OnHide", function()
+ -- The page's "Press a key..." label is a literal baked in at build
+ -- time, and reopening the window re-shows the CACHED page without
+ -- rebuilding -- so aborting a listen this way has to drop the
+ -- cache, or the button reads "Press a key..." forever. This path is
+ -- not hypothetical: EllesmereUI hides the window on
+ -- PLAYER_REGEN_DISABLED, so entering combat mid-listen lands here.
+ if StopListening() and EllesmereUI.InvalidatePageCache then
+ EllesmereUI:InvalidatePageCache()
+ end
+ end)
+ end
+
+ -- Keys can also change from Blizzard's Keybindings page or another addon.
+ -- The label is built from GetBindingKey, so without this the cached page
+ -- keeps showing the old key -- which would make the "one source of truth"
+ -- design only half-true in practice.
+ --
+ -- Signature-guarded, and that guard is not an optimisation: UPDATE_BINDINGS
+ -- fires on every override-binding registration anywhere in the suite (the
+ -- palette's own UpdateBindings included), and dropping the whole options
+ -- page cache on each of those would rebuild the visible page over and
+ -- over. Only a change to one of OUR keys is of any interest here.
+ --
+ -- The watcher serves the page's keybind rows and nothing else, so it is
+ -- created the first time the page is built rather than at login: a session
+ -- that never opens this page pays no frame and no event.
+ local lastKeySig = nil
+ local bindWatcher = nil
+ local function PaletteKeySignature()
+ local sig = ""
+ for i = 1, MAX_PALETTES do
+ local k1, k2 = GetBindingKey(BINDING_PREFIX .. i)
+ sig = sig .. "|" .. (k1 or "") .. "/" .. (k2 or "")
+ end
+ return sig
+ end
+
+ local function EnsureBindWatcher()
+ if bindWatcher then return end
+ lastKeySig = PaletteKeySignature()
+ bindWatcher = CreateFrame("Frame")
+ bindWatcher:RegisterEvent("UPDATE_BINDINGS")
+ bindWatcher:SetScript("OnEvent", function()
+ local sig = PaletteKeySignature()
+ if sig == lastKeySig then return end
+ lastKeySig = sig
+ if EllesmereUI.InvalidatePageCache then EllesmereUI:InvalidatePageCache() end
+ if EllesmereUI.IsShown and EllesmereUI:IsShown() and EllesmereUI.RefreshPage then
+ EllesmereUI:RefreshPage(true)
+ end
+ end)
+ end
+
+ ---------------------------------------------------------------------------
+ -- Palette preview
+ --
+ -- The live palette's own renderer (ns.CreatePaletteView), scaled down to fit the
+ -- panel and made interactive: drop an action on the trailing "+" to append
+ -- it, drag icons between entries to reorder, right-click to remove. Because
+ -- it is the same renderer, the arrangement here is literally the one the
+ -- user steers at in play.
+ --
+ -- Cached, not rebuilt. BuildPage runs on every RebuildPage and WoW frames
+ -- are never collected, so re-creating thirteen of them per rebuild would
+ -- leak. The page WRAPPER is discarded on rebuild though, so the block is
+ -- re-parented and re-anchored on every build.
+ ---------------------------------------------------------------------------
+ local PREVIEW_H = 280 -- height the block claims in the page layout
+ -- Largest radius + iconSize the block can hold. Half of PREVIEW_H less a
+ -- margin: the preview turns slot labels off, so the palette only has to clear
+ -- the block's own edges rather than leave room for captions under it.
+ local PREVIEW_SPAN = 124
+ local previewBlock, previewView
+
+ -- Half-extent the block can give a layout on one axis. The block is as wide
+ -- as the panel's content and only PREVIEW_H tall, so the two budgets differ
+ -- by a wide margin and every layout has to be measured against both.
+ local function BlockSpan(vertical)
+ if vertical then return PREVIEW_H * 0.5 - 24 end
+ local w = previewBlock and previewBlock:GetWidth() or 0
+ -- The block is anchored on both sides, so its width is unresolved on
+ -- the very first build. Layout re-runs on every Refresh, so a fallback
+ -- here corrects itself rather than sticking.
+ if w < 100 then w = 460 end
+ return w * 0.5 - 24
+ end
+
+ -- How far the preview may magnify a layout that does not fill the block on
+ -- its own. Fitting used to be a shrink-only rule, which left a short strip
+ -- or a small grid as a row of thumbnails in the middle of a 280px block
+ -- while the arc -- whose radius alone nearly fills it -- previewed at very
+ -- nearly its real size. Growing is capped rather than free: this is still a
+ -- preview of a palette the user steers at some other size, and a two-entry
+ -- strip blown up to fill the block would misrepresent it.
+ local PREVIEW_MAX_ZOOM = 2.0
+
+ -- How many entries the preview will actually place, which is what the fit
+ -- has to be measured over. The trailing "+" counts only while the palette
+ -- has room for another action -- the view stops drawing it at MAX_SLOTS
+ -- (see the shownCount it works out in Layout) -- and counting it anyway
+ -- fitted a full palette for one entry more than it shows, which drew it
+ -- smaller than the block allows.
+ local function PreviewEntryCount(palette)
+ local n = palette and #palette.slots or 0
+ if n < MAX_SLOTS then n = n + 1 end
+ return n
+ end
+
+ -- Fit to the block, don't crop: the live radius reaches 220, which is wider
+ -- than the panel, and a two-entry strip is far narrower than it. Scaling
+ -- radius, icon and dead zone by one factor keeps the proportions the user
+ -- chose, so a tight arc still previews as a tight one.
+ local function PreviewGeom()
+ local radius = Cfg("radius") or 96
+ local iconSize = Cfg("iconSize") or 44
+ local deadZone = Cfg("deadZone") or 24
+ local layout = ACfg("layout") or "ARC"
+ -- Half the width a selected entry reaches, which every budget has to
+ -- leave room for: the preview magnifies whatever the cursor is over.
+ local zoom = Cfg("selectedZoom") or 1.15
+ local k
+ if layout == "GRID" then
+ -- The grid is bounded on BOTH axes, so both budgets have to be met:
+ -- the block's width across the columns, and its height down the rows.
+ local palette = Palette(editPalette)
+ local n = PreviewEntryCount(palette)
+ local cols, rows
+ if previewView then cols, rows = previewView:GridDims() end
+ -- The view answers from its last Layout, so it has nothing to say
+ -- until the first one has run.
+ if not cols or rows < 1 then
+ cols = math.min(ACfg("gridColumns") or 4, n)
+ rows = math.ceil(n / cols)
+ end
+ local pitch = iconSize + (ACfg("fanGap") or 10)
+ k = math.min(PREVIEW_MAX_ZOOM,
+ BlockSpan(false) / (cols * pitch * 0.5 + iconSize * (zoom - 1) * 0.5),
+ BlockSpan(true) / (rows * pitch * 0.5 + iconSize * (zoom - 1) * 0.5))
+ elseif layout ~= "ARC" then
+ -- A fan's extent is the length of the strip rather than a radius, so
+ -- that is what has to be fitted. The preview draws EVERY slot -- an
+ -- editor cannot leave one unreachable -- plus the trailing "+" while
+ -- there is room for one, and nothing is culled, so the reach is
+ -- measured over that whole count.
+ local palette = Palette(editPalette)
+ local n = PreviewEntryCount(palette)
+ local reach
+ if (ACfg("fanInput") or "SCROLL") == "CURSOR" then
+ -- A pointer-steered fan is evenly spaced at full pitch, so its
+ -- reach grows much faster than a coverflow strip's.
+ reach = ns.FanHoverReach(n, iconSize, ACfg("fanGap") or 10)
+ else
+ -- Through the module rather than off the key: with the falloff
+ -- switched off the strip spreads out to full pitch, and a fit
+ -- measured at the stored decay would run it out of the block.
+ reach = ns.FanReach(n, iconSize, ACfg("fanGap") or 10,
+ (ns.FalloffRatios(editPalette)))
+ end
+ local vertical = ACfg("fanOrientation") == "VERTICAL"
+ -- Both axes, not just the one the strip runs along: a short strip
+ -- fits its own length several times over, and without the crossways
+ -- budget it would grow until the icons ran out of the block.
+ k = math.min(PREVIEW_MAX_ZOOM,
+ BlockSpan(vertical) / reach,
+ BlockSpan(not vertical) / (iconSize * 0.5 * zoom))
+ else
+ k = math.min(PREVIEW_MAX_ZOOM, PREVIEW_SPAN / (radius + iconSize))
+ end
+ return radius * k, iconSize * k, deadZone * k
+ end
+
+ -- Manual drag, threshold-based -- the same shape as the Widgets reorder row
+ -- (EllesmereUI_Widgets.lua:5391), because WoW's built-in RegisterForDrag
+ -- threshold is far too large for icons this size.
+ local DRAG_THRESHOLD = 4
+ local dragFrom, dragStartX, dragStartY, dragging, dragTarget
+
+ -- One escape-coded string, not tooltip lines: the panel helper takes a
+ -- single block of text, so the caption and the hints carry their own color
+ -- codes. Gray for what the entry IS, blue for what the mouse can do to it.
+ local function PreviewTooltip(widget)
+ local palette = Palette(editPalette)
+ local slot = palette and palette.slots[widget.index]
+ local text
+ if slot then
+ local _, name = ns.SlotDisplay(slot)
+ local caption
+ if slot.kind == "palette" then
+ -- Capped at what the palette being edited can seat: a lane
+ -- takes a nested palette whole, an arc or a halo its first
+ -- eight -- see NestChildCap.
+ local kids = ns.ChildSlots
+ and ns.ChildSlots(ns.ChildIndex(slot),
+ ns.NestChildCap and ns.NestChildCap(editPalette))
+ caption = ("nested palette, %d %s"):format(
+ kids and #kids or 0,
+ (kids and #kids == 1) and "entry" or "entries")
+ else
+ -- The stored kind strings are one word each; only the marker
+ -- kinds read better with the space put back.
+ caption = (slot.kind == "raidtarget" and "target marker")
+ or (slot.kind == "worldmarker" and "world marker")
+ or (slot.kind == "clearmarkers" and "target markers")
+ or (slot.kind == "cycleraidtarget"
+ and "target marker, next on each press")
+ or (slot.kind == "cycleworldmarker"
+ and "world marker, next on each press")
+ or (slot.kind == "randommount" and "mount")
+ or (slot.kind == "spec" and "specialization")
+ or slot.kind
+ end
+ text = (name or slot.kind)
+ .. "\n|cff999999" .. caption .. "|r"
+ .. "\n|cff66ccffDrag to reorder."
+ .. "\nRight-click to remove."
+ .. "\nDrop an action here to replace it.|r"
+ else
+ text = "Add an action"
+ .. "\n|cff66ccffLeft-click to pick a spell, mount, item, toy, "
+ .. "macro, battle pet or marker from a list."
+ .. "\nYou can also drop an action from the cursor here.|r"
+ end
+ if EllesmereUI.ShowWidgetTooltip then
+ EllesmereUI.ShowWidgetTooltip(widget, text)
+ end
+ end
+
+ local function HidePreviewTooltip()
+ if EllesmereUI.HideWidgetTooltip then EllesmereUI.HideWidgetTooltip() end
+ end
+
+ -- Place whatever is on the cursor. An empty cursor is not an error: a bare
+ -- left-click on a slot should do nothing at all, cursor untouched.
+ local function PreviewPlace(widget)
+ local palette = Palette(editPalette)
+ local slot = palette and ns.SlotFromCursor()
+ if not slot then return end
+ if widget.isPlaceholder then
+ if not ns.AddSlot(palette, slot) then return end
+ else
+ palette.slots[widget.index] = slot
+ end
+ ClearCursor()
+ Refresh()
+ end
+
+ ---------------------------------------------------------------------------
+ -- Action picker
+ --
+ -- Left-clicking the "+" entry with an empty cursor opens a category menu,
+ -- then that category's actions; choosing one appends a slot to the palette
+ -- being edited. Dropping an action from the cursor still works, but it
+ -- cannot be the only route: opening the spellbook closes the options
+ -- window, so there is no way to get a spell onto the cursor while the
+ -- preview is on screen.
+ --
+ -- Shaped after the Cooldown Manager's bar-glow spell picker
+ -- (EUI_CooldownManager_Options.lua:518): one cached anchored menu on
+ -- FULLSCREEN_DIALOG, the dropdown palette, pooled rows inside a capped
+ -- scroll frame, dismissed by a click outside itself.
+ ---------------------------------------------------------------------------
+ local PICK_W = 250
+ local PICK_ROW_H = 26
+ local PICK_MAX_LIST = 286 -- visible list height; a longer list scrolls
+ local PICK_HEAD_H = 24 -- title strip
+ local PICK_NAV_H = 56 -- back row + search box, category views only
+ local QUESTION_MARK = "Interface\\Icons\\INV_Misc_QuestionMark"
+
+ local pickerMenu, pickerAnchor
+
+ -- Category key -> entry array, built on first use of that category and kept.
+ -- The spell and mount lists run to hundreds of rows, so the search box
+ -- filters this cache instead of re-enumerating on every keystroke.
+ local pickerLists = {}
+
+ ---------------------------------------------------------------------------
+ -- Enumeration
+ --
+ -- Strictly read-only, every list. The journals' filter setters
+ -- (C_MountJournal.SetSearch/SetCollectedFilterSetting,
+ -- C_ToyBox.SetFilterString, C_PetJournal.SetSearchFilter, ...) rewrite the
+ -- user's saved Collections filters as a side effect and several are
+ -- protected, so the filtering happens in Lua here. That is also why the
+ -- ID-based getters are preferred: the index-based ones walk the journals'
+ -- currently FILTERED lists, so what the palette offers would otherwise
+ -- depend on what the user last typed in the Collections search box.
+ ---------------------------------------------------------------------------
+ local function SpellEntries()
+ local out, seen = {}, {}
+ local bank = Enum.SpellBookSpellBank.Player
+
+ local function Add(spellID, name, icon)
+ if type(spellID) ~= "number" or seen[spellID] or not name then return end
+ seen[spellID] = true
+ out[#out + 1] = { icon = icon, name = name,
+ slot = { kind = "spell", id = spellID } }
+ end
+
+ -- There is no C_SpellBook.GetNumSpellBookItems in this client. The book
+ -- is described by its skill lines instead, each carrying an offset into
+ -- the item indices and a count -- which is how Blizzard's own spellbook
+ -- walks it (Blizzard_SpellBookCategory.lua:180-218).
+ for line = 1, (C_SpellBook.GetNumSpellBookSkillLines() or 0) do
+ local li = C_SpellBook.GetSpellBookSkillLineInfo(line)
+ -- offSpecID is set on the other specialisation's tabs; those spells
+ -- are listed in the book but cannot be cast.
+ if li and not li.shouldHide and not li.offSpecID then
+ for i = li.itemIndexOffset + 1, li.itemIndexOffset + li.numSpellBookItems do
+ local info = C_SpellBook.GetSpellBookItemInfo(i, bank)
+ -- FutureSpell (a rank not learned yet) falls out here too:
+ -- only Spell and Flyout are handled.
+ if info and not info.isPassive and not info.isOffSpec then
+ if info.itemType == Enum.SpellBookItemType.Spell then
+ -- actionID, not spellID: for a spell actionID is the
+ -- BASE id while spellID is whatever override is up
+ -- right now, and the base is what the game resolves
+ -- overrides from when the button fires.
+ Add(info.actionID, info.name, info.iconID)
+ elseif info.itemType == Enum.SpellBookItemType.Flyout then
+ -- A flyout is not castable itself, so its known
+ -- slots are offered as individual spells.
+ local numSlots = select(3, GetFlyoutInfo(info.actionID))
+ for s = 1, (numSlots or 0) do
+ local spellID, _, isKnown, spellName =
+ GetFlyoutSlotInfo(info.actionID, s)
+ if isKnown then
+ Add(spellID, spellName, C_Spell.GetSpellTexture(spellID))
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ return out
+ end
+
+ local function MountEntries()
+ local out = {}
+ -- The roll the Mount Journal's own button makes, offered ahead of the
+ -- collection itself: it is the entry most palettes want, and hunting
+ -- for it among several hundred mounts sorted by name is not a search
+ -- anyone can make. pin is what holds it there past the sort.
+ do
+ local slot = { kind = "randommount" }
+ local icon, name = ns.SlotDisplay(slot)
+ out[1] = { icon = icon, name = name, slot = slot, pin = true }
+ end
+ for _, mountID in ipairs(C_MountJournal.GetMountIDs()) do
+ local name, spellID, icon, _, isUsable, _, _, _, _, hideOnChar, isCollected =
+ C_MountJournal.GetMountInfoByID(mountID)
+ -- isUsable here is a character capability (riding skill, faction,
+ -- class), not a "can you mount right now" -- that one is
+ -- GetMountUsabilityByID. So an unusable mount is one this character
+ -- can never summon, and offering it would be a dead slot.
+ if name and isCollected and isUsable and not hideOnChar then
+ out[#out + 1] = { icon = icon, name = name,
+ -- spellID is banked at pickup time because ResolveAction
+ -- needs the summon spell and it is already in hand here.
+ slot = { kind = "mount", id = mountID, spellID = spellID, name = name } }
+ end
+ end
+ return out
+ end
+
+ local function ItemEntries()
+ local out, seen = {}, {}
+ local function ScanBag(bag)
+ for slot = 1, C_Container.GetContainerNumSlots(bag) do
+ local info = C_Container.GetContainerItemInfo(bag, slot)
+ local itemID = info and info.itemID
+ -- Only items with a use effect. The palette fires "/use item:",
+ -- so an item without one is a slot that can never do anything.
+ if itemID and not seen[itemID] and info.itemName
+ and C_Item.GetItemSpell(itemID) then
+ seen[itemID] = true
+ out[#out + 1] = { icon = info.iconFileID, name = info.itemName,
+ slot = { kind = "item", id = itemID, name = info.itemName } }
+ end
+ end
+ end
+ for bag = Enum.BagIndex.Backpack, NUM_BAG_SLOTS do ScanBag(bag) end
+ -- The reagent bag is outside that range and easy to forget, but it is
+ -- where a lot of players keep their potions and phials -- exactly the
+ -- items someone puts on a palette.
+ ScanBag(Enum.BagIndex.ReagentBag)
+ return out
+ end
+
+ local function ToyEntries()
+ local out = {}
+ -- The toy box has no ID-based enumeration, only this filtered walk, so
+ -- this one list does follow the user's Collections filters. Narrowing it
+ -- back out would mean calling C_ToyBox.SetFilterString/SetCollectedShown,
+ -- which changes what they see in Collections -- not worth it.
+ for i = 1, (C_ToyBox.GetNumFilteredToys() or 0) do
+ local itemID = C_ToyBox.GetToyFromIndex(i)
+ if itemID and itemID > 0 and PlayerHasToy(itemID) then
+ local _, name, icon = C_ToyBox.GetToyInfo(itemID)
+ if name then
+ out[#out + 1] = { icon = icon, name = name,
+ slot = { kind = "toy", id = itemID, name = name } }
+ end
+ end
+ end
+ return out
+ end
+
+ local function MacroEntries()
+ local out = {}
+ local numAccount, numChar = GetNumMacros()
+ -- Two separate blocks: the per-character macros start at a fixed offset
+ -- rather than continuing from the account ones
+ -- (Blizzard_MacroUI.lua:155).
+ local function AddBlock(first, count)
+ for i = first, first + (count or 0) - 1 do
+ local name, icon = GetMacroInfo(i)
+ if name then
+ -- Stored by name, matching SlotFromCursor: the index moves
+ -- when the macro list is reordered, the name does not.
+ out[#out + 1] = { icon = icon, name = name,
+ slot = { kind = "macro", name = name } }
+ end
+ end
+ end
+ AddBlock(1, numAccount)
+ AddBlock(MAX_ACCOUNT_MACROS + 1, numChar)
+ return out
+ end
+
+ local function PetEntries()
+ local out = {}
+ for _, guid in ipairs(C_PetJournal.GetOwnedPetIDs()) do
+ local info = C_PetJournal.GetPetInfoTableByPetID(guid)
+ local name = info and (info.customName or info.name)
+ if name then
+ out[#out + 1] = { icon = info.icon, name = name,
+ slot = { kind = "battlepet", guid = guid, name = name } }
+ end
+ end
+ return out
+ end
+
+ -- This character's own specializations, in the order the game lists them.
+ -- The slot banks the specID rather than the index -- the module resolves
+ -- the index back at fire time, so the same palette carried to an alt
+ -- points at nothing instead of at somebody else's spec.
+ local function SpecEntries()
+ local out = {}
+ local classID = select(3, UnitClass("player"))
+ if not classID or not C_SpecializationInfo then return out end
+ for i = 1, (C_SpecializationInfo.GetNumSpecializationsForClassID(classID) or 0) do
+ local specID, name, _, icon = C_SpecializationInfo.GetSpecializationInfo(i)
+ if specID and name then
+ out[#out + 1] = { icon = icon, name = name,
+ slot = { kind = "spec", specID = specID, name = name } }
+ end
+ end
+ return out
+ end
+
+ -- The markers need no enumeration at all: the slot kinds carry the icon
+ -- and the name, so a candidate slot handed to SlotDisplay IS the entry.
+ --
+ -- One row per marker per set is twenty-one rows, and the two sets are the
+ -- one thing a user has already decided between before opening the menu --
+ -- so Markers holds two lists rather than one, and neither is long enough to
+ -- scroll. See the subs field on its category below.
+ local function MarkerAdder(out)
+ return function(kind, id, name)
+ local slot = { kind = kind, id = id }
+ local icon, drawn = ns.SlotDisplay(slot)
+ out[#out + 1] = { icon = icon, name = name or drawn, slot = slot }
+ end
+ end
+
+ -- The cyclers lead each list: one entry that steps through all eight is the
+ -- reason to come here at all on a palette with no room for nine, and
+ -- burying it under the eight it replaces hides it from exactly the person
+ -- who wants it. Deliberately NOT in the two marker presets -- a preset that
+ -- lays out all eight has nothing to cycle.
+ --
+ -- Each is named without the marker SlotDisplay appends. On a placed entry
+ -- that suffix says which one is up next, which is the point of it; on a
+ -- menu row it would read as the one marker this row places.
+ local function TargetMarkerEntries()
+ local out = {}
+ local Add = MarkerAdder(out)
+ Add("cycleraidtarget", nil, "Cycle Target Markers")
+ for i = 1, 8 do Add("raidtarget", i) end
+ Add("raidtarget", 0)
+ -- The all-units counterpart of the /tm 0 row above; the world markers
+ -- need none because their 0 row already clears them all.
+ Add("clearmarkers")
+ return out
+ end
+
+ local function WorldMarkerEntries()
+ local out = {}
+ local Add = MarkerAdder(out)
+ Add("cycleworldmarker", nil, "Cycle World Markers")
+ for i = 1, 8 do Add("worldmarker", i) end
+ Add("worldmarker", 0)
+ return out
+ end
+
+ -- Every palette this one may open. Not a list of things the game owns, so
+ -- it is rebuilt on each use rather than cached: adding a palette or filling
+ -- one in has to show up here without reopening the picker.
+ --
+ -- ns.CanNest does the refusing, and refuses more than the obvious: a palette
+ -- inside itself, and any chain that would close a loop -- A holding B
+ -- holding A -- which would send every push and every draw round until the
+ -- client gave out.
+ local function PaletteEntries()
+ local out = {}
+ for i = 1, PaletteCount() do
+ if i ~= editPalette and ns.CanNest and ns.CanNest(editPalette, i) then
+ local palette = Palette(i)
+ local name = (palette and palette.name) or AutoName(i)
+ local count = palette and #palette.slots or 0
+ -- Same order SlotDisplay uses: the palette's own icon, then its
+ -- first entry so it looks like what it holds.
+ local icon = palette and palette.icon
+ local first = palette and palette.slots[1]
+ if not icon and first and first.kind ~= "palette" then
+ icon = ns.SlotDisplay(first)
+ end
+ out[#out + 1] = {
+ icon = icon or "Interface\\Icons\\INV_Misc_Bag_08",
+ name = (count > 0) and (name .. " |cff808080(" .. count .. ")|r")
+ or (name .. " |cff808080(empty)|r"),
+ -- No name on the slot: SlotDisplay reads the palette's own,
+ -- so renaming the palette renames the entry that opens it.
+ slot = { kind = "palette", palette = i },
+ }
+ end
+ end
+ return out
+ end
+
+ -- custom = the typed-macro pane rather than a list of things to enumerate.
+ -- subs = the row opens a second list of categories rather than a list of
+ -- entries, for a category whose contents divide the way the user already
+ -- has in their head before they open it.
+ local PICKER_CATEGORIES = {
+ { key = "spell", label = "Spells", build = SpellEntries },
+ { key = "mount", label = "Mounts", build = MountEntries },
+ { key = "item", label = "Items", build = ItemEntries },
+ { key = "toy", label = "Toys", build = ToyEntries },
+ { key = "macro", label = "Macros", build = MacroEntries },
+ { key = "battlepet", label = "Battle Pets", build = PetEntries },
+ -- keepOrder on both: the markers run star to skull, the order every
+ -- marker menu in the game shows.
+ { key = "marker", label = "Markers", subs = {
+ { key = "marker_target", label = "Target Markers",
+ build = TargetMarkerEntries, keepOrder = true },
+ { key = "marker_world", label = "World Markers",
+ build = WorldMarkerEntries, keepOrder = true },
+ } },
+ -- keepOrder: the game lists a class's specs in one fixed order that
+ -- every character sheet shows, and alphabetising them would be the
+ -- one place in the interface they are not in it.
+ { key = "spec", label = "Specializations", build = SpecEntries,
+ keepOrder = true },
+ { key = "palette", label = "Palettes", build = PaletteEntries },
+ { key = "macrotext", label = "Custom Macro...", custom = true },
+ }
+
+ -- Which list the Back row goes home to, filled in here because a table
+ -- literal cannot name itself. A category with no parent is at the root.
+ for _, cat in ipairs(PICKER_CATEGORIES) do
+ for _, sub in ipairs(cat.subs or {}) do sub.parent = cat end
+ end
+
+ local ShowPickerCategories, ShowPickerCategory, ShowPickerCustom
+
+ local function HidePicker()
+ if pickerMenu then pickerMenu:Hide() end
+ end
+
+ -- Returns true when a slot was actually appended.
+ local function AssignEntry(entry)
+ local palette = Palette(editPalette)
+ -- The stored slot is a COPY: the entry belongs to the cached list, and
+ -- handing that one table to two entries would alias a single saved slot
+ -- into both of them.
+ local slot = {}
+ for k, v in pairs(entry.slot) do slot[k] = v end
+ -- ns.AddSlot refuses a full palette. Unreachable from here -- a full palette
+ -- draws no "+" to click -- but the refusal must not fall through into a
+ -- Refresh that has nothing to show.
+ if not (palette and ns.AddSlot(palette, slot)) then return false end
+ HidePicker()
+ Refresh()
+ return true
+ end
+
+ local function EnsurePickerMenu()
+ if pickerMenu then return pickerMenu end
+
+ local FONT = (EllesmereUI.GetFontPath and EllesmereUI.GetFontPath())
+ or "Interface\\AddOns\\EllesmereUI\\media\\fonts\\Expressway.TTF"
+ local bgR = EllesmereUI.DD_BG_R or 0.075
+ local bgG = EllesmereUI.DD_BG_G or 0.113
+ local bgB = EllesmereUI.DD_BG_B or 0.141
+ local bgA = EllesmereUI.DD_BG_HA or 0.98
+ local brdA = EllesmereUI.DD_BRD_A or 0.20
+ local hlA = EllesmereUI.DD_ITEM_HL_A or 0.08
+ local tR = EllesmereUI.TEXT_DIM_R or 0.7
+ local tG = EllesmereUI.TEXT_DIM_G or 0.7
+ local tB = EllesmereUI.TEXT_DIM_B or 0.7
+ local tA = EllesmereUI.TEXT_DIM_A or 0.85
+ local ACCENT = EllesmereUI.ELLESMERE_GREEN or { r = 0.047, g = 0.824, b = 0.624 }
+
+ local menu = CreateFrame("Frame", nil, UIParent)
+ -- The options window is on DIALOG (EllesmereUI.lua:7126), so the menu
+ -- has to sit a strata above it or the panel draws over it.
+ menu:SetFrameStrata("FULLSCREEN_DIALOG")
+ menu:SetFrameLevel(300)
+ menu:SetClampedToScreen(true)
+ menu:SetSize(PICK_W, 10)
+ menu:EnableMouse(true)
+ menu:Hide()
+
+ local mbg = menu:CreateTexture(nil, "BACKGROUND")
+ mbg:SetAllPoints()
+ mbg:SetColorTexture(bgR, bgG, bgB, bgA)
+ EllesmereUI.MakeBorder(menu, 1, 1, 1, brdA, EllesmereUI.PP)
+
+ menu.title = EllesmereUI.MakeFont(menu, 12, nil, 1, 1, 1, 0.9)
+ menu.title:SetPoint("TOPLEFT", menu, "TOPLEFT", 8, -7)
+
+ menu.back = CreateFrame("Button", nil, menu)
+ menu.back:SetHeight(PICK_ROW_H)
+ menu.back:SetPoint("TOPLEFT", menu, "TOPLEFT", 1, -PICK_HEAD_H)
+ menu.back:SetPoint("TOPRIGHT", menu, "TOPRIGHT", -1, -PICK_HEAD_H)
+ menu.back:SetFrameLevel(menu:GetFrameLevel() + 2)
+ local bHl = menu.back:CreateTexture(nil, "ARTWORK")
+ bHl:SetAllPoints()
+ bHl:SetColorTexture(1, 1, 1, 0)
+ local bTx = EllesmereUI.MakeFont(menu.back, 11, nil, tR, tG, tB, tA)
+ bTx:SetPoint("LEFT", menu.back, "LEFT", 8, 0)
+ bTx:SetText("\194\171 Categories")
+ -- Named, because a sub-category's entries go back to the sub-list
+ -- rather than to the root and the row has to say which.
+ menu.backText = bTx
+ menu.back:SetScript("OnEnter", function()
+ bHl:SetColorTexture(1, 1, 1, hlA); bTx:SetTextColor(1, 1, 1, 1)
+ end)
+ menu.back:SetScript("OnLeave", function()
+ bHl:SetColorTexture(1, 1, 1, 0); bTx:SetTextColor(tR, tG, tB, tA)
+ end)
+ -- One step up from wherever we are: the entries of a sub-category go to
+ -- its own list, everything else to the root. menu.cat is nil in both
+ -- list views, so a sub-list's own Back lands at the root, which is the
+ -- only place left to go.
+ menu.back:SetScript("OnClick", function()
+ ShowPickerCategories(menu.cat and menu.cat.parent or nil)
+ end)
+
+ menu.search = CreateFrame("EditBox", nil, menu)
+ menu.search:SetHeight(22)
+ menu.search:SetPoint("TOPLEFT", menu.back, "BOTTOMLEFT", 7, -4)
+ menu.search:SetPoint("TOPRIGHT", menu.back, "BOTTOMRIGHT", -7, -4)
+ menu.search:SetFrameLevel(menu:GetFrameLevel() + 2)
+ menu.search:SetFont(FONT, 11, "")
+ menu.search:SetTextColor(1, 1, 1, 0.9)
+ menu.search:SetAutoFocus(false)
+ menu.search:SetMaxLetters(40)
+ menu.search:SetTextInsets(6, 6, 0, 0)
+ local sBg = menu.search:CreateTexture(nil, "BACKGROUND")
+ sBg:SetAllPoints()
+ sBg:SetColorTexture(0, 0, 0, 0.4)
+ EllesmereUI.MakeBorder(menu.search, 1, 1, 1, 0.10, EllesmereUI.PP)
+ menu.searchPH = menu.search:CreateFontString(nil, "OVERLAY")
+ menu.searchPH:SetFont(FONT, 11, "")
+ menu.searchPH:SetTextColor(0.5, 0.5, 0.5, 0.6)
+ menu.searchPH:SetPoint("LEFT", menu.search, "LEFT", 6, 0)
+ menu.searchPH:SetText("Search...")
+ menu.search:SetScript("OnEscapePressed", function(s) s:ClearFocus() end)
+ menu.search:SetScript("OnEnterPressed", function(s) s:ClearFocus() end)
+ menu.search:SetScript("OnTextChanged", function(s)
+ menu.searchPH:SetShown((s:GetText() or "") == "")
+ -- Re-filters the cached list; ShowPickerCategory never clears the box.
+ if menu.cat then ShowPickerCategory(menu.cat) end
+ end)
+
+ menu.scroll = CreateFrame("ScrollFrame", nil, menu)
+ menu.scroll:SetFrameLevel(menu:GetFrameLevel() + 1)
+ menu.scroll:EnableMouseWheel(true)
+ menu.list = CreateFrame("Frame", nil, menu.scroll)
+ menu.list:SetWidth(PICK_W - 2)
+ menu.scroll:SetScrollChild(menu.list)
+ -- Thin track and thumb, as on the CDM source picker
+ -- (EUI_CooldownManager_Options.lua:16016): a capped 250px menu holding a
+ -- few hundred mounts gives no other sign that it scrolls.
+ menu.track = menu.scroll:CreateTexture(nil, "ARTWORK")
+ menu.track:SetWidth(3)
+ menu.track:SetColorTexture(1, 1, 1, 0.06)
+ menu.track:SetPoint("TOPRIGHT", menu.scroll, "TOPRIGHT", -1, 0)
+ menu.track:SetPoint("BOTTOMRIGHT", menu.scroll, "BOTTOMRIGHT", -1, 0)
+ menu.track:Hide()
+ menu.thumb = menu.scroll:CreateTexture(nil, "OVERLAY")
+ menu.thumb:SetWidth(3)
+ menu.thumb:SetColorTexture(1, 1, 1, 0.25)
+ menu.thumb:Hide()
+
+ function menu:UpdateThumb()
+ local visH, fullH = self.scroll:GetHeight(), self.list:GetHeight()
+ local maxScroll = math.max(0, fullH - visH)
+ if maxScroll <= 0 then
+ self.track:Hide()
+ self.thumb:Hide()
+ return
+ end
+ self.track:Show()
+ self.thumb:Show()
+ local thumbH = math.max(20, visH * visH / fullH)
+ self.thumb:SetHeight(thumbH)
+ local frac = (self.scroll:GetVerticalScroll() or 0) / maxScroll
+ self.thumb:ClearAllPoints()
+ self.thumb:SetPoint("TOPRIGHT", self.track, "TOPRIGHT", 0,
+ -frac * (visH - thumbH))
+ end
+
+ menu.scroll:SetScript("OnMouseWheel", function(self, delta)
+ local maxScroll = math.max(0, menu.list:GetHeight() - self:GetHeight())
+ if maxScroll <= 0 then return end
+ self:SetVerticalScroll(math.max(0, math.min(maxScroll,
+ (self:GetVerticalScroll() or 0) - delta * PICK_ROW_H * 2)))
+ menu:UpdateThumb()
+ end)
+
+ -- Pooled rows, re-labelled per view: switching category and every
+ -- keystroke in the search box re-runs the populate.
+ menu.rows = {}
+ function menu:GetRow(i)
+ local r = self.rows[i]
+ if r then return r end
+ r = CreateFrame("Button", nil, self.list)
+ r:SetHeight(PICK_ROW_H)
+ r:SetPoint("TOPLEFT", self.list, "TOPLEFT", 0, -(i - 1) * PICK_ROW_H)
+ r:SetPoint("TOPRIGHT", self.list, "TOPRIGHT", 0, -(i - 1) * PICK_ROW_H)
+ r:SetFrameLevel(self:GetFrameLevel() + 2)
+ r.hl = r:CreateTexture(nil, "ARTWORK")
+ r.hl:SetAllPoints()
+ r.hl:SetColorTexture(1, 1, 1, 0)
+ r.icon = r:CreateTexture(nil, "ARTWORK")
+ r.icon:SetSize(PICK_ROW_H - 6, PICK_ROW_H - 6)
+ r.icon:SetPoint("LEFT", r, "LEFT", 6, 0)
+ r.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
+ r.label = EllesmereUI.MakeFont(r, 11, nil, tR, tG, tB, tA)
+ r.label:SetJustifyH("LEFT")
+ r.label:SetWordWrap(false)
+ r.label:SetMaxLines(1)
+ r:SetScript("OnEnter", function(s)
+ s.hl:SetColorTexture(1, 1, 1, hlA); s.label:SetTextColor(1, 1, 1, 1)
+ end)
+ r:SetScript("OnLeave", function(s)
+ s.hl:SetColorTexture(1, 1, 1, 0); s.label:SetTextColor(tR, tG, tB, tA)
+ end)
+ self.rows[i] = r
+ return r
+ end
+
+ -----------------------------------------------------------------------
+ -- Custom Macro pane. Typed macro text, stored as a macrotext slot --
+ -- the one kind that has nothing to enumerate.
+ -----------------------------------------------------------------------
+ local custom = CreateFrame("Frame", nil, menu)
+ custom:SetPoint("TOPLEFT", menu.back, "BOTTOMLEFT", 7, -6)
+ custom:SetPoint("TOPRIGHT", menu.back, "BOTTOMRIGHT", -7, -6)
+ custom:SetHeight(150)
+ custom:SetFrameLevel(menu:GetFrameLevel() + 2)
+ custom:Hide()
+ menu.custom = custom
+
+ local function MakeInput(parent, height, multiline)
+ local box = CreateFrame("EditBox", nil, parent)
+ box:SetHeight(height)
+ box:SetFrameLevel(parent:GetFrameLevel() + 1)
+ box:SetFont(FONT, 11, "")
+ box:SetTextColor(1, 1, 1, 0.9)
+ box:SetAutoFocus(false)
+ box:SetTextInsets(5, 5, 3, 3)
+ box:SetMultiLine(multiline == true)
+ -- Newlines are part of a macro, so a multiline box must not treat
+ -- Enter as "commit"; the Add button is the only commit.
+ if not multiline then
+ box:SetScript("OnEnterPressed", function(s) s:ClearFocus() end)
+ end
+ box:SetScript("OnEscapePressed", function(s) s:ClearFocus() end)
+ local bg = box:CreateTexture(nil, "BACKGROUND")
+ bg:SetAllPoints()
+ bg:SetColorTexture(0, 0, 0, 0.4)
+ EllesmereUI.MakeBorder(box, 1, 1, 1, 0.10, EllesmereUI.PP)
+ return box
+ end
+
+ local nameLbl = EllesmereUI.MakeFont(custom, 10, nil, tR, tG, tB, tA)
+ nameLbl:SetPoint("TOPLEFT", custom, "TOPLEFT", 1, 0)
+ nameLbl:SetText("Label")
+ local nameBox = MakeInput(custom, 22, false)
+ nameBox:SetPoint("TOPLEFT", nameLbl, "BOTTOMLEFT", 0, -3)
+ nameBox:SetPoint("RIGHT", custom, "RIGHT", -1, 0)
+ nameBox:SetMaxLetters(24)
+
+ local textLbl = EllesmereUI.MakeFont(custom, 10, nil, tR, tG, tB, tA)
+ textLbl:SetPoint("TOPLEFT", nameBox, "BOTTOMLEFT", 0, -8)
+ textLbl:SetText("Macro Text")
+ local textBox = MakeInput(custom, 62, true)
+ textBox:SetPoint("TOPLEFT", textLbl, "BOTTOMLEFT", 0, -3)
+ textBox:SetPoint("RIGHT", custom, "RIGHT", -1, 0)
+ textBox:SetMaxLetters(255)
+
+ local addBtn = CreateFrame("Button", nil, custom)
+ addBtn:SetSize(70, 22)
+ addBtn:SetPoint("TOPRIGHT", textBox, "BOTTOMRIGHT", 0, -8)
+ addBtn:SetFrameLevel(custom:GetFrameLevel() + 1)
+ local aBg = addBtn:CreateTexture(nil, "BACKGROUND")
+ aBg:SetAllPoints()
+ aBg:SetColorTexture(ACCENT.r, ACCENT.g, ACCENT.b, 0.20)
+ EllesmereUI.MakeBorder(addBtn, ACCENT.r, ACCENT.g, ACCENT.b, 0.7, EllesmereUI.PP)
+ local aTx = EllesmereUI.MakeFont(addBtn, 11, nil, 1, 1, 1, 0.9)
+ aTx:SetPoint("CENTER")
+ aTx:SetText("Add")
+ addBtn:SetScript("OnEnter", function()
+ aBg:SetColorTexture(ACCENT.r, ACCENT.g, ACCENT.b, 0.35)
+ end)
+ addBtn:SetScript("OnLeave", function()
+ aBg:SetColorTexture(ACCENT.r, ACCENT.g, ACCENT.b, 0.20)
+ end)
+ addBtn:SetScript("OnClick", function()
+ local body = strtrim(textBox:GetText() or "")
+ if body == "" then return end
+ local label = strtrim(nameBox:GetText() or "")
+ if label == "" then label = "Macro" end
+ -- No icon is stored: SlotDisplay falls back to the question mark,
+ -- which is what an unnamed custom macro looks like on an action bar
+ -- too.
+ if AssignEntry({ slot = { kind = "macrotext", macrotext = body, name = label } }) then
+ nameBox:SetText("")
+ textBox:SetText("")
+ end
+ end)
+ menu:SetScript("OnHide", function(m)
+ m.search:ClearFocus()
+ nameBox:ClearFocus()
+ textBox:ClearFocus()
+ end)
+
+ -- Click-outside dismissal, same test the CDM picker uses. The anchor is
+ -- excluded so the "+" click that toggles the menu shut is not also read
+ -- as a click outside it.
+ menu:SetScript("OnUpdate", function(m)
+ if IsMouseButtonDown("LeftButton") and not m:IsMouseOver()
+ and not (pickerAnchor and pickerAnchor:IsMouseOver()) then
+ m:Hide()
+ end
+ end)
+
+ pickerMenu = menu
+ return menu
+ end
+
+ -- Anchors the list under whichever header rows the current view shows and
+ -- sizes the menu to its content.
+ local function PickerFit(menu, count)
+ -- Measured off the Back row itself rather than off menu.cat: a
+ -- sub-category list shows the row without being an entry view, and
+ -- reserving no space for it would put the first row under it.
+ local top = PICK_HEAD_H + (menu.back:IsShown() and PICK_NAV_H or 0)
+ menu.scroll:ClearAllPoints()
+ menu.scroll:SetPoint("TOPLEFT", menu, "TOPLEFT", 1, -top)
+ menu.scroll:SetPoint("RIGHT", menu, "RIGHT", -1, 0)
+ local listH = math.max(PICK_ROW_H, count * PICK_ROW_H)
+ menu.list:SetHeight(listH)
+ local visH = math.min(listH, PICK_MAX_LIST)
+ menu.scroll:SetHeight(visH)
+ menu:SetHeight(top + visH + 4)
+ menu.scroll:SetVerticalScroll(0)
+ menu:UpdateThumb()
+ end
+
+ -- parent = nil for the root list, or the category whose sub-list to show.
+ ShowPickerCategories = function(parent)
+ local menu = EnsurePickerMenu()
+ local cats = parent and parent.subs or PICKER_CATEGORIES
+ -- No cat: this is a list of categories, and the search box has nothing
+ -- to filter. The Back row is the one thing that differs between the two
+ -- levels -- the root has nowhere above it.
+ menu.cat = nil
+ menu.title:SetText(parent and parent.label or "Add Action")
+ menu.back:SetShown(parent ~= nil)
+ menu.backText:SetText("\194\171 Categories")
+ -- Focus first: hiding a focused edit box leaves the keyboard captured by
+ -- a box that is no longer on screen.
+ menu.search:ClearFocus()
+ menu.search:Hide()
+ menu.custom:Hide()
+ menu.scroll:Show()
+
+ for i, cat in ipairs(cats) do
+ local r = menu:GetRow(i)
+ r.icon:Hide()
+ -- Rows are shared with the entry views, which anchor the label to
+ -- the icon -- so both views re-anchor it every time.
+ r.label:ClearAllPoints()
+ r.label:SetPoint("LEFT", r, "LEFT", 8, 0)
+ r.label:SetPoint("RIGHT", r, "RIGHT", -6, 0)
+ r.label:SetText(cat.label)
+ r:SetScript("OnClick", function()
+ if cat.custom then
+ ShowPickerCustom()
+ elseif cat.subs then
+ ShowPickerCategories(cat)
+ else
+ menu.search:SetText("")
+ ShowPickerCategory(cat)
+ end
+ end)
+ r:Show()
+ end
+ for i = #cats + 1, #menu.rows do menu.rows[i]:Hide() end
+
+ PickerFit(menu, #cats)
+ menu:Show()
+ end
+
+ ShowPickerCategory = function(cat)
+ local menu = EnsurePickerMenu()
+ menu.cat = cat
+ menu.title:SetText(cat.label)
+ menu.back:Show()
+ menu.backText:SetText("\194\171 " .. (cat.parent and cat.parent.label or "Categories"))
+ menu.search:Show()
+ menu.searchPH:SetShown((menu.search:GetText() or "") == "")
+ menu.custom:Hide()
+ menu.scroll:Show()
+
+ local list = pickerLists[cat.key]
+ if not list then
+ list = cat.build()
+ -- Alphabetical rather than in enumeration order: the spellbook's own
+ -- grouping means nothing once the tabs are gone, and a name is what
+ -- the user is scanning for. A category can opt out for a list whose
+ -- own order IS the one the user knows -- the markers run star to
+ -- skull, the order every marker menu in the game shows.
+ -- A pinned entry sorts ahead of every plain one whatever its name.
+ -- Pinning is per entry rather than per category so a list that is
+ -- otherwise alphabetical can still lead with the row the user came
+ -- for.
+ if not cat.keepOrder then
+ table.sort(list, function(a, b)
+ if (a.pin or false) ~= (b.pin or false) then return a.pin == true end
+ return a.name < b.name
+ end)
+ end
+ pickerLists[cat.key] = list
+ end
+
+ local filter = (menu.search:GetText() or ""):lower()
+ local n = 0
+ for _, entry in ipairs(list) do
+ if filter == "" or entry.name:lower():find(filter, 1, true) then
+ n = n + 1
+ local r = menu:GetRow(n)
+ r.icon:SetTexture(entry.icon or QUESTION_MARK)
+ r.icon:Show()
+ r.label:ClearAllPoints()
+ r.label:SetPoint("LEFT", r.icon, "RIGHT", 6, 0)
+ r.label:SetPoint("RIGHT", r, "RIGHT", -6, 0)
+ r.label:SetText(entry.name)
+ r:SetScript("OnClick", function() AssignEntry(entry) end)
+ r:Show()
+ end
+ end
+
+ local shown = n
+ if n == 0 then
+ -- An empty menu reads as broken, so say why it is empty.
+ local r = menu:GetRow(1)
+ r.icon:Hide()
+ r.label:ClearAllPoints()
+ r.label:SetPoint("LEFT", r, "LEFT", 8, 0)
+ r.label:SetPoint("RIGHT", r, "RIGHT", -6, 0)
+ r.label:SetText(filter == "" and "Nothing to add" or "No matches")
+ r:SetScript("OnClick", nil)
+ r:Show()
+ shown = 1
+ end
+ for i = shown + 1, #menu.rows do menu.rows[i]:Hide() end
+
+ PickerFit(menu, shown)
+ menu:Show()
+ end
+
+ ShowPickerCustom = function()
+ local menu = EnsurePickerMenu()
+ -- No cat recorded: menu.cat means "a list view is up, re-filter it on a
+ -- keystroke", and there is no list here.
+ menu.cat = nil
+ menu.title:SetText("Custom Macro")
+ menu.back:Show()
+ -- Restated rather than left as it was: the row is shared, and the view
+ -- before this one may have been a sub-category's.
+ menu.backText:SetText("\194\171 Categories")
+ menu.search:ClearFocus()
+ menu.search:Hide()
+ -- Hiding the scroll frame takes the pooled rows with it: they are its
+ -- scroll child's children.
+ menu.scroll:Hide()
+ menu.custom:Show()
+ menu:SetHeight(PICK_HEAD_H + PICK_ROW_H + 6 + menu.custom:GetHeight() + 4)
+ menu:Show()
+ end
+
+ local function TogglePicker(widget)
+ local menu = EnsurePickerMenu()
+ if menu:IsShown() and pickerAnchor == widget then
+ HidePicker()
+ return
+ end
+ -- Dropped per open, not per category view: bags, macros and collections
+ -- all change while the game is running, and one open is the coarsest
+ -- point at which a rebuild is still free (the keystroke filtering is
+ -- what the cache is there to protect).
+ wipe(pickerLists)
+ pickerAnchor = widget
+ menu:ClearAllPoints()
+ menu:SetPoint("TOP", widget, "BOTTOM", 0, -4)
+ -- Cleared before the SetText below, which fires OnTextChanged: with the
+ -- previous open's category still recorded, that would re-enumerate and
+ -- briefly show the wrong view.
+ menu.cat = nil
+ menu.search:SetText("")
+ ShowPickerCategories()
+ end
+
+ ---------------------------------------------------------------------------
+ -- Palette presets
+ --
+ -- Each builder returns the slots a new palette starts with, worked out
+ -- from what THIS character knows or carries at the moment the chooser
+ -- opens -- a preset must never seed a slot that cannot fire. A preset
+ -- whose builder comes back empty is left out of the chooser, which is
+ -- also how the class-specific ones gate themselves: IsPlayerSpell answers
+ -- no to every spell on the wrong class.
+ ---------------------------------------------------------------------------
+ local function TargetMarkerSlots()
+ local out = {}
+ for i = 1, 8 do out[#out + 1] = { kind = "raidtarget", id = i } end
+ out[#out + 1] = { kind = "raidtarget", id = 0 }
+ return out
+ end
+
+ local function WorldMarkerSlots()
+ local out = {}
+ for i = 1, 8 do out[#out + 1] = { kind = "worldmarker", id = i } end
+ out[#out + 1] = { kind = "worldmarker", id = 0 }
+ return out
+ end
+
+ -- The base item plus its two expansion siblings, then the toy variants.
+ -- The toys all share one cooldown and one destination, so past MAX_SLOTS
+ -- the tail is interchangeable with what already made it in.
+ local HEARTH_ITEMS = { 6948, 140192, 110560 } -- Hearthstone, Dalaran, Garrison
+ local HEARTH_TOYS = {
+ 54452, 64488, 93672, 142542, 162973, 163045, 165669, 165670,
+ 165802, 166746, 166747, 168907, 172179, 180290, 182773, 183716,
+ 184353, 188952, 190196, 190237, 193588, 200630, 206195, 208704,
+ 209035, 212337, 228940,
+ }
+ local function HearthstoneSlots()
+ local out = {}
+ for _, itemID in ipairs(HEARTH_ITEMS) do
+ if C_Item.GetItemCount(itemID) > 0 then
+ out[#out + 1] = { kind = "item", id = itemID }
+ end
+ end
+ for _, toyID in ipairs(HEARTH_TOYS) do
+ if #out >= MAX_SLOTS then break end
+ if PlayerHasToy(toyID) then
+ out[#out + 1] = { kind = "toy", id = toyID }
+ end
+ end
+ return out
+ end
+
+ -- Self-teleports only, keyed by class. Mage portals are deliberately not
+ -- here: a palette entry fires for its owner, and the teleport is the spell
+ -- an owner wants.
+ local TELEPORT_SPELLS = {
+ DEATHKNIGHT = { 50977 }, -- Death Gate
+ DRUID = { 193753 }, -- Dreamwalk
+ MONK = { 126892 }, -- Zen Pilgrimage
+ SHAMAN = { 556 }, -- Astral Recall
+ MAGE = {
+ 3561, 3562, 3563, 3565, 3566, 3567, -- the classic capitals
+ 32271, 32272, 33690, 35715, 49358, 49359, -- Outland-era cities
+ 53140, 88342, 88344, 132621, 132627, -- Dalaran, Tol Barad, Vale
+ 176248, 176242, 224869, 281403, 281404, -- Warspear era through Boralus
+ 344587, 395277, 446540, -- Oribos, Valdrakken, Dornogal
+ },
+ }
+ local function TeleportSlots()
+ local out = {}
+ local list = TELEPORT_SPELLS[select(2, UnitClass("player"))]
+ for _, spellID in ipairs(list or {}) do
+ if #out >= MAX_SLOTS then break end
+ if IsPlayerSpell(spellID) then
+ out[#out + 1] = { kind = "spell", id = spellID }
+ end
+ end
+ return out
+ end
+
+ -- The same bag walk ItemEntries does, narrowed to drinkable consumables.
+ -- Numeric subclasses: Enum.ItemConsumableSubclass stops at Other, the
+ -- finer rows exist only as these constants (ItemDocumentation.lua).
+ local function PotionSlots()
+ local out, seen = {}, {}
+ local function ScanBag(bag)
+ for slot = 1, C_Container.GetContainerNumSlots(bag) do
+ local info = C_Container.GetContainerItemInfo(bag, slot)
+ local itemID = info and info.itemID
+ if itemID and not seen[itemID] and #out < MAX_SLOTS
+ and C_Item.GetItemSpell(itemID) then
+ seen[itemID] = true
+ local classID, subClassID = select(6, C_Item.GetItemInfoInstant(itemID))
+ if classID == Enum.ItemClass.Consumable
+ and (subClassID == 1 or subClassID == 2 or subClassID == 3) then
+ out[#out + 1] = { kind = "item", id = itemID }
+ end
+ end
+ end
+ end
+ for bag = Enum.BagIndex.Backpack, NUM_BAG_SLOTS do ScanBag(bag) end
+ ScanBag(Enum.BagIndex.ReagentBag)
+ return out
+ end
+
+ -- Every form a druid can be in, plus the two that are really ways of
+ -- moving between them. Aquatic and Flight are listed even though Travel
+ -- Form has absorbed both on most builds: IsPlayerSpell is what decides,
+ -- and a spell nobody has simply does not appear.
+ local FORM_SPELLS = {
+ 5487, -- Bear Form
+ 768, -- Cat Form
+ 5215, -- Prowl
+ 783, -- Travel Form
+ 1066, -- Aquatic Form
+ 165962, -- Flight Form
+ 24858, -- Moonkin Form
+ 114282, -- Treant Form
+ }
+ local function FormSlots()
+ local out = {}
+ for _, spellID in ipairs(FORM_SPELLS) do
+ if #out >= MAX_SLOTS then break end
+ if IsPlayerSpell(spellID) then
+ out[#out + 1] = { kind = "spell", id = spellID }
+ end
+ end
+ -- Getting OUT is the half of shapeshifting no form spell covers, and
+ -- there is no spell for it at all -- /cancelform is the whole of it.
+ -- Only offered once a form has been found, so the preset stays hidden
+ -- on a class that has none. The clear icon the marker entries use,
+ -- because it says the same thing.
+ if #out > 0 and #out < MAX_SLOTS then
+ out[#out + 1] = { kind = "macrotext", macrotext = "/cancelform",
+ name = "Cancel Form",
+ icon = "Interface\\Buttons\\UI-GroupLoot-Pass-Up" }
+ end
+ return out
+ end
+
+ -- One-of-a-set toggles that are not forms: a warrior's stances and a
+ -- paladin's auras are the same choice in two costumes, and both are
+ -- ordinary spells.
+ local STANCE_SPELLS = {
+ WARRIOR = { 386164, 386208, 386196 }, -- Battle, Defensive, Berserker
+ PALADIN = { 465, 32223, 183435, 317920 }, -- Devotion, Crusader, Retribution, Concentration
+ }
+ local function StanceSlots()
+ local out = {}
+ for _, spellID in ipairs(STANCE_SPELLS[select(2, UnitClass("player"))] or {}) do
+ if #out >= MAX_SLOTS then break end
+ if IsPlayerSpell(spellID) then
+ out[#out + 1] = { kind = "spell", id = spellID }
+ end
+ end
+ return out
+ end
+
+ -- Every spec this character has. One entry short of useful on a class with
+ -- one spec, so a single-spec character is not offered it.
+ local function SpecSlots()
+ local out = SpecEntries()
+ if #out < 2 then return {} end
+ local slots = {}
+ for i = 1, math.min(MAX_SLOTS, #out) do slots[i] = out[i].slot end
+ return slots
+ end
+
+ local PALETTE_PRESETS = {
+ { label = "Target Markers", build = TargetMarkerSlots },
+ { label = "World Markers", build = WorldMarkerSlots },
+ { label = "Hearthstones", build = HearthstoneSlots },
+ { label = "Teleports", build = TeleportSlots },
+ { label = "Potions", build = PotionSlots },
+ { label = "Druid Forms", build = FormSlots },
+ { label = "Stances", build = StanceSlots },
+ { label = "Specializations", build = SpecSlots },
+ }
+
+ ---------------------------------------------------------------------------
+ -- Adding and deleting palettes
+ --
+ -- The palette count used to be a slider. A slider says nothing about what
+ -- the new palette will hold; the Add button opens a chooser instead --
+ -- empty, or seeded from one of the presets above.
+ ---------------------------------------------------------------------------
+
+ -- slots comes in already built (the chooser built it to decide whether to
+ -- offer the preset at all), and each table in it is fresh from the
+ -- builder, so nothing here needs copying.
+ local function AddPalette(preset, slots)
+ local count = PaletteCount()
+ if count >= MAX_PALETTES then return end
+ Set("paletteCount", count + 1)
+ local palette = Palette(count + 1)
+ if palette then
+ -- The index may have been used and abandoned by the retired
+ -- palette-count slider, whose decrease hid palettes without
+ -- clearing them. A palette added on purpose starts empty.
+ palette.slots = {}
+ palette.icon = nil
+ palette.appearance = nil
+ palette.name = (preset and preset.label) or AutoName(count + 1)
+ for _, s in ipairs(slots or {}) do
+ if not ns.AddSlot(palette, s) then break end
+ end
+ end
+ editPalette = count + 1
+ HidePicker()
+ RebuildPage()
+ end
+
+ -- One pooled row of the shared menu, filled in. Shared by the two views
+ -- below that list something other than actions; the action views anchor
+ -- their labels the same two ways inline.
+ local function MenuRow(menu, i, icon, label, onClick)
+ local r = menu:GetRow(i)
+ if icon then
+ r.icon:SetTexture(icon)
+ r.icon:Show()
+ r.label:ClearAllPoints()
+ r.label:SetPoint("LEFT", r.icon, "RIGHT", 6, 0)
+ else
+ r.icon:Hide()
+ r.label:ClearAllPoints()
+ r.label:SetPoint("LEFT", r, "LEFT", 8, 0)
+ end
+ r.label:SetPoint("RIGHT", r, "RIGHT", -6, 0)
+ r.label:SetText(label)
+ r:SetScript("OnClick", onClick)
+ r:Show()
+ end
+
+ -- Open the shared menu as a plain list with `title` at the top, ready for
+ -- MenuRow. Returns the menu, or nil when this click was the one that
+ -- closes an already-open list on the same anchor.
+ local function OpenListMenu(anchor, title)
+ if not anchor then return nil end
+ local menu = EnsurePickerMenu()
+ if menu:IsShown() and pickerAnchor == anchor then
+ HidePicker()
+ return nil
+ end
+ pickerAnchor = anchor
+ menu:ClearAllPoints()
+ menu:SetPoint("TOP", anchor, "BOTTOM", 0, -4)
+ menu.cat = nil
+ menu.title:SetText(title)
+ menu.back:Hide()
+ menu.search:ClearFocus()
+ menu.search:Hide()
+ menu.custom:Hide()
+ menu.scroll:Show()
+ return menu
+ end
+
+ -- Copy one palette's SHAPE and PLACE onto the one being edited -- its
+ -- layout, where it opens, its scale, and every knob that only means
+ -- anything inside one layout. Entries are not touched, and neither is
+ -- anything the whole profile shares.
+ --
+ -- The override TABLE is copied rather than the values it resolves to, so a
+ -- key the source inherits is left inherited here as well: both palettes
+ -- then read the same profile value and look identical, and both still
+ -- follow it if it is ever changed.
+ local function CopyAppearance(fromIndex)
+ local src, dst = Palette(fromIndex), Palette(editPalette)
+ if not src or not dst or src == dst then return end
+ local out = {}
+ for key in pairs(ns.APPEARANCE_KEYS or {}) do
+ out[key] = src.appearance and src.appearance[key]
+ end
+ dst.appearance = out
+ HidePicker()
+ RebuildPage()
+ end
+
+ local function ShowCopyAppearanceMenu(anchor)
+ local menu = OpenListMenu(anchor, "Copy Appearance From")
+ if not menu then return end
+
+ local n = 0
+ for i = 1, PaletteCount() do
+ if i ~= editPalette then
+ local palette = Palette(i)
+ n = n + 1
+ MenuRow(menu, n, palette and palette.icon,
+ (palette and palette.name) or AutoName(i),
+ function() CopyAppearance(i) end)
+ end
+ end
+ if n == 0 then
+ -- An empty menu reads as broken, so say why it is empty.
+ MenuRow(menu, 1, nil, "No other palettes", nil)
+ n = 1
+ end
+ for i = n + 1, #menu.rows do menu.rows[i]:Hide() end
+
+ PickerFit(menu, n)
+ menu:Show()
+ end
+
+ -- A view on the same anchored menu the action picker uses, so the add
+ -- button and the "+" entry cannot both have one open: whichever opens
+ -- second takes the menu over.
+ local function ShowAddPaletteMenu(anchor)
+ local menu = OpenListMenu(anchor, "Add Palette")
+ if not menu then return end
+
+ MenuRow(menu, 1, nil, "Empty Palette", function() AddPalette(nil) end)
+ local n = 1
+ for _, preset in ipairs(PALETTE_PRESETS) do
+ local slots = preset.build()
+ if #slots > 0 then
+ n = n + 1
+ local icon = ns.SlotDisplay(slots[1])
+ MenuRow(menu, n, icon or QUESTION_MARK,
+ preset.label .. " |cff808080(" .. #slots .. ")|r",
+ function() AddPalette(preset, slots) end)
+ end
+ end
+ for i = n + 1, #menu.rows do menu.rows[i]:Hide() end
+
+ PickerFit(menu, n)
+ menu:Show()
+ end
+
+ -- Deletion really removes the palette rather than hiding it the way the
+ -- slider's decrease did: the palettes above it close ranks, and everything
+ -- that pointed at them follows -- nested entries repoint to the shifted
+ -- index, entries that opened the deleted palette are removed, and each
+ -- shifted palette's keybind moves with it.
+ local function DeletePalette(index)
+ local p = DB()
+ local count = PaletteCount()
+ if not p or count <= 1 or index < 1 or index > count then return end
+
+ -- The keybind shift below runs SetBinding, so the same combat wall the
+ -- keybind picker documents applies to the whole operation.
+ if InCombatLockdown() then
+ Complain("Action Palette: palettes can't be deleted in combat.")
+ return
+ end
+
+ -- Materialize every live palette first: the shift walks 1 .. count and
+ -- table.remove stops at the first hole.
+ for i = 1, count do Palette(i) end
+
+ table.remove(p.palettes, index)
+ Set("paletteCount", count - 1)
+
+ for i = 1, count - 1 do
+ local palette = Palette(i)
+ for j = #palette.slots, 1, -1 do
+ local s = palette.slots[j]
+ if s.kind == "palette" then
+ local target = tonumber(s.palette)
+ if target == index then
+ table.remove(palette.slots, j)
+ elseif target and target > index then
+ s.palette = target - 1
+ end
+ end
+ end
+ end
+
+ -- Each action can hold TWO keys (see CommitKey), so the whole set is
+ -- snapshotted, everything from the deleted index up is unbound, and
+ -- then each successor's keys are rebound one action lower. Interleaving
+ -- the two passes would unbind keys the previous step just moved.
+ local keys, any = {}, false
+ for n = 1, MAX_PALETTES do
+ keys[n] = { GetBindingKey(BINDING_PREFIX .. n) }
+ if n >= index and keys[n][1] then any = true end
+ end
+ for n = index, MAX_PALETTES do
+ for _, k in ipairs(keys[n]) do SetBinding(k) end
+ end
+ for n = index, MAX_PALETTES - 1 do
+ for _, k in ipairs(keys[n + 1] or {}) do
+ SetBinding(k, BINDING_PREFIX .. n)
+ end
+ end
+ if any then SaveBindings(GetCurrentBindingSet()) end
+
+ -- Keep the editor pointed at the palette it was on, which now sits one
+ -- index lower; deleting the edited palette itself falls to whichever
+ -- palette took its number.
+ if editPalette > index then editPalette = editPalette - 1 end
+ if editPalette > count - 1 then editPalette = count - 1 end
+ RebuildPage()
+ end
+
+ -- Which slot the cursor is over, for the reorder drag. The live palette's own
+ -- HitTest answers this from the angle to the hub, which only means anything
+ -- on a circle -- in a fan it would report a entry the user is nowhere near.
+ -- Nearest widget CENTRE is the layout-agnostic form of the same question,
+ -- and it also follows the strip while it slides.
+ local function PreviewDropTarget()
+ if not previewView:IsFan() then return previewView:HitTest() end
+
+ local shown = previewView:ShownCount()
+ if shown < 1 then return nil end
+
+ local mx, my = GetCursorPosition()
+ local best, bestDist
+ for i = 1, shown do
+ local w = previewView:GetSlotWidget(i)
+ -- Not folded into an `and`: that truncates to one value and would
+ -- drop cy on the floor.
+ local cx, cy
+ if w:IsShown() then cx, cy = w:GetCenter() end
+ if cx then
+ -- GetCenter is in the widget's own scaled space; the cursor is
+ -- in screen units, so the scale has to be applied to compare.
+ local es = w:GetEffectiveScale()
+ local dx, dy = mx - cx * es, my - cy * es
+ local d = dx * dx + dy * dy
+ if not bestDist or d < bestDist then best, bestDist = i, d end
+ end
+ end
+ return best
+ end
+
+ -- Installed on the press and cleared on the release, so the block is idle
+ -- whenever nothing is being dragged.
+ local function PreviewDragUpdate()
+ local es = previewView:GetFrame():GetEffectiveScale()
+ local x, y = GetCursorPosition()
+ x, y = x / es, y / es
+ if not dragging then
+ if math.abs(x - dragStartX) < DRAG_THRESHOLD
+ and math.abs(y - dragStartY) < DRAG_THRESHOLD then return end
+ dragging = true
+ local w = previewView:GetSlotWidget(dragFrom)
+ w:SetAlpha(0.4)
+ w:SetFrameLevel(previewView:GetFrame():GetFrameLevel() + 20)
+ HidePreviewTooltip()
+ end
+ -- The entry under the cursor. The "+" entry resolves to the
+ -- last real slot, so dropping there means "move to the end".
+ local hit = PreviewDropTarget()
+ -- n can reach 0 mid-drag -- right-click removes while the left
+ -- button is still held -- and min(hit, 0) is 0, which is TRUTHY
+ -- in Lua and would index widget 0.
+ local n = previewView:SlotCount()
+ dragTarget = (hit and n > 0) and math.min(hit, n) or nil
+ previewView:SetSelection(dragTarget)
+ end
+
+ local function EndPreviewDrag()
+ if dragFrom then
+ local w = previewView:GetSlotWidget(dragFrom)
+ w:SetAlpha(1)
+ w:SetFrameLevel(previewView:GetFrame():GetFrameLevel() + 1)
+ end
+ dragFrom, dragging = nil, false
+ if previewBlock then previewBlock:SetScript("OnUpdate", nil) end
+ previewView:SetSelection(nil)
+ end
+
+ local function InstallPreviewScripts()
+ for i = 1, (ns.MAX_SLOTS or 12) do
+ local w = previewView:GetSlotWidget(i)
+ w:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+
+ w:SetScript("OnEnter", function(self)
+ if dragging then return end
+ -- Reuse the selection paint: hovering an entry in the panel looks
+ -- exactly like steering onto it in play.
+ previewView:SetSelection(self.index)
+ PreviewTooltip(self)
+ end)
+ w:SetScript("OnLeave", function()
+ if dragging then return end
+ previewView:SetSelection(nil)
+ HidePreviewTooltip()
+ end)
+
+ w:SetScript("OnClick", function(self, button)
+ if button == "RightButton" then
+ if self.isPlaceholder then return end
+ local palette = Palette(editPalette)
+ if palette and ns.RemoveSlot(palette, self.index) then Refresh() end
+ return
+ end
+ -- A loaded cursor still means "place this here" on every entry;
+ -- the picker is the empty-cursor path on the "+" only.
+ if self.isPlaceholder and not GetCursorInfo() then
+ TogglePicker(self)
+ return
+ end
+ PreviewPlace(self)
+ end)
+ w:SetScript("OnReceiveDrag", function(self) PreviewPlace(self) end)
+
+ w:SetScript("OnMouseDown", function(self, button)
+ if button ~= "LeftButton" or self.isPlaceholder then return end
+ -- A loaded cursor means "place this here", not "reorder".
+ if GetCursorInfo() then return end
+ local es = previewView:GetFrame():GetEffectiveScale()
+ local x, y = GetCursorPosition()
+ dragStartX, dragStartY = x / es, y / es
+ dragFrom, dragTarget = self.index, nil
+ previewBlock:SetScript("OnUpdate", PreviewDragUpdate)
+ end)
+
+ -- OnMouseUp is delivered to the frame that got OnMouseDown even when
+ -- the cursor has left it, which is what makes the drop work at all.
+ w:SetScript("OnMouseUp", function(self, button)
+ if button ~= "LeftButton" or dragFrom ~= self.index then return end
+ local from, to, moved = dragFrom, dragTarget, dragging
+ EndPreviewDrag()
+ if not moved or not to then return end
+ local palette = Palette(editPalette)
+ if palette and ns.MoveSlot(palette, from, to) then Refresh() end
+ end)
+ end
+ end
+
+ -- yPos is the running page offset; returns the height consumed.
+ local function BuildPreview(parent, yPos)
+ -- PanelPP, not PP: panel-context geometry snaps to the options window's
+ -- own pixel grid, which is what every other options page uses.
+ local PP = EllesmereUI.PanelPP
+ local CONTENT_PAD = EllesmereUI.CONTENT_PAD or 45
+
+ -- A rebuild re-fans the palette, so the entry the picker is anchored to may
+ -- not be the "+" any more -- and an already-open menu would then be
+ -- pointing at an unrelated slot.
+ HidePicker()
+
+ if not previewBlock then
+ previewBlock = CreateFrame("Frame", nil, parent)
+
+ local bg = previewBlock:CreateTexture(nil, "BACKGROUND")
+ bg:SetAllPoints()
+ bg:SetColorTexture(0, 0, 0, 0.18)
+
+ previewView = ns.CreatePaletteView(previewBlock, {
+ interactive = true,
+ geom = PreviewGeom,
+ -- Labels would collide at the fitted scale, and the hub plus the
+ -- tooltip already name whatever the cursor is over.
+ showLabels = false,
+ -- Arrangement is what this preview is for; a live cooldown swipe
+ -- on a settings page is only noise.
+ showCooldowns = false,
+ -- And so is a red icon reporting that the dummy you are not
+ -- targeting is out of range of a spell you are only filing.
+ showUsability = false,
+ -- Kept short on purpose: the hub line is drawn centered inside a
+ -- an arc only ~75px in radius, and anything longer runs under the
+ -- entries at 3 and 9 o'clock. The rest is in the slot tooltips.
+ hintText = function(n)
+ if n == 0 then return "click + to add an action" end
+ return "drag to reorder"
+ end,
+ })
+ previewView:GetFrame():SetPoint("CENTER", previewBlock, "CENTER", 0, 0)
+ InstallPreviewScripts()
+
+ -- A press held while the panel closes under it (Esc, or the combat
+ -- auto-hide) would otherwise resume as a phantom drag on reshow.
+ previewBlock:SetScript("OnHide", function()
+ if dragFrom then EndPreviewDrag() end
+ -- The picker is parented to UIParent, so it does not follow the
+ -- panel down by itself. OnHide propagates from the wrapper, so
+ -- this covers closing the window and switching page alike.
+ HidePicker()
+ end)
+
+ -- Blocks the preview outright while the module is off, matching the
+ -- disabled overlay the standard rows draw over themselves.
+ local dim = CreateFrame("Frame", nil, previewBlock)
+ dim:SetAllPoints()
+ dim:EnableMouse(true)
+ local dimTex = dim:CreateTexture(nil, "OVERLAY")
+ dimTex:SetAllPoints()
+ dimTex:SetColorTexture(0.06, 0.08, 0.10, 0.70)
+ dim:SetScript("OnEnter", function(self)
+ if EllesmereUI.ShowWidgetTooltip then
+ EllesmereUI.ShowWidgetTooltip(self, "Enable the module to edit palettes.")
+ end
+ end)
+ dim:SetScript("OnLeave", function()
+ if EllesmereUI.HideWidgetTooltip then EllesmereUI.HideWidgetTooltip() end
+ end)
+ previewBlock._dim = dim
+ end
+
+ previewBlock:SetParent(parent)
+ previewBlock:ClearAllPoints()
+ -- Anchored on both sides rather than sized from parent:GetWidth(): the
+ -- wrapper's own width is not resolved yet on the first build, and this
+ -- block is cached, so a zero read would stick for the panel's lifetime.
+ PP.Point(previewBlock, "TOPLEFT", parent, "TOPLEFT", CONTENT_PAD, yPos)
+ PP.Point(previewBlock, "TOPRIGHT", parent, "TOPRIGHT", -CONTENT_PAD, yPos)
+ PP.Height(previewBlock, PREVIEW_H)
+ -- Re-asserted per build: an explicit frame level is absolute, and the
+ -- block's own level moves when it is re-parented to a fresh wrapper.
+ previewBlock._dim:SetFrameLevel(previewBlock:GetFrameLevel() + 30)
+ previewBlock._dim:SetShown(Cfg("enabled") ~= true)
+ previewBlock:Show()
+ previewView:Layout(editPalette)
+ -- A freshly laid out strip sits at its default centre, which is a
+ -- position between entries rather than on one. Park it on the first
+ -- slot so the preview opens looking like the strip in play does. Only a
+ -- SCROLLED strip has a centre at all: the grid and the pointer-steered
+ -- fan draw every entry at a fixed position and select by proximity.
+ if previewView:IsFan() and not previewView:IsGrid()
+ and not previewView:IsHoverFan() then
+ previewView:SetFanCenter(1)
+ end
+
+ return PREVIEW_H
+ end
+
+ ---------------------------------------------------------------------------
+ -- Inline cog button beside a DualRow region, opening a BuildCogPopup.
+ -- The same shape the other option pages use (EUI_AuraBuffReminders_
+ -- Options.lua:588): parked left of the region's control, dim until
+ -- hovered, and handing itself to showFn as the popup's anchor.
+ ---------------------------------------------------------------------------
+ local function MakeCogBtn(rgn, showFn, anchorTo, iconPath)
+ local cogBtn = CreateFrame("Button", nil, rgn)
+ cogBtn:SetSize(26, 26)
+ cogBtn:SetPoint("RIGHT", anchorTo or rgn._lastInline or rgn._control, "LEFT", -8, 0)
+ rgn._lastInline = cogBtn
+ cogBtn:SetFrameLevel(rgn:GetFrameLevel() + 5)
+ cogBtn:SetAlpha(0.4)
+ local cogTex = cogBtn:CreateTexture(nil, "OVERLAY")
+ cogTex:SetAllPoints()
+ cogTex:SetTexture(iconPath or EllesmereUI.COGS_ICON)
+ cogBtn:SetScript("OnEnter", function(self) self:SetAlpha(0.7) end)
+ cogBtn:SetScript("OnLeave", function(self) self:SetAlpha(0.4) end)
+ cogBtn:SetScript("OnClick", function(self) showFn(self) end)
+ return cogBtn
+ end
+
+
+ ---------------------------------------------------------------------------
+ -- Build Page
+ ---------------------------------------------------------------------------
+ local function BuildPage(pageName, parent, yOffset)
+ _installPickerAutoOff()
+ EnsureBindWatcher()
+
+ local W = EllesmereUI.Widgets
+ local y = yOffset
+ local row, h
+
+ if EllesmereUI.ClearContentHeader then EllesmereUI:ClearContentHeader() end
+ parent._showRowDivider = true
+
+ -- Not `== false`: the master switch is opt-in, so an absent key counts
+ -- as off. The appearance sub-settings below stay nil-means-on.
+ local function Disabled() return Cfg("enabled") ~= true end
+
+ -- Which palette is being edited is panel-session state, but the profile
+ -- under it can change without one: switching to a profile that holds
+ -- fewer palettes repoints the data with no reload, and the module reset
+ -- leaves a single palette behind. Clamped here rather than at each
+ -- reader because Palette, ACfg and ASet all go through
+ -- ns.EnsurePalette, which would CREATE the out-of-range palette and
+ -- bank that junk in the profile the user has just moved to.
+ local paletteCount = PaletteCount()
+ if editPalette > paletteCount then editPalette = paletteCount end
+
+ local centerValues = { CURSOR = "At Cursor", SCREEN = "Fixed Position" }
+ local centerOrder = { "CURSOR", "SCREEN" }
+
+ local layoutValues = { ARC = "Arc", FAN = "Fan", GRID = "Grid" }
+ local layoutOrder = { "GRID", "FAN", "ARC" }
+
+ local orientValues = { HORIZONTAL = "Horizontal", VERTICAL = "Vertical" }
+ local orientOrder = { "HORIZONTAL", "VERTICAL" }
+
+ local fanInputValues = { SCROLL = "Mouse Wheel", CURSOR = "Pointer" }
+ local fanInputOrder = { "SCROLL", "CURSOR" }
+
+ -----------------------------------------------------------------------
+ -- GENERAL
+ -----------------------------------------------------------------------
+ _, h = W:SectionHeader(parent, "GENERAL", y); y = y - h
+
+ local addPaletteBtn
+ row, h = W:DualRow(parent, y,
+ { type="toggle", text="Enable Action Palette",
+ getValue=function() return Cfg("enabled") == true end,
+ setValue=function(v) Set("enabled", v); RebuildPage() end },
+ -- The chooser it opens offers an empty palette or a preset. The new
+ -- palette takes a key of its own from the keybind row in PALETTE
+ -- SETUP, whatever its number.
+ { type="labeledButton", text="Add Palette",
+ disabled=function()
+ return Disabled() or PaletteCount() >= MAX_PALETTES
+ end,
+ disabledTooltip=function()
+ if PaletteCount() >= MAX_PALETTES then
+ return "This profile already holds " .. MAX_PALETTES
+ .. " palettes"
+ end
+ return "This option requires the module to be enabled"
+ end,
+ rawTooltip=true,
+ buttonText="Empty or Preset...",
+ -- An upvalue filled in below, not an onClick parameter: the
+ -- styled button invokes its handler with no arguments
+ -- (EllesmereUI_Widgets.lua:399), so the chooser has to reach the
+ -- frame it anchors under some other way.
+ onClick=function() ShowAddPaletteMenu(addPaletteBtn) end })
+ addPaletteBtn = row._rightRegion and row._rightRegion._control
+ y = y - h
+
+ -----------------------------------------------------------------------
+ -- PALETTE SETUP
+ -----------------------------------------------------------------------
+ _, h = W:SectionHeader(parent, "PALETTE SETUP", y); y = y - h
+
+ local paletteValues, paletteOrder = {}, {}
+ for i = 1, PaletteCount() do
+ local r = Palette(i)
+ paletteValues[i] = (r and r.name) or AutoName(i)
+ paletteOrder[#paletteOrder + 1] = i
+ end
+
+ local keyForEditPalette = GetBindingKey(BINDING_PREFIX .. editPalette)
+
+ row, h = W:DualRow(parent, y,
+ -- noCapture: which palette the preview is pointed at is panel-session
+ -- state, not a setting, so it must not be banked as a per-spec
+ -- override by the Spec Overrides capture pass.
+ { type="dropdown", text="Editing Palette", noCapture=true,
+ disabled=Disabled, disabledTooltip="the module",
+ values=paletteValues, order=paletteOrder,
+ getValue=function() return editPalette end,
+ setValue=function(v)
+ editPalette = v
+ -- Full rebuild, not a preview relayout: the keybind button's
+ -- label is baked in at build time from THIS palette's key.
+ RebuildPage()
+ end },
+ -- buttonText is consumed as a literal string, so the label is
+ -- resolved at build time. Both things that can change it --
+ -- committing a key and switching the edited palette -- rebuild the
+ -- page, so it cannot go stale while the panel is open.
+ { type="labeledButton", text="Palette Keybind",
+ disabled=Disabled, disabledTooltip="the module",
+ buttonText=(listenPalette == editPalette) and "Press a key..."
+ or (keyForEditPalette and (GetBindingText(keyForEditPalette) or keyForEditPalette)
+ or "Click to Bind"),
+ onClick=function() StartListening(editPalette) end })
+ y = y - h
+
+ -- Name and icon of the palette the dropdown above is pointed at, so they
+ -- sit with the selector rather than in APPEARANCE: everything else in
+ -- that section describes every palette at once.
+ --
+ -- noCapture on both, for the same reason the selector opts out: these
+ -- live inside p.palettes[n] rather than under a flat key, and a per-spec
+ -- override banked against whichever palette was on screen at capture
+ -- time would rename a palette the user was not looking at.
+ local editedPalette = Palette(editPalette)
+ row, h = W:DualRow(parent, y,
+ { type="input", text="Palette Name", noCapture=true,
+ inputStyle="popup", inputWidth=170, placeholder=AutoName(editPalette),
+ disabled=Disabled, disabledTooltip="the module",
+ tooltip="Shown in the center of this palette, and on the entry that "
+ .. "opens it from another palette. Clear the box for "
+ .. AutoName(editPalette) .. ".",
+ -- The auto name shows as an EMPTY box: the placeholder then says
+ -- what an empty box means, and the user never has to delete a
+ -- name they did not type.
+ getValue=function()
+ local pal = Palette(editPalette)
+ local nm = pal and pal.name
+ if not nm or nm == AutoName(editPalette) then return "" end
+ return nm
+ end,
+ setValue=function(txt)
+ local pal = Palette(editPalette)
+ if not pal then return end
+ local nm = strtrim(txt or "")
+ nm = (nm ~= "") and nm or AutoName(editPalette)
+ -- Only on a real change. Every commit fires on focus loss too,
+ -- including the one where the user simply clicked away, and
+ -- rebuilding the page under that click would swallow it.
+ if nm == pal.name then return end
+ pal.name = nm
+ -- Rebuild: the selector's own labels are baked in above, so a
+ -- plain refresh would leave the dropdown naming the old one.
+ RebuildPage()
+ end },
+ { type="input", text="Palette Icon", noCapture=true,
+ inputStyle="popup", inputWidth=100, placeholder="spell ID",
+ disabled=Disabled, disabledTooltip="the module",
+ tooltip="Icon for the entry that opens this palette from another "
+ .. "palette. Enter a spell, item, or icon ID. Clear the box to "
+ .. "go back to the first action's icon.",
+ getValue=function()
+ local pal = Palette(editPalette)
+ return (pal and pal.icon) and tostring(pal.icon) or ""
+ end,
+ setValue=function(txt)
+ local pal = Palette(editPalette)
+ if not pal then return end
+ -- Compared as TEXT, before resolving. Once committed the box
+ -- shows the resolved icon ID, and running that number back
+ -- through the lookup could land on an unrelated spell that
+ -- happens to share it -- so an untouched box is a no-op
+ -- rather than a second lookup. This also swallows the commit
+ -- that every focus loss fires, which would otherwise rebuild
+ -- the page under the click that caused it.
+ local raw = strtrim(txt or "")
+ if raw == ((pal.icon and tostring(pal.icon)) or "") then return end
+ pal.icon = ResolveIconInput(raw)
+ RebuildPage()
+ end })
+ -- Live preview beside the box, anchored to the box itself rather than to
+ -- a measured offset from the row's edge. Nothing refreshes it in place:
+ -- both things that change it -- a commit here, and switching palette --
+ -- rebuild the page.
+ local iconBox = row._rightRegion and row._rightRegion._control
+ if iconBox then
+ iconBox:SetMaxLetters(10)
+ local tex = row._rightRegion:CreateTexture(nil, "ARTWORK")
+ tex:SetSize(24, 24)
+ tex:SetPoint("RIGHT", iconBox, "LEFT", -8, 0)
+ tex:SetTexture(editedPalette and editedPalette.icon or nil)
+ tex:SetShown(editedPalette ~= nil and editedPalette.icon ~= nil)
+ end
+ local nameBox = row._leftRegion and row._leftRegion._control
+ -- The hub caption is drawn inside a small arc, so a name longer than
+ -- this runs out under the entries either side of it.
+ if nameBox then nameBox:SetMaxLetters(24) end
+ y = y - h
+
+ -- Baked at build time like the keybind label above: the name it shows
+ -- and the count that disables it both rebuild the page when they
+ -- change.
+ local editedName = (editedPalette and editedPalette.name)
+ or AutoName(editPalette)
+
+ -- Sits with the palette selector rather than in LAYOUT because it
+ -- acts on the whole of the appearance rather than on any one
+ -- section of it. Same upvalue trick as the Add Palette button above:
+ -- the styled button invokes its handler with no arguments, so the menu
+ -- has to reach its anchor some other way.
+ local copyAppearanceBtn
+ row, h = W:DualRow(parent, y,
+ { type="labeledButton", text="Copy Appearance", noCapture=true,
+ disabled=function()
+ return Disabled() or PaletteCount() <= 1
+ end,
+ disabledTooltip=function()
+ if PaletteCount() <= 1 then
+ return "This profile holds no second palette to copy from"
+ end
+ return "This option requires the module to be enabled"
+ end,
+ rawTooltip=true,
+ tooltip="Give " .. editedName .. " the same layout, opening "
+ .. "position, scale and layout settings as another palette. "
+ .. "Entries are not touched, and neither is anything the "
+ .. "whole profile shares -- colors, hub art, labels, nesting "
+ .. "and flick-ahead.",
+ buttonText="From another palette...",
+ onClick=function() ShowCopyAppearanceMenu(copyAppearanceBtn) end },
+ { type="labeledButton", text="Delete Palette",
+ disabled=function()
+ return Disabled() or PaletteCount() <= 1
+ end,
+ disabledTooltip=function()
+ if PaletteCount() <= 1 then
+ return "The last palette cannot be deleted"
+ end
+ return "This option requires the module to be enabled"
+ end,
+ rawTooltip=true,
+ buttonText="Delete " .. editedName,
+ onClick=function()
+ EllesmereUI:ShowConfirmPopup({
+ title = "Delete Palette",
+ message = "Delete " .. editedName .. " and its "
+ .. "contents? Entries in other palettes that open "
+ .. "it are removed too, and the palettes after it "
+ .. "move up one place.",
+ confirmText = "Delete",
+ cancelText = "Cancel",
+ onConfirm = function() DeletePalette(editPalette) end,
+ })
+ end })
+ copyAppearanceBtn = row._leftRegion and row._leftRegion._control
+ y = y - h
+
+ y = y - BuildPreview(parent, y)
+
+ -----------------------------------------------------------------------
+ -- LAYOUT
+ -----------------------------------------------------------------------
+ _, h = W:SectionHeader(parent, "LAYOUT", y); y = y - h
+
+ -- The page keeps the two decisions every palette needs -- which
+ -- layout it draws as and where it opens -- and the two sizes everyone
+ -- reaches for. Every knob that only means anything inside one layout
+ -- lives behind the cog on the Layout dropdown, and the popup is built
+ -- for the layout the edited palette is on: an arc has no strip to
+ -- describe and a strip has no radius, so the popup holds only rows
+ -- that do something. The popup can never be open when the layout
+ -- changes under it -- the dropdown click that changes it is a click
+ -- outside the popup, which closes it -- so the rows cannot go stale.
+ local layoutMode = ACfg("layout") or "ARC"
+
+ -- The nest rows ride in the layout popup rather than in a section of
+ -- their own: which of them are live is decided by the layout, and in
+ -- a per-layout popup that gating becomes simply which rows exist.
+ --
+ -- layoutCogShow is declared ahead of the rows because one of them
+ -- reaches back into the popup it lives in: a slider write is the one
+ -- popup edit that does not re-read the other rows' disabled
+ -- predicates by itself.
+ local layoutCogShow
+ local layoutCogTitle, layoutCogRows
+ if layoutMode == "ARC" then
+ layoutCogTitle = "Arc Settings"
+ layoutCogRows = {
+ { type="slider", label="Arc Radius", min=50, max=220, step=1,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return Cfg("radius") or 96 end,
+ set=function(v) Set("radius", v); Refresh() end },
+ { type="slider", label="Arc Span", noCapture=true,
+ min=30, max=360, step=5,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return ACfg("arcSpan") or 360 end,
+ set=function(v)
+ ASet("arcSpan", v); Refresh()
+ -- The rotation row below reads its life from the span,
+ -- so the popup is told to re-read its predicates.
+ local pf = layoutCogShow and layoutCogShow._popupFrame
+ if pf and pf._refresh then pf._refresh() end
+ end },
+ -- 360 is a full turn, and a rotation of a full circle is a
+ -- no-op -- so the rotation only becomes live once the span
+ -- has been narrowed to an arc that has somewhere to point.
+ { type="slider", label="Arc Rotation", noCapture=true,
+ min=-180, max=180, step=5,
+ disabled=function()
+ return Disabled() or (ACfg("arcSpan") or 360) >= 360
+ end,
+ disabledTooltip="an Arc Span below 360",
+ get=function() return ACfg("arcRotation") or 0 end,
+ set=function(v) ASet("arcRotation", v); Refresh() end },
+ -- Floor of 8, not 0: the dead zone is what makes "release
+ -- without steering" a cancel, and at 0 there is no cancel
+ -- region at all.
+ { type="slider", label="Dead Zone", min=8, max=80, step=1,
+ disabled=Disabled, disabledTooltip="the module",
+ tooltip="Release with the cursor inside this distance of the "
+ .."center to close the palette without firing anything.",
+ get=function() return Cfg("deadZone") or 24 end,
+ set=function(v) Set("deadZone", v); Refresh() end },
+ -- Holding the palette back for a moment lets an expert finish
+ -- a gesture before anything appears. Selection is live the
+ -- whole time, so nothing is lost by waiting.
+ { type="toggle", label="Flick-Ahead",
+ disabled=Disabled, disabledTooltip="the module",
+ tooltip="Keep the palette invisible for a moment after the "
+ .."key goes down. Selection is live the whole time, so a "
+ .."quick flick fires before anything is drawn.",
+ get=function() return Cfg("flickAhead") ~= false end,
+ set=function(v) Set("flickAhead", v); Refresh() end },
+ { type="slider", label="Flick Delay", min=0, max=0.40, step=0.01,
+ disabled=function()
+ return Disabled() or Cfg("flickAhead") == false
+ end,
+ disabledTooltip="Flick-Ahead",
+ get=function() return Cfg("flickDelay") or 0.12 end,
+ set=function(v) Set("flickDelay", v); Refresh() end },
+ -- The gap between the two rings' icons, not a radius, so what
+ -- the number says is what the eye measures.
+ { type="slider", label="Nest Distance", min=0, max=160, step=1,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return Cfg("nestBand") or 40 end,
+ set=function(v) Set("nestBand", v); Refresh() end },
+ { type="dropdown", label="Nest Width",
+ disabled=Disabled, disabledTooltip="the module",
+ tooltip="How far along the arc a nest may spread its entries."
+ .." Contained keeps it clear of the next nest either side,"
+ .." so two of them are never drawn on top of one another."
+ .." Overflowing lets it use the whole of Max Nest Span."
+ .." A nest reaches over the plain entries it passes, and"
+ .." they only give up their angles while it is open.",
+ values={ NONE = "Contained", MIDPOINT = "Overflowing" },
+ order={ "NONE", "MIDPOINT" },
+ get=function() return Cfg("arcChildOverflow") or "NONE" end,
+ set=function(v) Set("arcChildOverflow", v); Refresh() end },
+ { type="slider", label="Max Nest Span", min=30, max=180, step=5,
+ disabled=Disabled, disabledTooltip="the module",
+ tooltip="The widest angle a nest's entries may spread over."
+ .." They stay on one ring for as long as they fit inside"
+ .." it; the rest go onto a second ring further out.",
+ get=function() return Cfg("arcChildMaxSpan") or 90 end,
+ set=function(v) Set("arcChildMaxSpan", v); Refresh() end },
+ -- Smaller than the palette's own entries, so a nest reads as
+ -- subordinate to the entry it opens from. Drawing only: the
+ -- sectors are angular, so this changes nothing about what a
+ -- release picks.
+ { type="slider", label="Nest Icon Size", min=0.4, max=1.0, step=0.05,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return Cfg("nestScale") or 0.8 end,
+ set=function(v) Set("nestScale", v); Refresh() end },
+ }
+ elseif layoutMode == "FAN" then
+ layoutCogTitle = "Fan Settings"
+ -- The window-and-scroll rows: a pointer-steered fan draws the
+ -- whole palette at fixed positions, so it culls nothing, animates
+ -- nothing and has no wheel direction to invert.
+ local scrollOnly = function()
+ return Disabled() or (ACfg("fanInput") or "SCROLL") ~= "SCROLL"
+ end
+ layoutCogRows = {
+ { type="dropdown", label="Fan Direction", noCapture=true,
+ disabled=Disabled, disabledTooltip="the module",
+ values=orientValues, order=orientOrder,
+ get=function() return ACfg("fanOrientation") or "HORIZONTAL" end,
+ set=function(v) ASet("fanOrientation", v); Refresh() end },
+ { type="dropdown", label="Steering", noCapture=true,
+ disabled=Disabled, disabledTooltip="the module",
+ values=fanInputValues, order=fanInputOrder,
+ -- Refresh where the page row needed a rebuild: the three
+ -- rows this retires live in this same popup, and the popup
+ -- re-reads every disabled predicate after each write.
+ get=function() return ACfg("fanInput") or "SCROLL" end,
+ set=function(v) ASet("fanInput", v); Refresh() end },
+ -- How much of the strip is drawn at all. Neighbours past this
+ -- many steps are hidden rather than shrunk further.
+ { type="slider", label="Visible Each Side", noCapture=true,
+ min=1, max=6, step=1,
+ disabled=scrollOnly, disabledTooltip="Mouse Wheel steering",
+ get=function() return ACfg("fanVisible") or 3 end,
+ set=function(v) ASet("fanVisible", v); Refresh() end },
+ { type="slider", label="Entry Gap", noCapture=true,
+ min=0, max=40, step=1,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return ACfg("fanGap") or 10 end,
+ set=function(v) ASet("fanGap", v); Refresh() end },
+ -- How long the strip takes to slide to the entry that was
+ -- scrolled to. 0 snaps; the selection itself moves on the
+ -- tick either way.
+ { type="slider", label="Settle Time", noCapture=true,
+ min=0, max=0.40, step=0.01,
+ disabled=scrollOnly, disabledTooltip="Mouse Wheel steering",
+ get=function() return ACfg("fanAnimTime") or 0.10 end,
+ set=function(v) ASet("fanAnimTime", v); Refresh() end },
+ { type="toggle", label="Invert Scroll", noCapture=true,
+ disabled=scrollOnly, disabledTooltip="Mouse Wheel steering",
+ get=function() return ACfg("fanInvert") == true end,
+ set=function(v) ASet("fanInvert", v); Refresh() end },
+ { type="slider", label="Nest Distance", min=0, max=160, step=1,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return Cfg("nestBand") or 40 end,
+ set=function(v) Set("nestBand", v); Refresh() end },
+ { type="slider", label="Nest Icon Size", min=0.4, max=1.0, step=0.05,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return Cfg("nestScale") or 0.8 end,
+ set=function(v) Set("nestScale", v); Refresh() end },
+ -- Both long sides of a strip are equidistant, so which one a
+ -- nest opens on is a real choice. One label pair covers both
+ -- orientations, because this popup is not rebuilt when the
+ -- Fan Direction row above changes.
+ { type="dropdown", label="Nest Side",
+ disabled=Disabled, disabledTooltip="the module",
+ tooltip="Above or below a horizontal strip; right or left "
+ .."of a vertical one.",
+ values={ POSITIVE = "Above / Right", NEGATIVE = "Below / Left" },
+ order={ "POSITIVE", "NEGATIVE" },
+ get=function() return Cfg("nestSide") or "POSITIVE" end,
+ set=function(v) Set("nestSide", v); Refresh() end },
+ }
+ else
+ layoutCogTitle = "Grid Settings"
+ layoutCogRows = {
+ { type="toggle", label="Auto Columns", noCapture=true,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return ACfg("gridAutoColumns") ~= false end,
+ set=function(v) ASet("gridAutoColumns", v); Refresh() end },
+ { type="slider", label="Grid Columns", noCapture=true,
+ -- The top of the travel is the slot cap, not an arbitrary
+ -- 8. A grid never draws more columns than it has entries,
+ -- so asking for the most a palette can ever hold is how you
+ -- say "one row", and it stays one row as entries are added.
+ -- Column 1 is already the transpose of that, so both
+ -- single-file layouts are on the one slider and neither
+ -- needs a sentinel value.
+ min=1, max=MAX_SLOTS, step=1,
+ disabled=function()
+ return Disabled() or ACfg("gridAutoColumns") ~= false
+ end,
+ requireState="disabled", disabledTooltip="Auto Columns",
+ tooltip="How many entries a row of the grid holds. 1 stacks "
+ .."them into a single column; " .. MAX_SLOTS
+ .. " -- the most slots a palette can hold -- lays them "
+ .."out in a single row.",
+ get=function() return ACfg("gridColumns") or 4 end,
+ set=function(v) ASet("gridColumns", v); Refresh() end },
+ { type="slider", label="Entry Gap", noCapture=true,
+ min=0, max=40, step=1,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return ACfg("fanGap") or 10 end,
+ set=function(v) ASet("fanGap", v); Refresh() end },
+ { type="slider", label="Nest Distance", min=0, max=160, step=1,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return Cfg("nestBand") or 40 end,
+ set=function(v) Set("nestBand", v); Refresh() end },
+ { type="slider", label="Nest Icon Size", min=0.4, max=1.0, step=0.05,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return Cfg("nestScale") or 0.8 end,
+ set=function(v) Set("nestScale", v); Refresh() end },
+ -- On a grid the side NEAREST the entry wins; this answers for
+ -- an entry the sides tie on -- a lane's dead-centre parent,
+ -- and every parent of a single-row or single-column palette.
+ { type="dropdown", label="Nest Side",
+ disabled=Disabled, disabledTooltip="the module",
+ values={ POSITIVE = "Above", NEGATIVE = "Below" },
+ order={ "POSITIVE", "NEGATIVE" },
+ get=function() return Cfg("nestSide") or "POSITIVE" end,
+ set=function(v) Set("nestSide", v); Refresh() end },
+ -- Lane a halo hugging the block, centered on the point
+ -- of it nearest the entry that opens it, wrapping
+ -- the corners when the run is long
+ -- Halo the eight positions around the entry itself, the
+ -- block faded behind them
+ -- A stored value from the retired Popout style reads as Lane,
+ -- here and in the module both.
+ { type="dropdown", label="Grid Nest Style",
+ disabled=Disabled, disabledTooltip="the module",
+ values={ PERIMETER = "Lane", HALO = "Halo" },
+ order={ "PERIMETER", "HALO" },
+ get=function()
+ local v = Cfg("gridNestStyle")
+ return (v == "HALO") and "HALO" or "PERIMETER"
+ end,
+ set=function(v) Set("gridNestStyle", v); Refresh() end },
+ }
+ end
+
+ row, h = W:DualRow(parent, y,
+ { type="dropdown", text="Layout", noCapture=true,
+ disabled=Disabled, disabledTooltip="the module",
+ values=layoutValues, order=layoutOrder,
+ getValue=function() return ACfg("layout") or "ARC" end,
+ -- Rebuild, not Refresh: the cog beside this dropdown is loaded
+ -- with the rows of the layout picked here.
+ setValue=function(v) ASet("layout", v); RebuildPage() end },
+ { type="dropdown", text="Opens", noCapture=true,
+ disabled=Disabled, disabledTooltip="the module",
+ values=centerValues, order=centerOrder,
+ getValue=function() return ACfg("centerMode") or "CURSOR" end,
+ setValue=function(v) ASet("centerMode", v); Refresh() end })
+ do
+ -- captureRegion: the profile-wide rows in the popup (radius, dead
+ -- zone, flick, nesting) keep their Spec Overrides capture; the
+ -- per-palette rows carry noCapture for the reason ASet documents.
+ layoutCogShow = select(2, EllesmereUI.BuildCogPopup({
+ title = layoutCogTitle,
+ captureRegion = row._leftRegion,
+ rows = layoutCogRows,
+ }))
+ MakeCogBtn(row._leftRegion, layoutCogShow)
+
+ -- The X/Y offsets are the ONLY way to place the palette: the
+ -- on-screen drag editor is gone, and this module is not yet
+ -- registered with Unlock Mode. In cursor mode the palette opens
+ -- wherever the mouse is, so both rows sit dead until Fixed
+ -- Position is picked. No captureRegion: both rows are stored per
+ -- palette, so neither may bank a per-spec override.
+ local fixedOnly = function()
+ return Disabled() or (ACfg("centerMode") or "CURSOR") ~= "SCREEN"
+ end
+ local _, posCogShow = EllesmereUI.BuildCogPopup({
+ title = "Fixed Position",
+ rows = {
+ { type="slider", label="X Offset", noCapture=true,
+ min=-800, max=800, step=1,
+ disabled=fixedOnly, disabledTooltip="Fixed Position mode",
+ get=function() return ACfg("posX") or 0 end,
+ set=function(v) ASet("posX", v); Refresh() end },
+ { type="slider", label="Y Offset", noCapture=true,
+ min=-600, max=600, step=1,
+ disabled=fixedOnly, disabledTooltip="Fixed Position mode",
+ get=function() return ACfg("posY") or 0 end,
+ set=function(v) ASet("posY", v); Refresh() end },
+ },
+ })
+ MakeCogBtn(row._rightRegion, posCogShow)
+ end
+ y = y - h
+
+ row, h = W:DualRow(parent, y,
+ { type="slider", text="Icon Size",
+ disabled=Disabled, disabledTooltip="the module",
+ min=24, max=72, step=1,
+ getValue=function() return Cfg("iconSize") or 44 end,
+ setValue=function(v) Set("iconSize", v); Refresh() end },
+ { type="slider", text="Scale", noCapture=true,
+ disabled=Disabled, disabledTooltip="the module",
+ min=0.5, max=2.0, step=0.01,
+ getValue=function() return ACfg("scale") or 1.0 end,
+ setValue=function(v) ASet("scale", v); Refresh() end })
+ y = y - h
+
+ -----------------------------------------------------------------------
+ -- APPEARANCE
+ -----------------------------------------------------------------------
+ _, h = W:SectionHeader(parent, "APPEARANCE", y); y = y - h
+
+ row, h = W:DualRow(parent, y,
+ { type="toggle", text="Show Cooldowns",
+ disabled=Disabled, disabledTooltip="the module",
+ getValue=function() return Cfg("showCooldowns") ~= false end,
+ setValue=function(v) Set("showCooldowns", v); Refresh() end },
+ { type="toggle", text="Dim Unusable Entries",
+ disabled=Disabled, disabledTooltip="the module",
+ tooltip="Tint an entry that would do nothing right now: red when the "
+ .."target is out of range, blue when you are short of the "
+ .."resource, and gray when the game refuses it for any other "
+ .."reason. Spells and items only -- the game reports every "
+ .."toy as unusable, and a macro's usability depends on what "
+ .."its body resolves to.",
+ getValue=function() return Cfg("showUsability") ~= false end,
+ setValue=function(v) Set("showUsability", v); Refresh() end })
+ do
+ -- The layout tests read the EDITED palette's layout, exactly as
+ -- the page rows they replace did: these keys are profile-wide,
+ -- but whether their row is live is judged against the palette on
+ -- the preview.
+ local notArc = function()
+ return Disabled() or (ACfg("layout") or "ARC") ~= "ARC"
+ end
+ -- hubIcon is read against its DEFAULT rather than true/false
+ -- flatly: the logo is on unless the user has turned it off, so a
+ -- profile that has never touched the key must read as on.
+ local hubIconOff = function()
+ return notArc() or Cfg("hubIcon") == false
+ end
+ local _, displayCogShow = EllesmereUI.BuildCogPopup({
+ title = "Display",
+ captureRegion = row._leftRegion,
+ rows = {
+ { type="toggle", label="Show Center Text",
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return Cfg("showHubText") ~= false end,
+ set=function(v) Set("showHubText", v); Refresh() end },
+ -- Arc only, and not merely because it would look cramped:
+ -- the fan never draws per-slot labels at all. == true,
+ -- not ~= false: labels are off by default, so an
+ -- untouched key reads as off.
+ { type="toggle", label="Show Slot Labels",
+ disabled=notArc, disabledTooltip="the Arc layout",
+ get=function() return Cfg("showLabels") == true end,
+ set=function(v) Set("showLabels", v); Refresh() end },
+ { type="toggle", label="Show Direction Needle",
+ disabled=notArc, disabledTooltip="the Arc layout",
+ get=function() return Cfg("showNeedle") ~= false end,
+ set=function(v) Set("showNeedle", v); Refresh() end },
+ -- Hub art is arc-only: the fan and grid layouts put a
+ -- real entry at the centre, so there is nothing for it to
+ -- sit in.
+ { type="toggle", label="Logo In Center",
+ disabled=notArc, disabledTooltip="the Arc layout",
+ get=function() return Cfg("hubIcon") ~= false end,
+ set=function(v) Set("hubIcon", v); Refresh() end },
+ { type="slider", label="Logo Size", min=16, max=120, step=1,
+ disabled=hubIconOff, disabledTooltip="Logo In Center",
+ get=function() return Cfg("hubIconSize") or 46 end,
+ set=function(v) Set("hubIconSize", v); Refresh() end },
+ -- The centre text draws over the logo, so this is the
+ -- control that keeps the name readable rather than pure
+ -- decoration.
+ { type="slider", label="Logo Opacity", min=0.05, max=1.0, step=0.01,
+ disabled=hubIconOff, disabledTooltip="Logo In Center",
+ get=function() return Cfg("hubIconAlpha") or 0.55 end,
+ set=function(v) Set("hubIconAlpha", v); Refresh() end },
+ -- Stored 0..1 internally; displayed 0..100 to the user.
+ { type="slider", label="Background Opacity", min=0, max=100, step=5,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return (Cfg("bgAlpha") or 0.65) * 100 end,
+ set=function(v) Set("bgAlpha", v / 100); Refresh() end },
+ },
+ })
+ MakeCogBtn(row._leftRegion, displayCogShow)
+ end
+ y = y - h
+
+ -- Swatch and the toggle that overrides it share a row, in that order
+ -- -- the ActionBars interactions row does the same
+ -- (EUI_ActionBars_Options.lua:5569). A lone colorpicker cfg makes
+ -- DualRow build a full-width half, which is both wrong here and where
+ -- the swatch went unclickable.
+ row, h = W:DualRow(parent, y,
+ { type="colorpicker", text="Selection Color",
+ disabled=function() return Disabled() or Cfg("useClassColor") == true end,
+ disabledTooltip=function()
+ if Cfg("useClassColor") == true then
+ return "This option requires Use Class Color to be disabled"
+ end
+ return "This option requires the module to be enabled"
+ end,
+ rawTooltip=true,
+ getValue=function()
+ local c = Cfg("selectColor") or {}
+ return c[1] or 0.047, c[2] or 0.824, c[3] or 0.624
+ end,
+ setValue=function(r, g, b)
+ Set("selectColor", { r, g, b })
+ Refresh()
+ end },
+ { type="toggle", text="Use Class Color",
+ disabled=Disabled, disabledTooltip="the module",
+ getValue=function() return Cfg("useClassColor") == true end,
+ setValue=function(v) Set("useClassColor", v); RebuildPage() end })
+ do
+ local falloffOff = function()
+ return Disabled() or ACfg("falloff") == false
+ end
+ local _, selectionCogShow = EllesmereUI.BuildCogPopup({
+ title = "Selection Effects",
+ captureRegion = row._leftRegion,
+ rows = {
+ { type="slider", label="Selected Slot Zoom",
+ min=1.0, max=1.6, step=0.01,
+ disabled=Disabled, disabledTooltip="the module",
+ get=function() return Cfg("selectedZoom") or 1.15 end,
+ set=function(v) Set("selectedZoom", v); Refresh() end },
+ -- One switch for every layout: the entry under the cursor
+ -- is drawn at full size and full strength and its
+ -- neighbours draw back, which is a step round the ring,
+ -- along the strip or across the grid depending on which
+ -- one is open.
+ { type="toggle", label="Proximity Falloff", noCapture=true,
+ disabled=Disabled, disabledTooltip="the module",
+ tooltip="Draw entries smaller and fainter the further they "
+ .."are from the one under the cursor. Off draws every "
+ .."entry at full size, and spreads a Fan out to even "
+ .."spacing.",
+ get=function() return ACfg("falloff") ~= false end,
+ set=function(v) ASet("falloff", v); Refresh() end },
+ -- The two falloffs are per-STEP ratios, not absolutes:
+ -- 0.72 means each entry out from the centre is 72% of the
+ -- one before it. Near the top of the travel they flatten
+ -- out.
+ { type="slider", label="Size Falloff", noCapture=true,
+ min=0.40, max=0.95, step=0.01,
+ disabled=falloffOff, disabledTooltip="Proximity Falloff",
+ get=function() return ACfg("fanScaleDecay") or 0.72 end,
+ set=function(v) ASet("fanScaleDecay", v); Refresh() end },
+ { type="slider", label="Fade Falloff", noCapture=true,
+ min=0.20, max=0.95, step=0.01,
+ disabled=falloffOff, disabledTooltip="Proximity Falloff",
+ get=function() return ACfg("fanAlphaDecay") or 0.62 end,
+ set=function(v) ASet("fanAlphaDecay", v); Refresh() end },
+ },
+ })
+ MakeCogBtn(row._leftRegion, selectionCogShow)
+ end
+ y = y - h
+
+ _, h = W:Spacer(parent, y, 10); y = y - h
+
+ return math.abs(y)
+ end
+
+ ---------------------------------------------------------------------------
+ -- Register the module
+ ---------------------------------------------------------------------------
+ EllesmereUI:RegisterModule("EllesmereUIActionPalette", {
+ title = "Action Palette",
+ description = "Hold a keybind to open a palette of actions; point or scroll to choose, release to fire.",
+ pages = { PAGE_DISPLAY },
+ buildPage = BuildPage,
+ onReset = function()
+ -- Lite DB stores data at
+ -- EllesmereUIDB.profiles[X].addons.EllesmereUIActionPalette
+ if EllesmereUIDB and EllesmereUIDB.profiles then
+ local profile = EllesmereUIDB.activeProfile or "Default"
+ local p = EllesmereUIDB.profiles[profile]
+ if p and p.addons and p.addons.EllesmereUIActionPalette then
+ wipe(p.addons.EllesmereUIActionPalette)
+ end
+ end
+ local target = _G._EAP_AceDB
+ if target and target.profile and ns.DB_DEFAULTS then
+ EllesmereUI.Lite.DeepMergeDefaults(target.profile, ns.DB_DEFAULTS.profile)
+ end
+ if _G._EAP_Apply then _G._EAP_Apply() end
+ -- The reset leaves one palette, so the editor cannot stay pointed
+ -- at whichever one it was on.
+ editPalette = 1
+ end,
+ })
+end)
diff --git a/EllesmereUIActionPalette/EllesmereUIActionPalette.lua b/EllesmereUIActionPalette/EllesmereUIActionPalette.lua
new file mode 100644
index 000000000..13eb9c77d
--- /dev/null
+++ b/EllesmereUIActionPalette/EllesmereUIActionPalette.lua
@@ -0,0 +1,6965 @@
+-------------------------------------------------------------------------------
+-- EllesmereUIActionPalette.lua -- hold-to-open action palette for EllesmereUI
+--
+-- Hold a keybind -> a set of slots appears. Choose one, release the key to
+-- fire it. Releasing without having chosen cancels, and so does ESCAPE, which
+-- every layout answers to for as long as it is open.
+--
+-- One palette of actions, drawn and steered three ways:
+--
+-- ARC entries spread over `arcSpan` degrees, steered by the ANGLE from
+-- the centre. The sectors are unbounded in depth, so the gesture is
+-- a flick rather than a click. A span of 360 is the whole turn.
+-- FAN a strip running along one axis, horizontal or vertical by
+-- `fanOrientation`. Scroll-steered it cycles a compressed window
+-- past a fixed centre; pointer-steered it is a GRID one entry deep.
+-- GRID every entry at a fixed cell, the nearest one zoomed.
+--
+-- The layouts differ in INPUT MODEL -- angle, scroll-cycle, pointer-nearest --
+-- which is why they are separate rather than parameters of one another. The
+-- span is the exception: it is a parameter of the angular model, so the full
+-- turn the module opened life as is simply the arc's 360-degree case.
+--
+-- Each palette owns one hidden SecureActionButtonTemplate button; the palette's
+-- keybind is routed to it with SetOverrideBindingClick, and it is registered
+-- for "AnyDown","AnyUp":
+--
+-- key DOWN -> our PreClick opens the palette; a secure snippet wrapped around
+-- OnClick clears "type", so the press itself fires nothing
+-- key UP -> the snippet works out which entry the cursor is on and writes
+-- that slot's action attributes, the secure handler performs the
+-- cast, and our PostClick closes the palette
+--
+-- The choosing has to happen inside the snippet because an addon may not write
+-- attributes to a protected frame during combat -- see the Secure activation
+-- section for the blocked-action this design was built around. Only the
+-- angular layouts are steered in the snippet so far; the others still commit
+-- from Lua and therefore only fire out of combat.
+--
+-- Protected calls in this file, all of them deferred to PLAYER_REGEN_ENABLED
+-- when in combat: the override-binding updates, and PushPalette's writes of a
+-- palette's contents onto the secure buttons.
+-------------------------------------------------------------------------------
+local ADDON_NAME, ns = ...
+local EAP = EllesmereUI.Lite.NewAddon(ADDON_NAME)
+
+-- The live palette's frame. CreateLiveView fills it in, and this is the one
+-- part of it that is not made there.
+--
+-- The engine bills a script handler's whole call tree to the addon whose
+-- execution context called CreateFrame for the frame that carries the handler.
+-- CreateLiveView is reached from OnEnable, which runs under the parent's
+-- lifecycle dispatch, so a frame made there is stamped EllesmereUI for the
+-- session. This frame carries OnPaletteUpdate. Made there, every frame of
+-- steering and hit testing an open palette does was billed to the parent addon
+-- instead of to this one, which hid the cost of the module that causes it. The
+-- main chunk runs as this addon, so the frame is stamped correctly here.
+--
+-- The event frame in EllesmereUI_Lite.lua is created eagerly for this reason.
+local liveFrame = CreateFrame("Frame", "EUIActionPaletteFrame", UIParent)
+liveFrame:Hide()
+
+-- Upvalues
+local floor, ceil, min, max, abs = math.floor, math.ceil, math.min, math.max, math.abs
+local sin, cos, tan, atan2, sqrt, pi =
+ math.sin, math.cos, math.tan, math.atan2, math.sqrt, math.pi
+local log = math.log
+local tonumber, type, select = tonumber, type, select
+local tinsert, tremove, tsort = table.insert, table.remove, table.sort
+local GetCursorInfo, ClearCursor = GetCursorInfo, ClearCursor
+local GetCursorPosition = GetCursorPosition
+local GetBindingKey = GetBindingKey
+local InCombatLockdown = InCombatLockdown
+local GetTime = GetTime
+
+local TWO_PI = pi * 2
+local QUESTION_MARK = "Interface\\Icons\\INV_Misc_QuestionMark"
+
+-- Palette / slot limits. MAX_PALETTES must match the number of
+-- entries in Bindings.xml: every palette can carry a key of its own, so no
+-- palette is ever reachable only by being nested inside another one. A key is
+-- what builds a palette's secure button, so the ones left unbound cost nothing.
+local MAX_PALETTES = 16
+local MAX_SLOTS = 12
+
+-- Entries a nested palette contributes through a parent whose nest is bounded
+-- by the PARENT'S own region -- a sector of the arc, a halo's ring of eight
+-- fixed positions. Eight is where those stop being readable. Every other nest
+-- runs along ground of its own -- a block's perimeter, a row across a strip --
+-- and seats a nested palette's full MAX_SLOTS. See NestChildCap, which is
+-- where the per-layout answer lives.
+local MAX_CHILDREN = 8
+
+-- How many concentric rings a nested arc's children may spill into before a
+-- crowded claim just packs its last ring tighter than one child pitch. More
+-- children than the span cap can hold are answered by adding a ring one child
+-- pitch further out (see ChildGeom), not by pushing the existing ring out to
+-- some unbounded radius -- eight entries in a ten-degree sector used to land a
+-- ring three times the width of the palette itself. The cap keeps that answer
+-- bounded on both sides: the live view and the snippet only ever carry
+-- MAX_CHILD_ROWS worth of ring attributes, so a claim that would need a fifth
+-- ring degrades by crowding the fourth instead of drifting the two out of
+-- step with each other.
+local MAX_CHILD_ROWS = 4
+
+-- How many rect gates a single claim's REGION may be built from. One box
+-- (HALO, whose ring already sits close enough round its parent that the old
+-- bounding box was the true shape), three for a nest that sits in one piece
+-- off one side of the block (the parent's own cell, the nest's own tight box,
+-- and a corridor one child cell wide connecting them), and five for a lane
+-- with the block to itself: the parent's cell plus one box per side of the
+-- block its run reached, and a full run can wrap onto all four. One
+-- box across the lot instead would swallow the block's own corner ground --
+-- see PerimeterNest, the "Arming gates" section and RunReach below.
+--
+-- Nine is what a lane sharing the block with OTHER claims comes to. Each of
+-- those has its own cell taken out of this claim's coverage (see ParentHoles),
+-- which splits the side it falls on into at most a slab clear of it and one
+-- interval reaching back to the parent -- the pieces past it are dropped, being
+-- ground this claim cannot be armed on anyway. Three sides carrying a hole is
+-- the worst that comes up: eight for any two claims and nine for any three,
+-- swept over every arrangement of them on a 2x2, 6-, 9- and 12-slot block at
+-- both child counts that change the answer and at every nest scale, and a block
+-- with EVERY slot nesting stays inside it too. Past nine the tail is dropped,
+-- child-bearing pieces being written first, so a palette that did overflow would
+-- lose ground between its entries rather than a child.
+local REGION_MAX = 9
+
+-- The Nest Distance the profile ships with, named because the lane style
+-- reads it as a baseline rather than as a distance: a lane hugs the block, and
+-- what it takes off the slider is only whatever the user asked for BEYOND
+-- this. Every other style measures its whole stand-off from the slider.
+local NEST_BAND_DEFAULT = 40
+
+-- The binding ACTION name, and it keeps the module's first name for good. WoW
+-- stores a keybind against this string, so renaming it would unbind every
+-- palette every user has set. The name is never shown: BINDING_NAME_
+-- below is what the Keybindings page reads.
+local BINDING_PREFIX = "EUI_RADIAL"
+
+-- DIALOG is also the options window's strata (EllesmereUI.lua:7126), which is
+-- fine: the palette only exists on screen while a key is held.
+local LIVE_STRATA = "DIALOG"
+
+-------------------------------------------------------------------------------
+-- Database
+--
+-- Everything under DB_DEFAULTS.profile lives in the suite's central store, at
+-- EllesmereUIDB.profiles[].addons.EllesmereUIActionPalette (see
+-- EllesmereUI.Lite.NewDB). That placement is the whole profile integration:
+-- the data follows whichever profile the character resolves to, a profile
+-- swap or import repoints db.profile and reaches _EAP_Apply through
+-- RefreshAllAddons (EllesmereUI_Profiles.lua:1433), and profile export and
+-- import carry the table because the module is listed in ADDON_DB_MAP
+-- (EllesmereUI_Profiles.lua:83). A new setting only needs a default here to
+-- be per-profile and exportable -- there is nothing else to register.
+--
+-- Two kinds of state must NOT be added under profile: per-character or
+-- account-wide state (keybinds, for example, stay in WoW's binding system),
+-- and anything read outside db.profile through a cached reference (P() is
+-- the accessor that stays correct across a swap). If the module ever grows
+-- unlock-anchored elements, their key prefix must also be added to
+-- KEY_PREFIX_FOLDER in EllesmereUI_Profiles.lua.
+-------------------------------------------------------------------------------
+local DB_DEFAULTS = {
+ profile = {
+ -- Off until the user asks for it: a palette needs a key bound to it
+ -- before it can do anything, so shipping it on would only cost a
+ -- session that never wanted it.
+ enabled = false,
+
+ -- Placement. posX/posY are a UIParent-LOGICAL delta from UIParent's
+ -- center, i.e. independent of the palette's own scale -- the same
+ -- convention MythicTimer's standalonePos uses
+ -- (EllesmereUIMythicTimer.lua:2651). PositionPalette divides by scale at
+ -- apply time, because SetPoint offsets live in the frame's own scaled
+ -- space; without that, changing Scale would also move the palette.
+ centerMode = "CURSOR", -- CURSOR | SCREEN
+ posX = 0,
+ posY = 0,
+
+ -- Layout. ARC steers with the cursor's angle; FAN is a coverflow
+ -- strip scrubbed with the mouse wheel, which keeps working while the
+ -- right button is held to steer the camera and the cursor is therefore
+ -- frozen. Orientation is a property of the strip, not a layout of its
+ -- own: the two axes differ only in which way the entries run.
+ layout = "ARC", -- ARC | FAN | GRID
+ fanOrientation = "HORIZONTAL", -- HORIZONTAL | VERTICAL
+ gridAutoColumns = true, -- near-square, sized to what the palette holds
+ gridColumns = 4, -- used only when gridAutoColumns is off
+
+ -- Arc. 360 is a full turn. Anything less fans the entries across a
+ -- sector centred on arcRotation (0 = straight up, growing clockwise),
+ -- which keeps a palette clear of a screen edge and gives a nested palette
+ -- somewhere to open that does not cover its parent.
+ arcSpan = 360, -- degrees, 30..360
+ arcRotation = 0, -- degrees, direction the arc is centred on
+
+ -- Nesting. A slot of kind "palette" opens another palette's entries one
+ -- level further out, on ground reached through the parent entry itself
+ -- -- see ChildGeom.
+ -- A clear GAP between the parent's icon and its children, not a
+ -- centre-to-centre radius: measured centre to centre it has to cover
+ -- both icons' halves before it separates anything at all, and at any
+ -- ordinary icon size the two rings came out touching.
+ nestBand = NEST_BAND_DEFAULT,
+ nestScale = 0.8, -- child icon size, against the palette's own
+ -- Which side of a block layout the nested entries hang off. Only
+ -- consulted where the sides are equidistant -- a strip is one entry
+ -- deep, so both of its long sides are -- because otherwise the side
+ -- NEAREST the parent cell wins, and that is decided by the cell's
+ -- position rather than by anything the user has to think about.
+ nestSide = "POSITIVE", -- POSITIVE | NEGATIVE (above/right, below/left)
+ -- Where a GRID puts a nest. A strip ignores this: one row or column has
+ -- no interior to lay a lane or halo into, so it always builds a small
+ -- block of its own, centred on the parent and broken out across the
+ -- strip.
+ -- PERIMETER a lane hugging the block, centred on the point of it
+ -- nearest the parent and wrapping the corners when long
+ -- HALO the eight positions around the parent, block faded behind
+ -- A retired POPOUT value -- the nested palette as a detached block --
+ -- reads as PERIMETER; see NestMetrics.
+ gridNestStyle = "PERIMETER",
+ -- How far along the arc a nest may spread. NONE stops at the midpoint
+ -- with the next NEST either side, MIDPOINT spends the whole span cap
+ -- whatever is out there; both may cross the plain entries in between.
+ arcChildOverflow = "NONE", -- NONE | MIDPOINT
+ arcChildMaxSpan = 90, -- degrees, the widest a child arc may grow
+
+ -- Geometry
+ radius = 96,
+ iconSize = 44,
+ deadZone = 24,
+ scale = 1.0,
+
+ -- Fan geometry. Both decays are per-step multipliers away from the
+ -- centre, so one number describes the whole falloff. The floors keep
+ -- distant entries legible instead of letting them vanish, and matter
+ -- most on the options preview, which draws the whole palette at once.
+ --
+ -- falloff is the whole depth cue, on or off, for every layout at once:
+ -- the arc, the grid and both strips all read the same two decays, so a
+ -- palette that draws its entries flat in one mode draws them flat in
+ -- all of them. The strip spaces itself off the size falloff as well, so
+ -- switching it off there also spreads the strip out to full pitch.
+ falloff = true,
+ fanVisible = 3, -- entries drawn each side of the centre
+ fanGap = 10,
+ fanScaleDecay = 0.72,
+ fanAlphaDecay = 0.62,
+ fanMinScale = 0.30,
+ fanMinAlpha = 0.12,
+ fanAnimTime = 0.10, -- seconds for the strip to settle
+ fanInvert = false, -- flip which way a scroll tick travels
+
+ -- How a fan is steered. SCROLL cycles a window of the palette past a fixed
+ -- centre. CURSOR lays the WHOLE palette out at fixed positions and zooms
+ -- whichever entry the pointer is nearest, so it needs no wheel at all.
+ fanInput = "SCROLL", -- SCROLL | CURSOR
+
+ -- Flick-ahead. The arc's entries are unbounded in depth, so a gesture
+ -- can be finished before the palette has even faded in. Holding it back for
+ -- a moment lets an expert flick without a menu ever appearing, while a
+ -- hesitant press still gets the full display. Selection is live the
+ -- whole time -- only the drawing waits.
+ flickAhead = true,
+ flickDelay = 0.12, -- seconds held before the palette fades in
+ flickFade = 0.10, -- seconds the fade itself takes
+
+ -- Appearance
+ -- Hub art. hubIcon draws the EllesmereUI logo in the middle; turn it
+ -- off for a small additive star instead. Arc only -- the fan and grid
+ -- layouts put a real entry at the centre, so the hub draws no art
+ -- there at all.
+ hubIcon = true,
+ hubIconSize = 46,
+ hubIconAlpha = 0.55,
+
+ -- Off by default: the icons read as a palette on their own, and the
+ -- hub caption plus the tooltip already name whatever is selected.
+ showLabels = false,
+ showHubText = true,
+ showNeedle = true,
+ showCooldowns = true,
+ -- Tint an entry that would do nothing right now: red out of range,
+ -- blue short of the resource, gray and desaturated for anything else
+ -- the game refuses. The same three cues an action button gives.
+ showUsability = true,
+ selectedZoom = 1.15,
+ bgAlpha = 0.65,
+ selectColor = { 0.047, 0.824, 0.624 }, -- EllesmereUI teal (#0cd29f)
+ useClassColor = false,
+
+ paletteCount = 1,
+ -- palette.slots is a DENSE, ORDERED array: the palette auto-sizes to what the
+ -- user has actually assigned, so three actions means three big entries
+ -- rather than three icons and five dead gaps. Order is the entry order,
+ -- clockwise from 12 o'clock, and is what the editor reorders.
+ palettes = {
+ [1] = { name = "Palette 1", slots = {} },
+ },
+ },
+}
+ns.DB_DEFAULTS = DB_DEFAULTS
+
+-- The name a palette carries until the user types one of their own. It is also
+-- what an emptied name box reverts to, and the only name the legacy rename
+-- below may replace, so all three read it from here.
+local function AutoPaletteName(index)
+ return "Palette " .. index
+end
+ns.AutoPaletteName = AutoPaletteName
+
+-- Names the module has outgrown, converted in place. The defaults have already
+-- been merged in by the time this runs, so each of these takes the old value
+-- wholesale rather than merging it: whatever the defaults seeded under the new
+-- name is a fresh empty, never something to keep. Clearing the old key is what
+-- makes a second run a no-op.
+local function MigrateNames(p)
+ -- The horizontal and vertical strips were once two layouts. They differed
+ -- only in which axis they ran along, so they are one layout with an
+ -- orientation now.
+ if p.layout == "FAN_H" or p.layout == "FAN_V" then
+ p.fanOrientation = p.layout == "FAN_V" and "VERTICAL" or "HORIZONTAL"
+ p.layout = "FAN"
+ end
+
+ -- RADIAL was what the arc was called while a full circle was the only thing
+ -- it could draw. The layout is unchanged; only the word for it is.
+ if p.layout == "RADIAL" then p.layout = "ARC" end
+
+ -- A set of actions was a "ring" for the same reason, and stopped being one
+ -- the moment it could be drawn as a strip or a grid.
+ if p.rings then p.palettes, p.rings = p.rings, nil end
+ if p.ringCount then p.paletteCount, p.ringCount = p.ringCount, nil end
+ -- Auto-generated names only. A palette the user has named keeps its name.
+ for i, palette in pairs(p.palettes or {}) do
+ if palette.name == "Ring " .. i then palette.name = AutoPaletteName(i) end
+ -- A nested entry used to be handed a COPY of the palette's name when it
+ -- was created, which then went stale the moment that palette was
+ -- renamed. SlotDisplay reads the palette's own name whenever the entry
+ -- carries none, so the copy is simply dropped.
+ for _, slot in pairs(palette.slots or {}) do
+ if slot.kind == "palette" then slot.name = nil end
+ end
+ end
+end
+
+local db
+
+-- Every profile is converted on FIRST TOUCH rather than once at load. Switching
+-- profile repoints db.profile at a different table without reloading the UI
+-- (EllesmereUI_Profiles.lua:745), and a per-spec profile is resolved only after
+-- OnInitialize has run -- so migrating "the profile that was active at load"
+-- would leave both of those unconverted, reading the default-seeded empty
+-- palette while the user's own sat under the old key. Worse, the next login
+-- would then migrate over the top of whatever they had edited in the meantime.
+--
+-- Weak keys: the memo must not keep a profile table alive after the profile
+-- itself is deleted.
+local migrated = setmetatable({}, { __mode = "k" })
+local function P()
+ local p = db and db.profile
+ if p and not migrated[p] then
+ migrated[p] = true
+ MigrateNames(p)
+ end
+ return p
+end
+-- Exported so the options page reads the profile through the same accessor
+-- rather than reaching into db.profile itself, which would skip the migration
+-- above on whichever side happened to touch a switched-in profile first.
+ns.Profile = P
+
+-- Does a blob hold anything a user actually arranged? Only the profile that is
+-- ALREADY LIVE needs asking: the login pass runs before the defaults are merged
+-- and can simply test the destination for emptiness, while a live destination
+-- always holds the whole defaults table and would never read as empty.
+local function HasPalettes(blob)
+ local palettes = type(blob) == "table" and blob.palettes
+ if type(palettes) ~= "table" then return false end
+ for _, palette in pairs(palettes) do
+ if type(palette) == "table" and type(palette.slots) == "table"
+ and next(palette.slots) ~= nil then
+ return true
+ end
+ end
+ return false
+end
+
+-- One profile's move off the pre-rename key. See MigrateLegacySV for what the
+-- rename was and why current data wins.
+--
+-- live is the table the module is already reading through, passed only for the
+-- profile that is active right now: the blob is poured INTO it rather than the
+-- key repointed, because a swap would strand db.profile and every other holder
+-- of that reference on the table they were handed.
+local function MigrateLegacyProfile(prof, live)
+ local addons = type(prof) == "table" and prof.addons
+ if type(addons) ~= "table" then return end
+ local legacy = addons.EllesmereUIRadialWheel
+ if legacy == nil then return end
+
+ if type(legacy) == "table" then
+ if live then
+ if HasPalettes(legacy) and not HasPalettes(live) then
+ wipe(live)
+ for k, v in pairs(legacy) do live[k] = v end
+ EllesmereUI.Lite.DeepMergeDefaults(live, DB_DEFAULTS.profile)
+ -- Converted by the next P(), like any switched-in profile: what
+ -- was just poured in carries pre-rename field names.
+ migrated[live] = nil
+ end
+ else
+ local dest = addons.EllesmereUIActionPalette
+ if type(dest) ~= "table" or next(dest) == nil then
+ addons.EllesmereUIActionPalette = legacy
+ end
+ end
+ end
+ -- Dropped either way, which is what makes a second pass a no-op.
+ addons.EllesmereUIRadialWheel = nil
+end
+
+-- The same move for the profile in use, so a profile STRING imported from a
+-- pre-rename build heals when it is applied rather than at the next login.
+local function MigrateActiveProfile()
+ local live = db and db.profile
+ if not live then return end
+ local profiles = EllesmereUIDB and EllesmereUIDB.profiles
+ local prof = profiles and db._profileName and profiles[db._profileName]
+ MigrateLegacyProfile(prof, live)
+end
+
+-- Palettes past the first are created on demand: the defaults table only seeds
+-- palette 1, so DeepMergeDefaults never has to know how many the user wants.
+local function EnsurePalette(index)
+ local p = P()
+ -- nil is an answer, not an error: ChildIndex hands one back for a
+ -- nested-palette slot whose palette number is missing or out of range, and
+ -- its callers pass it straight through on their way to the question-mark
+ -- fallback. Comparing it would take the paint of the whole containing
+ -- palette down with it.
+ if not p or not index or index < 1 or index > MAX_PALETTES then return nil end
+ if not p.palettes then p.palettes = {} end
+ local palette = p.palettes[index]
+ if not palette then
+ palette = { name = AutoPaletteName(index), slots = {} }
+ p.palettes[index] = palette
+ end
+ if type(palette.slots) ~= "table" then palette.slots = {} end
+
+ -- Self-healing compaction. The array must have no holes for #slots to be
+ -- meaningful, and a hole is exactly what a cleared slot used to leave
+ -- behind under the old fixed-slot-count model. Also enforces MAX_SLOTS.
+ local dense, n = {}, 0
+ for i = 1, MAX_SLOTS do
+ local slot = palette.slots[i]
+ if slot and slot.kind then
+ n = n + 1
+ dense[n] = slot
+ end
+ end
+ palette.slots = dense
+ palette.slotCount = nil -- retired: the count is now derived from #slots
+ return palette
+end
+ns.EnsurePalette = EnsurePalette
+
+-- A palette as it already stands, with no compaction and nothing created. For
+-- the READERS on a steering path: EnsurePalette allocates a slot array and
+-- writes it back into the profile on every call, which is the wrong thing to do
+-- once per cursor movement. A palette that has never been ensured has no
+-- entries to hand back anyway, and every write site still goes through
+-- EnsurePalette, so what this reads is always compact by the time it exists.
+local function ReadPalette(index)
+ local p = P()
+ if not p or not index or index < 1 or index > MAX_PALETTES then return nil end
+ local palette = p.palettes and p.palettes[index]
+ if type(palette) ~= "table" then return nil end
+ if type(palette.slots) ~= "table" then return nil end
+ return palette
+end
+
+-------------------------------------------------------------------------------
+-- Per-palette appearance
+--
+-- A palette carries its own SHAPE and PLACE: which layout it is drawn as,
+-- where it opens, how big, and every setting that only means anything inside
+-- one of those layouts. Everything else -- the selection color, the hub art,
+-- the labels, the nesting geometry, flick-ahead -- stays profile-wide, because
+-- those describe the module's look rather than one palette's arrangement, and
+-- a suite of palettes that each looked different would read as several addons.
+--
+-- Every one of these is an OVERRIDE, not a value: a palette that has never
+-- been given one reads the profile's, which is what makes this change invisible
+-- to a profile written before it existed. That is also the whole of the
+-- saved-variables migration -- there is nothing to move, because the old flat
+-- keys are exactly the fallback the new ones fall back to.
+--
+-- Stored under palette.appearance rather than flat on the palette. A palette
+-- already carries name, icon and slots, and a flat store would put profile
+-- keys in the same namespace as those -- fine for today's key set and a trap
+-- for the first profile key ever named "icon".
+-------------------------------------------------------------------------------
+local APPEARANCE_KEYS = {
+ layout = true, fanOrientation = true, fanInput = true,
+ centerMode = true, posX = true, posY = true, scale = true,
+ gridAutoColumns = true, gridColumns = true,
+ arcSpan = true, arcRotation = true,
+ fanVisible = true, fanGap = true, fanAnimTime = true, fanInvert = true,
+ falloff = true, fanScaleDecay = true, fanAlphaDecay = true,
+}
+ns.APPEARANCE_KEYS = APPEARANCE_KEYS
+
+-- The overrides table for a palette, created on demand. Only the options page
+-- writes here; everything else reads through the view below.
+function ns.PaletteAppearance(palette, create)
+ if not palette then return nil end
+ if not palette.appearance and create then palette.appearance = {} end
+ return palette.appearance
+end
+
+-- One READ-ONLY view per palette, with that palette's overrides in front of
+-- the profile. Handing this back as `p` is what let the whole renderer stay
+-- written as `p.layout`: the fallback lives in one metatable instead of at
+-- sixty call sites, none of which could have been left out safely.
+--
+-- Keyed by the palette TABLE rather than by its index: deleting a palette
+-- shifts every palette above it down one, and switching profile replaces the
+-- lot. An override belongs to the palette, and so does its view.
+--
+-- Never pruned, and not bounded by MAX_PALETTES either: deleting a palette and
+-- adding one leaves the deleted table in here, alive, for the rest of the
+-- session. Each entry is one empty table and one metatable, and palettes are
+-- added and deleted by hand on a settings page, so the ceiling is what a person
+-- can be bothered to click.
+local appearanceViews = {}
+
+local function PA(paletteIndex)
+ local p = P()
+ if not p then return nil end
+ -- Straight off p.palettes, NOT through EnsurePalette: this runs several
+ -- times per frame on every steered layout, and EnsurePalette rebuilds the
+ -- slot array to compact it. A palette that has never been ensured has no
+ -- overrides to read anyway, so the profile is the whole answer.
+ local palette = paletteIndex and p.palettes and p.palettes[paletteIndex]
+ if type(palette) ~= "table" then return p end
+
+ local view = appearanceViews[palette]
+ if not view then
+ view = setmetatable({}, {
+ __index = function(_, key)
+ if APPEARANCE_KEYS[key] then
+ local v = palette.appearance and palette.appearance[key]
+ if v ~= nil then return v end
+ end
+ -- P() rather than a captured profile: a palette table outlives
+ -- nothing, but reading through the accessor keeps the migration
+ -- on first touch running for whichever profile is current.
+ local prof = P()
+ return prof and prof[key]
+ end,
+ -- Nothing writes through this, and a write that slipped in would
+ -- land on the view and be invisible to the saved variables.
+ __newindex = function() error("Action Palette appearance view is read-only", 2) end,
+ })
+ appearanceViews[palette] = view
+ end
+ return view
+end
+ns.PaletteProfile = PA
+
+-- Ordered mutations. All three keep the array dense so #slots stays the
+-- authoritative entry count.
+function ns.AddSlot(palette, slot)
+ if not palette or not slot then return nil end
+ if #palette.slots >= MAX_SLOTS then return nil end
+ palette.slots[#palette.slots + 1] = slot
+ return #palette.slots
+end
+
+function ns.RemoveSlot(palette, index)
+ if not palette or not palette.slots[index] then return false end
+ tremove(palette.slots, index)
+ return true
+end
+
+-- Move, not swap: dragging an icon between two others should insert it there
+-- and shuffle the rest along, which is what a reorder is.
+function ns.MoveSlot(palette, from, to)
+ if not palette then return false end
+ local n = #palette.slots
+ if from == to or from < 1 or from > n or to < 1 or to > n then return false end
+ tinsert(palette.slots, to, tremove(palette.slots, from))
+ return true
+end
+
+-- How many palettes EXIST. Each of them has a entry of its own, so
+-- this is also the range every loop that claims a key or pushes actions runs
+-- over -- a palette can be opened by a key, nested inside another palette, or
+-- both.
+local function PaletteCount()
+ local p = P()
+ return min(MAX_PALETTES, max(1, (p and p.paletteCount) or 1))
+end
+ns.PaletteCount = PaletteCount
+
+-------------------------------------------------------------------------------
+-- Nesting
+--
+-- A slot of kind "palette" names another palette by index. The palette it
+-- names is an ordinary one -- it may carry a keybind as well, or exist purely
+-- to be nested.
+--
+-- ONE level. A claim's ground is measured from the parent entry it hangs off,
+-- and a nested entry has no ground of its own for a further claim to be
+-- measured from: a palette slot INSIDE a nested palette is drawn but fires
+-- nothing.
+--
+-- A CLAIM's cells only answer at all once the cursor has gone through the
+-- claim's own parent entry first, and stop answering once it leaves the
+-- claim's ground -- see ArmedClaim, EnsureGates and the two gate frames every
+-- claim gets. That is PATH-dependent, so the final cursor position alone is
+-- no longer the whole answer: which claim, if any, is armed is state the
+-- secure sandbox has to carry across the hold, which is what the gates are
+-- for.
+-------------------------------------------------------------------------------
+
+-- The palette a slot opens, or nil for a slot that fires an action.
+local function ChildIndex(slot)
+ if not slot or slot.kind ~= "palette" then return nil end
+ local idx = tonumber(slot.palette)
+ if not idx or idx < 1 or idx > MAX_PALETTES then return nil end
+ return idx
+end
+ns.ChildIndex = ChildIndex
+
+-- The reachable entries of a nested palette. Capped rather than refused, so a
+-- palette that is also bound to a key keeps all twelve of its slots when it is
+-- opened directly and offers what its parent's layout can seat when it is
+-- nested -- the caller says how many via cap, and MAX_CHILDREN is the floor
+-- every layout can manage. See NestChildCap.
+local function ChildSlots(paletteIndex, cap)
+ local palette = paletteIndex and EnsurePalette(paletteIndex)
+ if not palette then return nil end
+ local out = {}
+ for i = 1, min(cap or MAX_CHILDREN, #palette.slots) do
+ out[i] = palette.slots[i]
+ end
+ return out, palette
+end
+ns.ChildSlots = ChildSlots
+
+-- How many entries a nested palette may contribute on palette `parentIndex`,
+-- read off the stored profile -- what the editor's tooltip answers with, and
+-- what the live views answer too, their layout following the same profile.
+-- Eight is where a nest bounded by its PARENT'S own region stops being
+-- readable, and the two nests bounded that way stay there: an arc's children
+-- hold a sector of the parent's arc, and a halo is eight positions with
+-- nothing to grow into. Everything else runs along ground of its own -- a
+-- lane holds twenty cells and more at ordinary sizes, and a strip's row
+-- spreads as wide as it needs to -- so it seats a nested palette whole.
+local function NestChildCap(parentIndex)
+ local p = PA(parentIndex)
+ local layout = (p and p.layout) or "ARC"
+ if layout == "ARC" then return MAX_CHILDREN end
+ if layout == "GRID" and p and p.gridNestStyle == "HALO" then
+ return MAX_CHILDREN
+ end
+ return MAX_SLOTS
+end
+ns.NestChildCap = NestChildCap
+
+-- May `child` be nested inside `parent`? No for a palette inside itself, and no
+-- for any chain that would close a loop -- A holding B holding A. Checked when
+-- the slot is CREATED rather than when it is walked: a stored cycle would send
+-- every push and every draw of that palette round until the client gave out.
+function ns.CanNest(parentIndex, childIndex)
+ if not parentIndex or not childIndex then return false end
+ if parentIndex == childIndex then return false end
+
+ -- Walk down from the candidate child. Reaching the parent means the parent
+ -- already sits somewhere below it, so nesting it would close the loop. The
+ -- seen set also bounds the walk over data that is ALREADY cyclic, which a
+ -- profile edited by hand or carried over from an older build may be.
+ local seen, stack = { [childIndex] = true }, { childIndex }
+ while #stack > 0 do
+ local idx = tremove(stack)
+ if idx == parentIndex then return false end
+ local palette = EnsurePalette(idx)
+ for i = 1, (palette and #palette.slots or 0) do
+ local c = ChildIndex(palette.slots[i])
+ if c and not seen[c] then
+ seen[c] = true
+ stack[#stack + 1] = c
+ end
+ end
+ end
+ return true
+end
+
+local function SelectColor()
+ local p = P()
+ if p and p.useClassColor then
+ local c = RAID_CLASS_COLORS and RAID_CLASS_COLORS[select(2, UnitClass("player"))]
+ if c then return c.r, c.g, c.b end
+ end
+ local sc = p and p.selectColor
+ if sc then return sc[1] or 1, sc[2] or 1, sc[3] or 1 end
+ return 0.047, 0.824, 0.624
+end
+ns.SelectColor = SelectColor
+ns.MAX_SLOTS = MAX_SLOTS
+ns.REGION_MAX = REGION_MAX
+ns.MAX_PALETTES = MAX_PALETTES
+ns.MAX_CHILDREN = MAX_CHILDREN
+
+-------------------------------------------------------------------------------
+-- Slot model
+--
+-- A slot is { kind = , ... }. Everything the secure button needs is
+-- derived from the slot at click time by ResolveAction; everything the UI
+-- needs is derived by SlotDisplay. Both are pure lookups over the stored
+-- ids, so a slot never caches a stale icon or name across a patch.
+-------------------------------------------------------------------------------
+
+-- Both marker kinds store the ICON position: 1..8 in the star-to-skull order
+-- every marker UI shows, 0 for the entry that clears instead of placing. The
+-- engine numbers its WORLD markers differently, so the world kind maps the
+-- stored position onto the engine's number before it names one.
+--
+-- The engine runs blue, green, purple, red, yellow, orange, silver, white --
+-- square, triangle, diamond, cross, star, circle, moon, skull. Blizzard's
+-- WORLD_RAID_MARKER_ORDER (Blizzard_CompactRaidFrameManager.lua:5-12) is the
+-- same eight numbers listed SKULL to STAR, which is the order its dropdown
+-- draws them in; read as though it ran star to skull it lands on the marker
+-- mirrored about the middle, which is why star used to place the skull.
+local WORLD_MARKER_ENGINE = { 5, 6, 3, 2, 7, 1, 4, 8 }
+
+local MARKER_NAMES = {
+ "Star", "Circle", "Diamond", "Triangle", "Moon", "Square", "Cross", "Skull",
+}
+
+-------------------------------------------------------------------------------
+-- Cycling marker entries
+--
+-- One entry that walks the eight markers instead of naming one: each press
+-- places the next, star to skull and back to the star. It is the whole set in
+-- a single slot, for a palette that has no room for nine.
+--
+-- The position last placed is kept on the slot, so the run continues across a
+-- reload rather than restarting at the star. It is NOT what the press reads,
+-- though: an insecure SetAttribute is refused in combat, which is exactly when
+-- marking matters, so the secure snippet keeps the authoritative copy and
+-- advances it itself. CyclePosBack hands the snippet's answer back here after
+-- the press, and the push seeds the snippet from it again. Both ends derive
+-- "next" from "last" the same way, so the icon can never advertise a marker
+-- other than the one the press places.
+-------------------------------------------------------------------------------
+local CYCLE_N = 8
+
+-- The macro text each step fires, in the order the entry runs through them.
+-- Both cycles step through the eight ICON positions; the world one maps each
+-- onto the engine's number exactly as a fixed world marker slot does. nil for
+-- every kind that does not cycle, which is what marks a slot as one.
+local function CycleSteps(kind)
+ local out = {}
+ if kind == "cycleraidtarget" then
+ for i = 1, CYCLE_N do out[i] = "/tm " .. i end
+ elseif kind == "cycleworldmarker" then
+ for i = 1, CYCLE_N do out[i] = "/wm " .. WORLD_MARKER_ENGINE[i] end
+ else
+ return nil
+ end
+ return out
+end
+
+-- The icon position the NEXT press places. Derived from the stored one rather
+-- than stored itself, so there is only ever one number to keep in step.
+local function CycleNext(slot)
+ local last = tonumber(slot and slot.cyclePos) or 0
+ if last < 1 or last > CYCLE_N then last = 0 end
+ return last % CYCLE_N + 1
+end
+
+-- The position the snippet advanced to, back onto the slot it belongs to, so
+-- the next push and every icon drawn before it agree with what actually fired.
+-- Called from the release; a cell that does not cycle answers nothing and
+-- leaves the slot alone.
+local function CyclePosBack(btn, idx, slot)
+ if not btn or not idx or not slot then return end
+ local pos = tonumber(btn:GetAttribute("eapCycPos" .. idx))
+ if pos and CycleSteps(slot.kind) then slot.cyclePos = pos end
+end
+
+-- "Summon Random Favorite Mount", used only to DRAW the entry -- its icon and
+-- its localized name. Firing does not cast it: the spell is neither in the
+-- spellbook nor a journal mount, so CastSpellByName resolves it to nothing
+-- and the release fires nothing. The roll is made by
+-- C_MountJournal.SummonByID(0), which is ordinary API -- the Mount Journal's
+-- own button calls it straight from an insecure click handler
+-- (Blizzard_MountCollection.lua:998) -- so the kind fires through
+-- FireInsecure the way a battle pet does.
+local RANDOM_FAVORITE_MOUNT = 150544
+
+local function MarkerIcon(id)
+ return "Interface\\TargetingFrame\\UI-RaidTargetingIcon_" .. id
+end
+
+-- The CURRENT index of the specialization a slot names. A spec slot stores the
+-- specID, which is the same number on every character that has that spec, and
+-- resolves it here -- the index is only a position in one character's list, so
+-- a palette carried to an alt would otherwise point at somebody else's spec.
+-- A spec this character does not have answers nil, and the slot then does
+-- nothing rather than switching to whatever sits at that position.
+local function SpecIndexFor(slot)
+ local want = tonumber(slot and slot.specID)
+ if not want or not C_SpecializationInfo then return nil end
+ local classID = select(3, UnitClass("player"))
+ if not classID then return nil end
+ for i = 1, (C_SpecializationInfo.GetNumSpecializationsForClassID(classID) or 0) do
+ if C_SpecializationInfo.GetSpecializationInfo(i) == want then return i end
+ end
+ return nil
+end
+
+-- kind -> attribute triple for the secure button, plus an optional 4th value:
+-- a sibling attribute key that must be cleared because the same action type
+-- would otherwise read it in preference. Returns nil for the kinds with no
+-- secure action type at all (battlepet, spec, randommount), which
+-- FireInsecure handles instead.
+local function ResolveAction(slot)
+ if not slot or not slot.kind then return nil end
+ local k = slot.kind
+
+ -- A palette opens entries; it never fires one. Returning nothing is what
+ -- makes a release on the parent itself a cancel, which is the only sensible
+ -- reading of "you stopped on the door rather than going through it".
+ if k == "palette" then return nil end
+
+ if k == "spell" then
+ if type(slot.id) ~= "number" then return nil end
+ return "spell", "spell", slot.id
+
+ elseif k == "item" then
+ if type(slot.id) ~= "number" then return nil end
+ return "item", "item", "item:" .. slot.id
+
+ elseif k == "toy" then
+ if type(slot.id) ~= "number" then return nil end
+ return "toy", "toy", slot.id
+
+ elseif k == "macro" then
+ -- Stored by name so reordering the macro list doesn't repoint the
+ -- slot. RunMacro accepts a name, so the name is what we hand over.
+ -- The 4th return clears the sibling key: type="macro" reads "macro"
+ -- first and only falls through to "macrotext" when it is unset
+ -- (SecureTemplates.lua:441). The direction that matters is therefore
+ -- the other branch -- a macrotext slot must clear a stale "macro", or
+ -- the earlier slot's macro name wins. This branch clears macrotext for
+ -- symmetry, so neither key can outlive the slot that set it.
+ local nameOrIndex = slot.name or slot.id
+ if not nameOrIndex then return nil end
+ return "macro", "macro", nameOrIndex, "macrotext"
+
+ elseif k == "macrotext" then
+ if type(slot.macrotext) ~= "string" or slot.macrotext == "" then return nil end
+ return "macro", "macrotext", slot.macrotext, "macro"
+
+ elseif k == "mount" then
+ -- C_MountJournal.SummonByID is protected, so the mount is summoned
+ -- through its own summon spell instead.
+ --
+ -- By NAME, not by id: SECURE_ACTIONS.spell routes a numeric value to
+ -- CastSpellByID and a string to CastSpellByName
+ -- (SecureTemplates.lua:387-395). Mount summon spells do not live in the
+ -- spellbook, and CastSpellByID does nothing for them; CastSpellByName is
+ -- the path a plain "/cast " macro takes, which does work.
+ local mountName, spellID = nil, slot.spellID
+ if slot.id then
+ mountName, spellID = C_MountJournal.GetMountInfoByID(slot.id)
+ spellID = slot.spellID or spellID
+ end
+ -- The spell's own name over the journal's display name: it is what
+ -- CastSpellByName resolves against.
+ local info = type(spellID) == "number" and C_Spell.GetSpellInfo(spellID)
+ local castName = (info and info.name) or mountName
+ if type(castName) ~= "string" or castName == "" then return nil end
+ return "spell", "spell", castName
+
+ elseif k == "raidtarget" then
+ -- Fired as the /tm slash command rather than through
+ -- SECURE_ACTIONS.raidtarget: that action reads TWO attributes, marker
+ -- and action, and the firing end of the snippet pushes exactly one key
+ -- per cell. The command reaches SetRaidTarget itself
+ -- (SlashCommands.lua:1381-1406), and /tm 0 is its documented clear.
+ --
+ -- What makes that legal is the secure button running the macro, NOT
+ -- anything about SetRaidTarget: it is protected, and refuses any call
+ -- an addon makes from its own Lua. See the clearmarkers branch below,
+ -- which was written on the opposite assumption and did not work.
+ local id = tonumber(slot.id)
+ if not id or id < 0 or id > 8 then return nil end
+ return "macro", "macrotext", "/tm " .. id, "macro"
+
+ elseif k == "clearmarkers" then
+ -- SECURE_ACTIONS.raidtarget's own clear-all branch, which calls
+ -- RemoveRaidTargets (SecureTemplates.lua:590-592). SetRaidTarget is
+ -- documented AllowedWhenUntainted and is refused outright from an
+ -- addon's own Lua -- the sweep this replaces raised
+ -- ADDON_ACTION_FORBIDDEN on its first call -- so going through the
+ -- secure button, which runs the action untainted, is the only route.
+ --
+ -- The ACTION rides in the ordinary key/value slot: raidtarget reads
+ -- "marker" and "action", and the firing end of the snippet pushes one
+ -- key per cell. clear-all is the one branch that never looks at
+ -- "marker", so the single key can be spent on "action" instead.
+ --
+ -- It also clears more than the loop could: RemoveRaidTargets reaches
+ -- marks on units the client cannot currently name, which no clear-all
+ -- macro can. In a group it needs lead or assist, and quietly does
+ -- nothing without them -- the same rule as marking itself.
+ return "raidtarget", "action", "clear-all"
+
+ elseif k == "worldmarker" then
+ -- Same one-attribute reasoning as /tm above: /wm places a world
+ -- marker, /cwm clears. Both take the ENGINE's marker number, so the
+ -- stored icon position goes through the map.
+ local id = tonumber(slot.id)
+ if not id or id < 0 or id > 8 then return nil end
+ if id == 0 then
+ -- The /cwm handler compares its argument against the client's own
+ -- ALL string (SlashCommands.lua:824-830), so that string is what
+ -- gets baked -- a hardcoded "all" would fail on a localized client.
+ return "macro", "macrotext", "/cwm " .. (ALL or "all"), "macro"
+ end
+ return "macro", "macrotext", "/wm " .. WORLD_MARKER_ENGINE[id], "macro"
+
+ elseif k == "cycleraidtarget" or k == "cycleworldmarker" then
+ -- The step the position on the slot says is up. The snippet overwrites
+ -- this with its own answer on every press -- see the eapCycN branch --
+ -- so what is pushed here is only what the entry would fire if the
+ -- cycle attributes went missing: the right marker, just not advancing.
+ local steps = CycleSteps(k)
+ return "macro", "macrotext", steps[CycleNext(slot)], "macro"
+ end
+
+ return nil
+end
+ns.ResolveAction = ResolveAction
+
+-- The kinds with no secure action type at all. None of these calls is
+-- protected -- summoning a battle pet or a mount and changing specialization
+-- are all ordinary API -- so all are safe straight from PostClick.
+--
+-- WHICH cell reaches here is the snippet's answer rather than the live view's
+-- selection; see OnPostClick.
+local function FireInsecure(slot)
+ if not slot then return end
+ if slot.kind == "battlepet" and slot.guid and C_PetJournal then
+ C_PetJournal.SummonPetByGUID(slot.guid)
+
+ elseif slot.kind == "randommount" and C_MountJournal then
+ -- The Mount Journal button's own call
+ -- (Blizzard_MountCollection.lua:998); 0 means "a random favorite".
+ -- Refused by the game itself in combat, where no mount can be
+ -- summoned anyway.
+ C_MountJournal.SummonByID(0)
+
+ elseif slot.kind == "spec" then
+ local index = SpecIndexFor(slot)
+ -- Refused in combat by the game itself, with its own error message.
+ -- Nothing to defer to PLAYER_REGEN_ENABLED: a spec change the user
+ -- asked for mid-fight and got minutes later is not what they meant.
+ if index then C_SpecializationInfo.SetSpecialization(index) end
+ end
+end
+
+-- icon, name for display. Never returns nil for icon so a slot whose target
+-- has been removed from the game still renders as an occupied slot.
+--
+-- Note the deliberate absence of `C_Foo and C_Foo.Bar(x)` guards here: an
+-- `and` expression is truncated to ONE value, which would silently drop every
+-- return past the first and leave every icon nil.
+local function SlotDisplay(slot)
+ if not slot or not slot.kind then return nil, nil end
+ local k = slot.kind
+
+ if k == "spell" then
+ local info = C_Spell.GetSpellInfo(slot.id)
+ if info then return info.iconID or QUESTION_MARK, info.name end
+
+ elseif k == "item" then
+ if type(slot.id) ~= "number" then return QUESTION_MARK, slot.name end
+ local _, _, _, _, icon = C_Item.GetItemInfoInstant(slot.id)
+ local name = C_Item.GetItemInfo(slot.id)
+ return icon or QUESTION_MARK, name or slot.name
+
+ elseif k == "toy" then
+ if type(slot.id) ~= "number" then return QUESTION_MARK, slot.name end
+ local _, name, icon = C_ToyBox.GetToyInfo(slot.id)
+ return icon or QUESTION_MARK, name or slot.name
+
+ elseif k == "macro" then
+ local name, icon = GetMacroInfo(slot.name or slot.id)
+ return icon or QUESTION_MARK, name or slot.name
+
+ elseif k == "macrotext" then
+ return slot.icon or QUESTION_MARK, slot.name or "Macro"
+
+ elseif k == "mount" then
+ if type(slot.id) ~= "number" then return QUESTION_MARK, slot.name end
+ local name, _, icon = C_MountJournal.GetMountInfoByID(slot.id)
+ return icon or QUESTION_MARK, name or slot.name
+
+ elseif k == "randommount" then
+ local info = C_Spell.GetSpellInfo(RANDOM_FAVORITE_MOUNT)
+ -- The client's own caption for the Mount Journal button, so the entry
+ -- reads the same on a localized client as the thing it summons.
+ return (info and info.iconID) or QUESTION_MARK,
+ MOUNT_JOURNAL_SUMMON_RANDOM_FAVORITE_MOUNT
+ or (info and info.name) or "Random Favorite Mount"
+
+ elseif k == "spec" then
+ local index = SpecIndexFor(slot)
+ if index then
+ local _, name, _, icon = C_SpecializationInfo.GetSpecializationInfo(index)
+ return icon or QUESTION_MARK, name or slot.name
+ end
+ -- A spec this character does not have. Drawn as an occupied slot with
+ -- whatever name it was picked up under, like every other entry whose
+ -- target has gone away.
+ return QUESTION_MARK, slot.name
+
+ elseif k == "battlepet" then
+ if type(slot.guid) ~= "string" then return QUESTION_MARK, slot.name end
+ local _, _, _, _, _, _, _, name, icon = C_PetJournal.GetPetInfoByPetID(slot.guid)
+ return icon or QUESTION_MARK, name or slot.name
+
+ elseif k == "raidtarget" then
+ local id = tonumber(slot.id) or 0
+ if id < 1 or id > 8 then
+ return "Interface\\Buttons\\UI-GroupLoot-Pass-Up", "Clear Target Marker"
+ end
+ return MarkerIcon(id), "Target Marker: " .. MARKER_NAMES[id]
+
+ elseif k == "clearmarkers" then
+ return "Interface\\Buttons\\UI-GroupLoot-Pass-Up", "Clear All Target Markers"
+
+ elseif k == "worldmarker" then
+ local id = tonumber(slot.id) or 0
+ if id < 1 or id > 8 then
+ return "Interface\\Buttons\\UI-GroupLoot-Pass-Up", "Clear World Markers"
+ end
+ return MarkerIcon(id), "World Marker: " .. MARKER_NAMES[id]
+
+ elseif k == "cycleraidtarget" or k == "cycleworldmarker" then
+ -- Drawn as the marker the next press places, not as a fixed emblem: in
+ -- a radial the icon is what the entry is picked by, and one that never
+ -- changed while the action did would be worse than no entry at all.
+ local id = CycleNext(slot)
+ local what = (k == "cycleraidtarget") and "Target" or "World"
+ return MarkerIcon(id), "Cycle " .. what .. " Marker: " .. MARKER_NAMES[id]
+
+ elseif k == "palette" then
+ -- ReadPalette, not EnsurePalette: a nested parent is repainted from the
+ -- steering path, which must not rewrite the profile's slot array.
+ local palette = ReadPalette(ChildIndex(slot))
+ -- Three choices before the fallback, narrowest first: this entry's own
+ -- override, then the icon the palette carries wherever it is nested,
+ -- then the palette's first entry -- so a "Mounts" palette looks like a
+ -- mount without anyone having to pick an icon for it. Only one level
+ -- down: a first entry that is itself a palette would send this round
+ -- its own loop.
+ local icon = slot.icon or (palette and palette.icon)
+ local first = palette and palette.slots[1]
+ if not icon and first and first.kind ~= "palette" then
+ icon = SlotDisplay(first)
+ end
+ return icon or QUESTION_MARK,
+ slot.name or (palette and palette.name) or "Palette"
+ end
+
+ return QUESTION_MARK, slot.name
+end
+ns.SlotDisplay = SlotDisplay
+
+-- Cooldown source per kind. Returns start, duration, enable -- handed to
+-- CooldownFrame_Set verbatim, never compared or arithmetic'd, so secret
+-- cooldown values stay untouched.
+-- Returns EITHER a duration object (spells, mounts) OR start, duration, enable
+-- (items, toys). Two shapes because only spells have a secret-safe getter.
+--
+-- Spell cooldowns must not go through C_Spell.GetSpellCooldown: it is flagged
+-- SecretWhenCooldownsRestricted (SpellDocumentation.lua:252), so once cooldowns
+-- are restricted its startTime and duration come back as SECRET numbers. Our
+-- execution is an addon's and therefore tainted, and CooldownFrame_Set opens with
+-- `start > 0 and duration > 0` (Cooldown.lua:3) -- comparing a secret from
+-- tainted execution throws, which is what filled the log with 17 errors on the
+-- first in-combat open. C_Spell.GetSpellCooldownDuration returns an opaque
+-- duration object instead: it is AllowedWhenTainted, and it goes straight into
+-- the widget C-side, so nothing here ever reads a secret.
+--
+-- Items keep the plain numeric path -- C_Item.GetItemCooldown carries no secret
+-- flag and there is no duration-object equivalent for items.
+local function SlotCooldown(slot)
+ if not slot then return nil end
+ local k = slot.kind
+ if k == "spell" or k == "mount" then
+ local id
+ if k == "mount" then
+ -- No falling back to slot.id here: that is a mountID, and looking
+ -- a mountID up as a spellID reports some unrelated spell's cooldown.
+ id = slot.spellID or select(2, C_MountJournal.GetMountInfoByID(slot.id))
+ else
+ id = slot.id
+ end
+ if id and C_Spell.GetSpellCooldownDuration then
+ -- Parenthesised, so only the duration comes back: 12.1 returns a
+ -- second value that would land in this function's `start` slot and
+ -- hand a boolean to CooldownFrame_Set.
+ return (C_Spell.GetSpellCooldownDuration(id))
+ end
+ elseif k == "item" or k == "toy" then
+ if slot.id and C_Item.GetItemCooldown then
+ return nil, C_Item.GetItemCooldown(slot.id)
+ end
+ end
+ return nil
+end
+
+-- WHETHER an entry writes a number in its corner, and WHAT that number is --
+-- deliberately two returns rather than one nilable value. Two things earn a
+-- number: a stack of items, and a spell's charges.
+--
+-- The charge count must never be looked at, not even for truth or for nil.
+-- C_Spell.GetSpellCharges is flagged SecretWhenCooldownsRestricted
+-- (SpellDocumentation.lua:234), so currentCharges comes back a SECRET number
+-- once restrictions are in effect, and touching one from tainted execution
+-- throws -- the same wall the spell cooldown hit. So the caller is told
+-- separately that there IS a count, and the count itself only ever reaches
+-- SetText, which swallows a secret; Blizzard's own action button hands the
+-- identical kind of value to the identical call (ActionButton.lua:810).
+--
+-- What is safe to test is the TABLE the call returns, which is not itself
+-- secret: it comes back as nothing for a spell that has no charges at all.
+--
+-- Neither item call carries a secret flag, so those may be compared -- and
+-- have to be, because a count only means something on a stackable item. A
+-- Hearthstone writing "1" in its corner is noise.
+local function SlotCount(slot)
+ if not slot then return false end
+ local k = slot.kind
+
+ if k == "spell" then
+ if type(slot.id) ~= "number" or not C_Spell.GetSpellCharges then return false end
+ local charges = C_Spell.GetSpellCharges(slot.id)
+ if not charges then return false end
+ return true, charges.currentCharges
+
+ elseif k == "item" then
+ if type(slot.id) ~= "number" then return false end
+ -- Position 8 is the stack size. It is nil until the item's data has
+ -- been cached, which costs at most the count on one open -- the
+ -- palette repaints from scratch every time it is drawn.
+ local stack = select(8, C_Item.GetItemInfo(slot.id))
+ if not stack or stack <= 1 then return false end
+ return true, C_Item.GetItemCount(slot.id)
+ end
+
+ return false
+end
+
+-- How an entry that CANNOT be fired right now is tinted, in the three states
+-- an action button has always distinguished. The idle and selected tints are
+-- multiplied through these, so an unusable entry still reads as selected while
+-- saying why it would do nothing.
+local USABILITY_TINT = {
+ OUTOFRANGE = { 0.90, 0.20, 0.20, false },
+ NOPOWER = { 0.45, 0.45, 1.00, false },
+ UNUSABLE = { 0.45, 0.45, 0.45, true },
+}
+
+-- Which of those states an entry is in, or nil for an entry that is fine and
+-- for a kind with nothing to say.
+--
+-- Every call here is secret-SAFE, and that was checked rather than assumed:
+-- C_Spell.IsSpellUsable, C_Spell.IsSpellInRange, C_Item.IsUsableItem,
+-- C_Item.ItemHasRange and C_Item.IsItemInRange all carry no
+-- SecretWhenCooldownsRestricted flag in the generated documentation, unlike
+-- the cooldown and charge getters two functions up. So these results may be
+-- branched on. Do not add a kind here without checking its getter the same
+-- way -- a mount's usability, for one, has to come from the Mount Journal
+-- rather than from its summon spell, which is not in the spellbook and
+-- answers unusable for every mount.
+--
+-- Out of range OUTRANKS the other two, matching every action bar: a spell you
+-- cannot reach is the thing to say first, and it is the state a step forward
+-- fixes.
+--
+-- Macros, markers and palettes answer nil. A macro's usability is whatever its
+-- body resolves to, which is not knowable from here, and tinting one gray on a
+-- guess is worse than saying nothing.
+--
+-- Toys also answer nil. A toy is not a bag item, so C_Item.IsUsableItem says
+-- unusable for every toy a player owns, and graying the whole Hearthstones
+-- palette is exactly what that produced. The toy box itself draws no
+-- usability tint -- Blizzard_ToyBox.lua desaturates only UNCOLLECTED toys --
+-- and the cooldown swipe already tells the one thing a toy has to tell.
+local function SlotUsability(slot)
+ if not slot then return nil end
+ local k = slot.kind
+
+ if k == "spell" then
+ if type(slot.id) ~= "number" then return nil end
+ if C_Spell.IsSpellInRange(slot.id) == false then return "OUTOFRANGE" end
+ local usable, noPower = C_Spell.IsSpellUsable(slot.id)
+ if usable then return nil end
+ return noPower and "NOPOWER" or "UNUSABLE"
+
+ elseif k == "item" then
+ if type(slot.id) ~= "number" then return nil end
+ if C_Item.ItemHasRange(slot.id)
+ and C_Item.IsItemInRange(slot.id, "target") == false then
+ return "OUTOFRANGE"
+ end
+ local usable, noPower = C_Item.IsUsableItem(slot.id)
+ if usable then return nil end
+ return noPower and "NOPOWER" or "UNUSABLE"
+ end
+
+ return nil
+end
+
+-- Build a slot table from whatever is on the cursor. Returns nil when the
+-- cursor holds something the palette can't fire.
+local function SlotFromCursor()
+ local cursorType, a, b, c = GetCursorInfo()
+ if not cursorType then return nil end
+
+ if cursorType == "spell" then
+ -- Position 2 is the spellbook SLOT, not the spell -- Blizzard's own
+ -- comment says so at SharedUIPanelTemplates.lua:1823, where it reads
+ -- select(4, GetCursorInfo()) for the id. No fallback to position 2:
+ -- that would store a slot number as a spellID and silently create a
+ -- slot that casts the wrong thing.
+ if type(c) ~= "number" then return nil end
+ return { kind = "spell", id = c }
+
+ elseif cursorType == "item" then
+ local itemID = tonumber(a)
+ if not itemID then return nil end
+ return { kind = "item", id = itemID }
+
+ elseif cursorType == "macro" then
+ local name = GetMacroInfo(a)
+ if not name then return nil end
+ return { kind = "macro", id = a, name = name }
+
+ elseif cursorType == "mount" then
+ -- Position 2 is the mountID: Blizzard reads it exactly this way at
+ -- SharedUIPanelTemplates.lua:1827. Guessing at other positions is
+ -- unsafe here because display indices and mountIDs are both small
+ -- integers, so a wrong guess resolves to a real but unrelated mount.
+ local mountID = tonumber(a)
+ if not mountID then return nil end
+ local name, spellID = C_MountJournal.GetMountInfoByID(mountID)
+ if not name then return nil end
+ return { kind = "mount", id = mountID, spellID = spellID, name = name }
+
+ elseif cursorType == "toy" then
+ local itemID = tonumber(a)
+ if not itemID then return nil end
+ return { kind = "toy", id = itemID }
+
+ elseif cursorType == "battlepet" then
+ if not a then return nil end
+ return { kind = "battlepet", guid = a }
+ end
+
+ return nil
+end
+ns.SlotFromCursor = SlotFromCursor
+
+-------------------------------------------------------------------------------
+-- Palette view -- the renderer, instanced
+--
+-- Two instances exist: the live palette and the options-page preview. Sharing
+-- one renderer is the whole point of the split -- the preview's entry order,
+-- angles and hit test ARE the live palette's, so what the user arranges in the
+-- panel is exactly what they steer at in play.
+--
+-- A view owns its container frame, the center hub, and a pool of MAX_SLOTS
+-- slot widgets. It does NOT own interaction: the live palette drives itself from
+-- ns.Open/ns.Close, and the preview installs its own scripts on the widgets it
+-- gets back from GetSlotWidget.
+-------------------------------------------------------------------------------
+local views = {} -- every view, live and preview
+local liveView -- the palette the keybinds open
+-- Declared up here, not beside EnsureScrollCatcher: AdvanceFan reads the fan
+-- index straight off it, and that is defined long before the catcher is built.
+local scrollCatcher
+local secureHeader
+-- The button ESCAPE is bound to while a palette is open. Declared here for the
+-- same reason: ns.Close drops its binding, and that is defined long before the
+-- secure activation section builds it.
+local cancelButton
+-- One secure button per BOUND palette, indexed the same way. Declared here for
+-- the same reason as the three above: PaletteView:ArmedClaim reads a claim's
+-- armed state off a palette's own button, and that is defined long before the
+-- secure activation section builds any of them.
+local secureButtons = {}
+local openedAt = 0
+
+-- A held key whose up-event never reaches us (alt-tab, /reload prompt, a
+-- taxi takeoff) would otherwise leave the palette on screen forever.
+local OPEN_TIMEOUT = 30
+
+-- Selection is drawn with two cues only: the icon border takes the selection
+-- color and thickens, and the entry grows. No additive glow -- at palette scale
+-- it bloomed over the neighbouring entries and made the border it was supposed
+-- to emphasise harder to read.
+local SEL_BORDER = 2
+local IDLE_BORDER = 1
+
+-- The entry an ARMED claim hangs off. Selection says where the cursor is; this
+-- says which nest is live, and the two part company the moment the cursor moves
+-- on into the children -- the entry it came in through has to go on saying so,
+-- because it is the only thing on screen that names the nest the release will
+-- fire out of.
+--
+-- Derived from the selection color rather than from a setting of its own: the
+-- same hue lifted toward white and drawn a pixel thicker. That reads as "more
+-- than selected" at icon size while still sitting next to the selection color,
+-- rather than introducing a second color the user would have to learn.
+local ARM_BORDER = 3
+local ARM_LIFT = 0.55
+
+-- The magnification a selected entry is drawn at.
+local function SelectedZoom()
+ local p = P()
+ return max(1, (p and p.selectedZoom) or 1.15)
+end
+
+-- Magnification is applied to the entry's SIZE, never its scale. SetPoint
+-- offsets are read in the widget's own scaled space, so scaling an entry also
+-- multiplies the offset it is anchored at -- and in the arc that offset carries
+-- the radius, so selecting an entry threw it outward, out from under the very
+-- cursor that had selected it, and the two states then flickered against each
+-- other. Growing it in place moves nothing.
+--
+-- widget.baseSize is the unzoomed size the layout wants, published by whichever
+-- geometry pass last placed the entry. Every steered layout rewrites its sizes
+-- each frame and applies the zoom itself as it goes; this is what carries the
+-- zoom across a selection CHANGE, and it is the whole of the answer on a view
+-- that never steers -- the options preview, which draws a static arc.
+--
+-- armed marks the entry an armed claim hangs off, which may or may not also be
+-- the selected one. The zoom stays a selection cue alone: an armed parent the
+-- cursor has already left is not what a release would fire.
+-- widget.usability is the state PaintCell last read for this cell (see
+-- SlotUsability), applied here rather than there because these three branches
+-- rewrite the icon's tint on every selection change and would erase it.
+local function ApplySlotVisual(widget, selected, armed)
+ local p = P()
+ local r, g, b = SelectColor()
+ -- The unusable tint MULTIPLIES whatever the selection state asked for, so
+ -- an out-of-range entry the cursor is on still reads as the selected one.
+ local tint = USABILITY_TINT[widget.usability or ""]
+ local ur, ug, ub = 1, 1, 1
+ if tint then ur, ug, ub = tint[1], tint[2], tint[3] end
+ widget.icon:SetDesaturated(tint ~= nil and tint[4] or false)
+ local t = armed and ARM_BORDER or selected and SEL_BORDER or IDLE_BORDER
+ widget.border:SetPoint("TOPLEFT", widget, "TOPLEFT", -t, t)
+ widget.border:SetPoint("BOTTOMRIGHT", widget, "BOTTOMRIGHT", t, -t)
+ local base = widget.baseSize
+ if base then
+ local z = selected and SelectedZoom() or 1
+ widget:SetSize(base * z, base * z)
+ end
+ if armed then
+ local lr = r + (1 - r) * ARM_LIFT
+ local lg = g + (1 - g) * ARM_LIFT
+ local lb = b + (1 - b) * ARM_LIFT
+ widget.border:SetVertexColor(lr, lg, lb, 1)
+ widget.bg:SetVertexColor(r * 0.22, g * 0.22, b * 0.22, min(1, (p and p.bgAlpha or 0.65) + 0.25))
+ widget.icon:SetVertexColor(ur, ug, ub)
+ widget.label:SetTextColor(lr, lg, lb)
+ elseif selected then
+ widget.border:SetVertexColor(r, g, b, 1)
+ widget.bg:SetVertexColor(r * 0.22, g * 0.22, b * 0.22, min(1, (p and p.bgAlpha or 0.65) + 0.25))
+ widget.icon:SetVertexColor(ur, ug, ub)
+ widget.label:SetTextColor(r, g, b)
+ else
+ widget.border:SetVertexColor(0, 0, 0, 0.9)
+ widget.bg:SetVertexColor(0.05, 0.05, 0.06, p and p.bgAlpha or 0.65)
+ widget.icon:SetVertexColor(0.72 * ur, 0.72 * ug, 0.72 * ub)
+ widget.label:SetTextColor(0.75, 0.75, 0.75)
+ end
+end
+
+-- The suite's font, on every string the palette draws.
+--
+-- The Blizzard font object each string is created from stays the source of its
+-- SIZE: the layout is tuned against those sizes, and a re-font is meant to
+-- change the typeface and the outline, nothing else. iconText marks the strings
+-- that sit ON an icon, which follow the suite's "Outline Icon Text" switch
+-- rather than the plain outline mode.
+local FONT_KEY = "actionPalette"
+local fontStrings = {}
+
+local function ApplyModuleFont(fs)
+ local size = fs.eapFontSize
+ if not size or not EllesmereUI.GetFontPath then return end
+ local flags
+ if fs.eapIconText and EllesmereUI.GetIconTextOutlineFlag then
+ flags = EllesmereUI.GetIconTextOutlineFlag(FONT_KEY)
+ else
+ flags = EllesmereUI.GetFontOutlineFlag and EllesmereUI.GetFontOutlineFlag(FONT_KEY) or ""
+ end
+ -- Runtime SetShadowOffset no longer renders on 12.x; the shadow has to be
+ -- carried by a FontObject, primed BEFORE the typeface call.
+ if EllesmereUI.PrimeFontShadow then
+ local useShadow = flags == "" and EllesmereUI.GetFontUseShadow
+ and EllesmereUI.GetFontUseShadow()
+ EllesmereUI.PrimeFontShadow(fs, useShadow and true or false)
+ end
+ fs:SetFont(EllesmereUI.GetFontPath(FONT_KEY), size, flags)
+end
+
+-- Called once per string, right after it is created from its Blizzard font
+-- object, and again for every string whenever the font settings change.
+local function AdoptFontString(fs, iconText)
+ local _, size = fs:GetFont()
+ fs.eapFontSize = size
+ fs.eapIconText = iconText or nil
+ fontStrings[#fontStrings + 1] = fs
+ ApplyModuleFont(fs)
+end
+
+local function RefreshFonts()
+ for i = 1, #fontStrings do ApplyModuleFont(fontStrings[i]) end
+end
+
+local function CreateSlotWidget(view, index)
+ local w = CreateFrame("Button", nil, view.frame, "BackdropTemplate")
+ w.index = index
+
+ w.bg = w:CreateTexture(nil, "BACKGROUND")
+ w.bg:SetTexture("Interface\\Buttons\\WHITE8X8")
+ w.bg:SetAllPoints(w)
+
+ w.border = w:CreateTexture(nil, "BORDER")
+ w.border:SetTexture("Interface\\Buttons\\WHITE8X8")
+ w.border:SetPoint("TOPLEFT", w, "TOPLEFT", -1, 1)
+ w.border:SetPoint("BOTTOMRIGHT", w, "BOTTOMRIGHT", 1, -1)
+
+ -- The border texture sits behind bg, so it reads as a 1px outline.
+ w.bg:SetDrawLayer("BACKGROUND", 1)
+ w.border:SetDrawLayer("BACKGROUND", 0)
+
+ w.icon = w:CreateTexture(nil, "ARTWORK")
+ w.icon:SetPoint("TOPLEFT", w, "TOPLEFT", 2, -2)
+ w.icon:SetPoint("BOTTOMRIGHT", w, "BOTTOMRIGHT", -2, 2)
+ w.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
+
+ w.cd = CreateFrame("Cooldown", nil, w, "CooldownFrameTemplate")
+ w.cd:SetAllPoints(w.icon)
+ w.cd:SetHideCountdownNumbers(false)
+ w.cd:SetDrawEdge(false)
+
+ -- Stack size or charges, in the corner an action button writes them in.
+ -- Its text may be a SECRET value (see SlotCount), so it is SHOWN and
+ -- HIDDEN rather than written and cleared: a FontString carrying secret
+ -- text refuses text access to tainted callers, and a refused clear would
+ -- leave the previous entry's number standing.
+ w.count = w:CreateFontString(nil, "OVERLAY", "NumberFontNormal")
+ w.count:SetPoint("BOTTOMRIGHT", w, "BOTTOMRIGHT", -1, 2)
+ w.count:SetJustifyH("RIGHT")
+ w.count:Hide()
+ AdoptFontString(w.count, true)
+
+ w.label = w:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ AdoptFontString(w.label)
+ w.label:SetPoint("TOP", w, "BOTTOM", 0, -2)
+ w.label:SetWidth(96)
+ w.label:SetWordWrap(false)
+
+ -- The "+" affordance for an interactive view's trailing placeholder entry.
+ -- Created unconditionally; Layout is what decides whether it is ever shown.
+ w.plus = w:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
+ AdoptFontString(w.plus)
+ w.plus:SetPoint("CENTER")
+ w.plus:SetText("+")
+ w.plus:Hide()
+
+ w:EnableMouse(false)
+ return w
+end
+
+local PaletteView = {}
+local PaletteViewMeta = { __index = PaletteView }
+
+local function DefaultGeom()
+ local p = P()
+ if not p then return 96, 44, 24 end
+ return p.radius or 96, p.iconSize or 44, p.deadZone or 24
+end
+
+-- radius, iconSize, deadZone for this view. Called through a plain function
+-- call, never `opts.geom and opts.geom()` -- an `and` expression is truncated
+-- to one value and would drop iconSize and deadZone on the floor.
+function PaletteView:Geom()
+ return (self.opts.geom or DefaultGeom)()
+end
+
+-- The profile as THIS view's palette sees it: its own appearance overrides in
+-- front of the profile's values. Every geometry pass reads through here rather
+-- than through P(), which is what makes two palettes able to be drawn as two
+-- different layouts.
+--
+-- appIndex is a temporary override for a caller measuring a palette this view
+-- is not currently laid out for -- PushPalette does exactly that for every
+-- bound palette in turn while the view still holds whatever was last drawn.
+function PaletteView:P()
+ return PA(self.appIndex or self.paletteIndex)
+end
+
+function PaletteView:GetFrame() return self.frame end
+function PaletteView:GetPaletteIndex() return self.paletteIndex end
+function PaletteView:GetSelection() return self.selection end
+function PaletteView:SlotCount() return self.slotCount end
+function PaletteView:ShownCount() return self.shownCount end
+function PaletteView:GetSlotWidget(index) return self.widgets[index] end
+
+-- ARC | FAN | GRID. A view may pin its own mode (the options preview
+-- pins one so the page can show either without changing what the user plays
+-- with); everything else follows the profile.
+function PaletteView:LayoutMode()
+ local p = self:P()
+ return self.opts.layout or (p and p.layout) or "ARC"
+end
+
+-- Which way a fan runs. Every axis-dependent decision in the file reads this
+-- one predicate, so a strip is one layout with an orientation rather than two
+-- layouts that happen to share every setting. Meaningless outside a fan, where
+-- callers do not ask.
+function PaletteView:FanHoriz()
+ local p = self:P()
+ return not p or p.fanOrientation ~= "VERTICAL"
+end
+
+function PaletteView:IsFan()
+ return self:LayoutMode() ~= "ARC"
+end
+
+function PaletteView:IsGrid()
+ return self:LayoutMode() == "GRID"
+end
+
+-- The lattice spacing entries are placed on: one icon plus the gap between two
+-- of them. The grid, both strips and a nested arc all measure from this.
+function PaletteView:Pitch()
+ local p = self:P()
+ local _, iconSize = self:Geom()
+ return iconSize + ((p and p.fanGap) or 10)
+end
+
+-------------------------------------------------------------------------------
+-- A claim's true ground, as a small set of rects rather than one bounding
+-- box -- see the "Arming gates" section further down for what these feed.
+-- Shared by both the ARC claims (ChildGeom) and the block-layout ones
+-- (CellChildGeom): a nest that breaks out of its parent on one side leaves a
+-- bounding box across the two swallowing whatever plain ground of the block
+-- sits between them, which is exactly the "dim never backs out" complaint.
+-- The true shape is instead the parent's own cell, the nest's own tight box,
+-- and a narrow corridor connecting the two -- standing on the block's own
+-- ground either side of that corridor is standing outside the nest.
+-------------------------------------------------------------------------------
+
+-- Tight bounding box around a set of child boxes, with no parent box folded
+-- in -- unlike the old single-rect scheme, this is meant to be paired with a
+-- SEPARATE parent box and corridor rather than merged with them.
+local function NestBBox(cells)
+ local first = cells[1]
+ local x0, x1 = first.x - first.hw, first.x + first.hw
+ local y0, y1 = first.y - first.hh, first.y + first.hh
+ for j = 2, #cells do
+ local b = cells[j]
+ x0, x1 = min(x0, b.x - b.hw), max(x1, b.x + b.hw)
+ y0, y1 = min(y0, b.y - b.hh), max(y1, b.y + b.hh)
+ end
+ return { x = (x0 + x1) * 0.5, y = (y0 + y1) * 0.5,
+ hw = (x1 - x0) * 0.5, hh = (y1 - y0) * 0.5 }
+end
+
+-- The rect connecting a claim's parent box to its nest box, that lets
+-- crossing the gap between them count as staying on the claim's own ground.
+-- axis is the axis the nest's own cells spread ALONG -- every style that
+-- hangs its nest off one side of the parent already tags its claim with
+-- this, the same convention HaloNest opts out of by setting neither axis nor
+-- sign. The corridor runs along the OTHER axis, in the direction sign says
+-- the nest lies.
+--
+-- As WIDE as the wider of the parent cell or the nest box, not one child
+-- cell: a natural diagonal reach from the parent toward the nest's own
+-- centre drifts outside a one-cell-wide band long before it arrives, and
+-- once LeaveSnippet's true-shape test actually runs (see EnsureGates) that
+-- reads as having left the claim -- the nest vanishing mid-reach. minWidth
+-- is only a floor, for the degenerate case of a single-cell nest whose box
+-- is no wider than the corridor itself would otherwise be.
+local function CorridorBox(parentBox, nest, axis, sign, minWidth)
+ local along, away = "x", "y"
+ local hAlong, hAway = "hw", "hh"
+ if axis ~= "X" then along, away, hAlong, hAway = "y", "x", "hh", "hw" end
+
+ local pEdge = parentBox[away] + sign * parentBox[hAway]
+ local nEdge = nest[away] - sign * nest[hAway]
+ local lo, hi = min(pEdge, nEdge), max(pEdge, nEdge)
+
+ local box = {}
+ box[along] = parentBox[along]
+ box[hAlong] = max(minWidth * 0.5, parentBox[hAlong], nest[hAlong])
+ box[away], box[hAway] = (lo + hi) * 0.5, max(0, (hi - lo) * 0.5)
+ return box
+end
+
+-- One rect covering a nest run AND the ground between it and the parent's own
+-- cell: the corridor folded into the run rather than standing beside it. That is
+-- what lets a run with cells on more than one side of the block have EVERY side
+-- of it reachable -- a reach for a child round the far side of a corner leaves
+-- the parent cell diagonally, and a claim with one corridor pointing at one side
+-- loses the cursor the moment it aims at another -- without spending a region
+-- gate per side on the corridors alone.
+--
+-- The PARENT'S OWN CELL folded in, rather than a corridor drawn between the two:
+-- a rect is convex, so a rect holding both ends of a line holds every point of
+-- it, and a reach IS a line -- a hand goes straight at the icon it wants. Any
+-- corridor narrower than that leaves some straight reach crossing ground the
+-- claim does not hold, however carefully it is aimed: a corridor laid across the
+-- gap the two boxes leave in ONE direction is exited by a reach that leaves the
+-- parent cell through a different edge. That was measurable -- a reach for the
+-- far end of a run wrapped round a corner left the claim less than two units
+-- short of the run's own rect, disarmed mid-reach, and fired the plain entry the
+-- cursor came to rest over instead of the child it was aimed at.
+--
+-- What it costs is honest: the sweep from the parent's cell out to a run stands
+-- over whatever plain entries lie in it -- for a parent in the middle of a block
+-- the ones between it and its lane, which the corridor this replaces covered too
+-- (that was as wide as the whole nest), and for a parent on the block's own edge
+-- the rest of its own row or column, which the corridor did not. Those entries
+-- stay exactly as selectable and as firable as they were; what they no longer do
+-- is back the nest out. A run reached by a straight line that breaks halfway is
+-- worse than a nest that stays up one entry too long: the break does not cancel
+-- anything, it fires whatever the cursor came to rest over instead.
+--
+-- One kind of entry in that sweep IS allowed to back the nest out: another
+-- claim's own entry, which the sweep would otherwise make unreachable while this
+-- claim is armed -- its parent gate being dark the whole time. Those cells are
+-- taken back out afterwards, one at a time; see ParentHoles.
+local function RunReach(parentBox, run)
+ return NestBBox({ run, parentBox })
+end
+
+-- The overshoot grace, applied to one nest run's own rect. Reaching quickly for
+-- a small child icon overruns the run's edge by a few units, and a cursor path
+-- sampled once per frame can put a single sample outside it on the way in;
+-- either one used to read as having left the claim, and the nest vanished
+-- mid-reach. A cell's own box is untouched, so this only decides how long the
+-- nest STAYS open -- what a release fires still needs the cursor inside a child
+-- cell.
+--
+-- axis and sign are the run's, as ever: the axis its cells spread along and the
+-- side of the block it lies on. ALONG the run the grace applies both ways --
+-- either end of a run is more run, or empty screen. ACROSS it, only OUTWARD:
+-- inward is the block itself, and for a lane hugging it that is a plain entry's
+-- own centre less than half an icon away, which a region reaching over would
+-- leave unselectable while the nest was up. A nest set a whole band out has the
+-- room to spare either way, and nothing wants the inward half of it.
+local function GraceBox(box, grace, axis, sign)
+ local away, hAway, hAlong = "y", "hh", "hw"
+ if axis ~= "X" then away, hAway, hAlong = "x", "hw", "hh" end
+ box[hAlong] = box[hAlong] + grace
+ box[away] = box[away] + sign * grace * 0.5
+ box[hAway] = box[hAway] + grace * 0.5
+ return box
+end
+
+-- The carve below works in edges; everything else here works in centre and
+-- half-extent.
+local function BoxEdges(b)
+ return b.x - b.hw, b.x + b.hw, b.y - b.hh, b.y + b.hh
+end
+
+local function EdgeBox(x0, x1, y0, y1)
+ return { x = (x0 + x1) * 0.5, y = (y0 + y1) * 0.5,
+ hw = (x1 - x0) * 0.5, hh = (y1 - y0) * 0.5 }
+end
+
+local function BoxesMeet(a, b)
+ return abs(a.x - b.x) < a.hw + b.hw and abs(a.y - b.y) < a.hh + b.hh
+end
+
+local function AnyBoxMeets(b, boxes)
+ for j = 1, (boxes and #boxes or 0) do
+ if BoxesMeet(b, boxes[j]) then return true end
+ end
+ return false
+end
+
+-- A piece thinner than this holds nothing a cursor could be inside, and would
+-- spend one of the REGION_MAX gate slots a piece that matters needs.
+local CARVE_MIN = 1
+
+-- One region rect with ONE other claim's parent cell taken out of it, as up to
+-- four pieces appended to `out`.
+--
+-- Why a hole at all: a claim's region sweeps its parent's own row or column (see
+-- RunReach), so while claim A is armed its region stands over claim B's parent
+-- cell. B's own parent gate is dark for as long as A is armed, and gliding from
+-- A's entry straight onto B's leaves no region of A's -- so no OnLeave runs,
+-- nothing disarms, and B cannot be reached at all without leaving the row first.
+-- Taking B's cell out of A's coverage puts a real boundary there: the glide
+-- leaves an rgate at the hole's edge, LeaveSnippet's geometric re-test answers
+-- "outside", and its re-arm hands the claim over.
+--
+-- splitY says which axis the FULL-WIDTH slabs are cut on, and the caller sets it
+-- from the run's own axis so the slab that survives whole is the one holding the
+-- run: a hole is always a cell INSIDE the block, and the run lies beyond the
+-- block along the region's away axis, so cutting that axis first leaves every
+-- child in one piece rather than sliced into per-column strips.
+local function CarveBox(out, b, hole, splitY)
+ local bx0, bx1, by0, by1 = BoxEdges(b)
+ local hx0, hx1, hy0, hy1 = BoxEdges(hole)
+ if hx1 <= bx0 or hx0 >= bx1 or hy1 <= by0 or hy0 >= by1 then
+ out[#out + 1] = b
+ return
+ end
+ hx0, hx1 = max(hx0, bx0), min(hx1, bx1)
+ hy0, hy1 = max(hy0, by0), min(hy1, by1)
+ if splitY then
+ if hy0 - by0 >= CARVE_MIN then out[#out + 1] = EdgeBox(bx0, bx1, by0, hy0) end
+ if by1 - hy1 >= CARVE_MIN then out[#out + 1] = EdgeBox(bx0, bx1, hy1, by1) end
+ if hx0 - bx0 >= CARVE_MIN then out[#out + 1] = EdgeBox(bx0, hx0, hy0, hy1) end
+ if bx1 - hx1 >= CARVE_MIN then out[#out + 1] = EdgeBox(hx1, bx1, hy0, hy1) end
+ else
+ if hx0 - bx0 >= CARVE_MIN then out[#out + 1] = EdgeBox(bx0, hx0, by0, by1) end
+ if bx1 - hx1 >= CARVE_MIN then out[#out + 1] = EdgeBox(hx1, bx1, by0, by1) end
+ if hy0 - by0 >= CARVE_MIN then out[#out + 1] = EdgeBox(hx0, hx1, by0, hy0) end
+ if by1 - hy1 >= CARVE_MIN then out[#out + 1] = EdgeBox(hx0, hx1, hy1, by1) end
+ end
+end
+
+-- A hole pulled back off this claim's OWN children, or nil when there is no
+-- hole left worth punching. A child cell that stands over a neighbouring
+-- claim's cell has to WIN there -- it is drawn there, and a hole under it would
+-- make it unselectable -- but a child that merely grazes the cell must not cost
+-- the whole hole: a lane hugs the block so closely that its cells reach back
+-- over the outer row's own boxes by half a gap, so every hole a lane wants would
+-- otherwise be refused on a sliver.
+--
+-- Each pass gives away the one side a child has got LEAST far in through, which
+-- is the smallest concession that answers that child. What must survive it is
+-- the neighbour's own CENTRE: that is where a cursor aimed at the neighbour's
+-- icon lands, and the hole exists so that landing there is outside this claim.
+local function ClipHole(hole, cells)
+ local x0, x1, y0, y1 = BoxEdges(hole)
+ -- One side given away per pass, so four passes per child is the most that
+ -- can be asked of it -- plus the pass that finds nothing left to answer.
+ for _ = 1, 4 * (cells and #cells or 0) + 1 do
+ local best, bx0, bx1, by0, by1
+ for j = 1, (cells and #cells or 0) do
+ local qx0, qx1, qy0, qy1 = BoxEdges(cells[j])
+ if qx1 > x0 and qx0 < x1 and qy1 > y0 and qy0 < y1 then
+ local d = min(qx1 - x0, x1 - qx0, qy1 - y0, y1 - qy0)
+ if not best or d < best then
+ best, bx0, bx1, by0, by1 = d, qx1, qx0, qy1, qy0
+ end
+ end
+ end
+ if not best then
+ local h = EdgeBox(x0, x1, y0, y1)
+ -- The centre, with room around it: a hole clipped down to a line
+ -- through the neighbour's icon is not somewhere a hand can land.
+ if abs(h.x - hole.x) + CARVE_MIN <= h.hw
+ and abs(h.y - hole.y) + CARVE_MIN <= h.hh then
+ return h
+ end
+ return nil
+ end
+ if best == bx0 - x0 then x0 = bx0
+ elseif best == x1 - bx1 then x1 = bx1
+ elseif best == by0 - y0 then y0 = by0
+ else y1 = by1 end
+ if x1 - x0 < CARVE_MIN or y1 - y0 < CARVE_MIN then return nil end
+ end
+ return nil
+end
+
+-- Does this hole stand STRAIGHT OUT from the parent, in the direction its own
+-- children lie? Then it is the one piece of ground the nest cannot be reached
+-- across, and the nest keeps it: a claim whose entry has another claim's entry
+-- between it and its own run -- the middle column of a block, all three of them
+-- nesting -- would otherwise have every child of its middle nest cut off, the
+-- reach handing the claim over before it arrived. The swap in that one direction
+-- is what gives way instead, and it is still there the way round the block. A
+-- hole to the SIDE of the parent blocks nothing and is carved as normal, which is
+-- the case the carve exists for.
+--
+-- The claim's OWN side only -- groups[1], the nearest one, which PerimeterNest
+-- has already sorted to the front -- not every side a long run wrapped onto. The
+-- tail of a wrapped run reaches back past the parent's neighbours, and protecting
+-- those directions too would leave a block whose neighbouring entries both nest
+-- with no hole anywhere and no swap at all. Reaching a wrapped cell out past
+-- another claim's entry hands the claim over instead, which is the trade the carve
+-- is for; what must not happen is a nest with no way in.
+local function BlocksReach(c, hole)
+ local pb = c.parentBox
+ local side = (c.groups and c.groups[1]) or c
+ local axis, sign = side.axis, side.sign
+ if not axis then return false end
+ local away, halong = "y", "hw"
+ if axis ~= "X" then away, halong = "x", "hh" end
+ local along = (away == "y") and "x" or "y"
+ return abs(hole[along] - pb[along]) < hole[halong] + pb[halong]
+ and sign * (hole[away] - pb[away]) > 0
+end
+
+-- Every OTHER claim's parent cell, clipped off this claim's own children.
+local function ParentHoles(claims, i)
+ local c, holes = claims[i], nil
+ for j = 1, #claims do
+ local h = (j ~= i) and claims[j].parentBox or nil
+ if h and BlocksReach(c, h) then h = nil end
+ if h then
+ if AnyBoxMeets(h, c.cells) then h = ClipHole(h, c.cells) end
+ if h then
+ holes = holes or {}
+ holes[#holes + 1] = h
+ end
+ end
+ end
+ return holes
+end
+
+-- Is this piece ground the claim already holds? Every side of a run folds the
+-- parent's own cell in (see RunReach), so the sides overlap heavily around it and
+-- carving each of them splits the same ground into pieces again and again -- and
+-- a piece straddling two rects the claim already has is nobody's subset. Answered
+-- by subtracting what is already there and asking whether anything survives, so
+-- that costs no more code than the carve itself. A gate spent on ground the claim
+-- holds anyway is a gate a piece that matters may not get.
+local function Covered(b, regions)
+ local pieces = { b }
+ for r = 1, #regions do
+ local kept = {}
+ for i = 1, #pieces do CarveBox(kept, pieces[i], regions[r], true) end
+ pieces = kept
+ if #pieces == 0 then return true end
+ end
+ return false
+end
+
+-- Append one region rect to a claim, carved. The pieces holding one of this
+-- claim's own children come first: PushPalette writes only REGION_MAX of them,
+-- so an overflow drops the tail, and a dropped piece with a child under it would
+-- take that child off the claim's ground entirely.
+local function AddRegion(c, box, axis, holes)
+ if not holes then
+ c.regions[#c.regions + 1] = box
+ return
+ end
+ local pieces = { box }
+ for hi = 1, #holes do
+ local kept = {}
+ for pi = 1, #pieces do
+ CarveBox(kept, pieces[pi], holes[hi], axis ~= "Y")
+ end
+ pieces = kept
+ end
+ for pass = 1, 2 do
+ for pi = 1, #pieces do
+ local b = pieces[pi]
+ local holds = AnyBoxMeets(b, c.cells)
+ -- A piece that holds no child of this claim and does not touch its
+ -- parent cell either is on the FAR side of a hole, and the only way
+ -- onto it is across that hole -- which hands the claim over before
+ -- the cursor arrives. Keeping it would spend a gate on ground this
+ -- claim can never be armed on.
+ if holds == (pass == 1)
+ and (holds or BoxesMeet(b, c.parentBox))
+ and not Covered(b, c.regions) then
+ c.regions[#c.regions + 1] = b
+ end
+ end
+ end
+end
+
+-- Nested geometry for one palette. Returns an array of CLAIMS -- one per slot
+-- that opens a palette -- or nil when nothing in it nests:
+--
+-- parent the slot index the children hang off
+-- palette the palette index they come from
+-- slots the child slots themselves, already capped at what this view's
+-- layout can seat (see NestChildCap)
+-- n how many
+-- angle the parent entry's own angle } arc only
+-- half the half-angle of the room the rings may spread into } arc only
+-- ground the claim's own ground for the DISARM test, as a beam out of the
+-- parent entry and a wedge past the entry ring } arc only
+-- rows concentric rings of children, hugging the arc's own ring;
+-- { radius, step, n, base, start, lo, hi } each -- start is the
+-- CENTRE angle of that ring's first child, lo/hi the radial band
+-- it answers to, hi nil on the outermost ring } arc only
+-- radius the outermost ring's radius, for sizing the frame } arc only
+-- band the distance at which the children take over from the parent
+-- cells one box per child, { x, y, hw, hh } } block layouts only
+-- axis the axis its run travels on, X or Y
+-- sign which side of the block it came out on, +1 up/right
+-- dim whether the block behind it is pushed back while it is open
+--
+-- ONE allocator, read by the drawing, by the hit test and by the push onto the
+-- secure button. A second copy of any of this inside the snippet would drift
+-- from what the palette draws the first time an option moved -- the same reason
+-- the grid's cell centres are pushed rather than re-derived.
+--
+-- How much of the arc a claim's children may spread along: up to
+-- arcChildMaxSpan, whatever its parent's own sector is worth. Child sectors do
+-- NOT have to partition the plane against their neighbours, which is what used
+-- to hold them inside the parent's own step: a claim answers a release only
+-- while it is armed, and only the cursor's passing through that parent entry
+-- arms it, so ground two claims' children both cover is never ambiguous -- at
+-- most one of them is live. arcChildOverflow decides whether the widening is
+-- allowed onto a neighbouring CLAIM's ground at all:
+--
+-- NONE stop at the midpoint to the nearest other claim, so no two
+-- nests are ever drawn over one another
+-- MIDPOINT spend the whole span, overlapping other nests if it comes to it
+--
+-- Both may cross the PLAIN entries in between: those keep answering their own
+-- angles whenever nothing is armed, and give them up only for as long as a
+-- nest reaching over them is open.
+--
+-- More children than the span can hold is answered by RINGING them: a claim's
+-- children hug the arc's own ring, spaced roughly a child pitch apart, and a
+-- ring with no room left spills the rest into a second ring one child pitch
+-- further out rather than growing its own radius until the angle buys enough
+-- room -- which is unbounded, and used to put children on a claim with several
+-- of them a screen-width past the arc they were supposed to hang off.
+function PaletteView:ChildGeom(shown, palette)
+ local p = self:P()
+ if not p or not palette or shown < 1 then return nil end
+ -- An editor draws no nests. What a nested entry holds is that palette's own
+ -- business -- switch to it and it is the whole preview -- and drawing every
+ -- nest at once buries the palette actually being arranged. It would also
+ -- make the preview budget space for a reach it is not showing, shrinking the
+ -- palette under the cursor to leave room for entries that are not there.
+ if self.opts.interactive then return nil end
+
+ -- Claimants in entry order first: how much room each one may take depends
+ -- on where the next one sits, so none of them can be sized on its own.
+ --
+ -- The cap is NestChildCap's answer, asked of the view's own predicates
+ -- rather than of the stored profile so a preview that pinned its layout
+ -- seats what the layout it is showing can seat. The two agree everywhere
+ -- else: the predicates read the same profile.
+ local cap = MAX_SLOTS
+ if self:LayoutMode() == "ARC"
+ or (self:IsGrid() and p.gridNestStyle == "HALO") then
+ cap = MAX_CHILDREN
+ end
+ local claims
+ for i = 1, shown do
+ local kids = ChildSlots(ChildIndex(palette.slots[i]), cap)
+ if kids and #kids > 0 then
+ claims = claims or {}
+ claims[#claims + 1] = { parent = i, n = #kids, slots = kids,
+ palette = ChildIndex(palette.slots[i]) }
+ end
+ end
+ if not claims then return nil end
+
+ -- Placement is per layout; the claims themselves are not. An arc carves
+ -- sectors out of its parent's own, so its children are found by angle; a
+ -- block layout gives every child a box and finds them by containment. The
+ -- two answer the same question -- which region of the plane is this? -- in
+ -- the terms their own layout is already steered in.
+ if self:IsPointerLayout() then return self:CellChildGeom(claims, shown) end
+ if self:IsFan() then return self:StripNest(claims, shown) end
+ if self:LayoutMode() ~= "ARC" then return nil end
+
+ local step, arcStart, full = self:ArcGeom(shown)
+ local radius, iconSize = self:Geom()
+ -- Scaled by whatever this view scaled its geometry by, recovered from the
+ -- icon size Geom handed back -- the same recovery the hub logo makes. The
+ -- radius already carries that factor; a band read at its literal profile
+ -- size would not, and the options preview would then draw its nests at
+ -- full distance around a palette fitted to two-thirds.
+ local base = p.iconSize or 44
+ local k = (base > 0) and (iconSize / base) or 1
+ local band = max(0, p.nestBand or NEST_BAND_DEFAULT) * k
+ local gap = ((p and p.fanGap) or 10) * k
+ -- Nested entries are drawn smaller than the palette's own, so a nest reads
+ -- as subordinate to the entry it hangs off rather than as a second ring of
+ -- equals. It costs nothing in the hit test: the sectors are angular, and an
+ -- icon's size has no part in deciding which one the cursor is in.
+ local childIcon = iconSize * min(1, max(0.4, p.nestScale or 0.8))
+ local childPitch = childIcon + gap
+ local capHalf = min(180, max(10, p.arcChildMaxSpan or 90)) * pi / 180 * 0.5
+ -- Anything that is not the one opt-in value keeps clear of other claims,
+ -- so a saved profile that never set this reads as the cautious side.
+ local keepClear = p.arcChildOverflow ~= "MIDPOINT"
+ local count = #claims
+
+ -- Angles for all of them before any of them is sized: a claim keeping clear
+ -- of its neighbours measures against the claim either side of it, and half
+ -- of those sit later in the array.
+ for i = 1, count do
+ claims[i].angle = arcStart + (claims[i].parent - 1) * step
+ end
+
+ -- Both icons' halves plus the gap, so the band the user sets is the space
+ -- actually seen between the palette's own ring and the first ring of
+ -- children -- the ring every claim's children start hugging from.
+ local inner = radius + iconSize * 0.5 + childIcon * 0.5 + band
+
+ for i = 1, count do
+ local c = claims[i]
+ -- The whole cap by default. NONE stops at the midpoint with the nearest
+ -- CLAIMANT either side, so two nests never share ground; a lone
+ -- claimant on a full circle has no neighbour to meet, and on an open
+ -- arc the ends are free space.
+ local half = capHalf
+ if keepClear and count > 1 then
+ local nxt = claims[i + 1] and claims[i + 1].angle
+ or (full and (claims[1].angle + TWO_PI))
+ local prev = claims[i - 1] and claims[i - 1].angle
+ or (full and (claims[count].angle - TWO_PI))
+ if nxt then half = min(half, (nxt - c.angle) * 0.5) end
+ if prev then half = min(half, (c.angle - prev) * 0.5) end
+ end
+ -- Never NARROWER than the parent's own sector: a claim always has at
+ -- least the room the entry it hangs off already owns.
+ half = max(half, step * 0.5)
+
+ c.icon = childIcon
+ c.half = half
+ -- Halfway across the gap: clear of the parent's own icon, short of the
+ -- first ring of children. The parent entry keeps everything inside
+ -- this.
+ c.band = radius + iconSize * 0.5 + band * 0.5
+
+ -- Ring the children rather than pushing them out: each ring sits one
+ -- child pitch further out than the last, its children spaced roughly a
+ -- pitch apart along it, and a ring with no room left for the rest
+ -- spills them into the next ring instead of growing its own radius.
+ -- The room is the whole of c.half, so a claim whose children fit
+ -- inside the span cap stays ONE ring however wide that has to be.
+ -- MAX_CHILD_ROWS caps how many rings a claim may spill into -- past it
+ -- the last ring simply takes everyone still waiting, however crowded
+ -- that makes it, which is the same trade the old radius clamp made
+ -- except it no longer drifts the children away from the arc to make
+ -- it.
+ local rows, placed, ri = {}, 0, 0
+ while placed < c.n and ri < MAX_CHILD_ROWS do
+ local rr = inner + ri * childPitch
+ -- The arc length one child pitch buys at this ring's radius, in
+ -- radians -- further out, the same angle spans more distance, so
+ -- fewer degrees are needed to keep neighbours a pitch apart.
+ local angStep = childPitch / rr
+ local capacity = (ri == MAX_CHILD_ROWS - 1) and (c.n - placed)
+ or max(1, floor((half * 2) / angStep + 1e-6) + 1)
+ local m = min(c.n - placed, capacity)
+ rows[#rows + 1] = { radius = rr, step = angStep, n = m, base = placed,
+ start = c.angle - (m - 1) * 0.5 * angStep }
+ placed = placed + m
+ ri = ri + 1
+ end
+ -- The boundary between two rings is their midpoint radius: past it,
+ -- the further ring's children are the nearer ones underfoot. The
+ -- first ring's inner edge is c.band, the parent/child hand-off
+ -- already computed above; the last ring has no outer edge at all.
+ rows[1].lo = c.band
+ for r = 2, #rows do
+ local mid = (rows[r - 1].radius + rows[r].radius) * 0.5
+ rows[r - 1].hi, rows[r].lo = mid, mid
+ end
+ c.rows = rows
+ -- The outermost ring's radius, so Layout can size the frame to hold
+ -- every ring rather than just the first.
+ c.radius = rows[#rows].radius
+
+ -- The ground the DISARM test keeps this claim armed on, in two pieces
+ -- (LeaveSnippet, ANGULAR branch). Neither is the release's own per-ring
+ -- resolution, and both are supersets of it, which is the only property
+ -- that has to hold: a claim must never disarm anywhere its own release
+ -- would still fire one of its children.
+ --
+ -- WEDGE, from the entry ring's outer edge outward. Its half-angle is
+ -- the widest RING's: a ring answers the angles its children's own
+ -- sectors cover, n * step wide, so the widest one covers every angle
+ -- the release could resolve to a child here -- and one wedge for the
+ -- lot leaves no gap between two rings of unequal width for the cursor
+ -- to disarm in on its way out to the further one. Floored at the
+ -- parent's own sector, which is the wider of the two on a claim of one
+ -- or two children, and at the parent icon's own angular width, so a
+ -- palette of one entry on an open arc (step 0) still has a wedge.
+ -- Plus a grace of half a child sector, so overshooting the edge child
+ -- by a hair leaves the nest open to correct back into rather than
+ -- closing it for good; the grace deliberately does NOT widen the
+ -- release, which still resolves the rings exactly, so the graced
+ -- sliver fires the plain entry behind it while the nest stays live.
+ --
+ -- BEAM, out of the parent entry itself, for everything nearer than
+ -- that. The wedge alone is not enough and the parent's icon box is not
+ -- enough either: the reach for a child is a straight line from the
+ -- parent, so it passes BESIDE the icon before it gets out past the
+ -- entry ring -- a nest spread 44 degrees either side of a 30-degree
+ -- entry is crossed at 11 to 25 degrees while still inside the ring's
+ -- own radius -- and that ground answers to no ring and no icon box.
+ -- The beam is a half-icon wide at the palette's centre and opens at
+ -- the parent's own sector angle, which is what keeps it clear of the
+ -- NEIGHBOURING entries' icons: the room between the beam's edge and a
+ -- neighbour's centre works out to radius * tan(step / 2) - icon / 2,
+ -- i.e. the beam misses the neighbour exactly when the two entries'
+ -- icons do not overlap in the first place. That clearance is what lets
+ -- a neighbouring claim take over -- gliding onto its entry leaves this
+ -- ground, which disarms, which puts its parent gate back up.
+ local wide, coarsest = 0, 0
+ for r = 1, #rows do
+ wide = max(wide, rows[r].n * rows[r].step * 0.5)
+ coarsest = max(coarsest, rows[r].step)
+ end
+ c.ground = {
+ -- Radial projection onto the parent's own axis, not plain
+ -- distance: the beam is measured along and across that axis.
+ ax = sin(c.angle), ay = cos(c.angle),
+ -- Inward of the icon's inner face is a retreat toward the centre,
+ -- and disarming there is what hands the other claims their gates
+ -- back.
+ lo = radius - iconSize * 0.5,
+ -- Where the wedge takes over from the beam. Past the entry ring
+ -- rather than past the icons ON it, and that is the whole margin
+ -- there is to work with: a nest spread wider than its parent's
+ -- sector is reached by a line that crosses its NEIGHBOURS' icons,
+ -- so some of that ground has to answer to the armed claim or the
+ -- reach breaks -- while a neighbouring entry's own CENTRE sits at
+ -- the ring itself and so is always outside the wedge, whatever the
+ -- step. That is what a handoff needs: gliding onto another claim's
+ -- entry leaves this ground, which disarms, which puts that claim's
+ -- parent gate back up.
+ edge = radius + iconSize * 0.25,
+ beam = iconSize * 0.5,
+ -- Clamped short of a quarter turn: tan runs away at one, and a
+ -- palette of two entries on a full circle has a half-sector of
+ -- exactly that. Past 60 degrees the wedge covers those angles
+ -- anyway everywhere it applies.
+ slope = tan(min(step * 0.5, pi / 3)),
+ half = max(wide + coarsest * 0.5, step * 0.5,
+ (iconSize * 0.5) / max(1, radius)),
+ }
+
+ -- The parent's own icon box, for the arming gate (see EnsureGates)
+ -- and the disarm test alike -- standing on it always counts as this
+ -- claim's ground. ChildRingPos wants c.rows, which is why this waits
+ -- until here rather than running alongside the loop above.
+ local px, py = radius * sin(c.angle), radius * cos(c.angle)
+ c.parentBox = { x = px, y = py, hw = iconSize * 0.5, hh = iconSize * 0.5 }
+
+ -- What the disarm test actually decides an arc claim's ground by is
+ -- polar -- c.ground above. The rects
+ -- built here are only EVENT surfaces for the real gate frames, which
+ -- can only ever be rects: generous rather than tight is fine for
+ -- them, because the geometric test that decides whether leaving one
+ -- actually disarms never trusts their bounds, only the wedge.
+ local ringBoxes = {}
+ for j = 1, c.n do
+ local r, a = self:ChildRingPos(c, j)
+ local cx, cy = r * sin(a), r * cos(a)
+ ringBoxes[j] = { x = cx, y = cy, hw = c.icon * 0.5, hh = c.icon * 0.5 }
+ end
+ local nest = NestBBox(ringBoxes)
+
+ -- The corridor's break-out direction is whichever screen axis the
+ -- parent's own radial position leans further along -- an
+ -- approximation of "straight out from the centre", which is all a
+ -- rect can ever be for a wedge.
+ local axis, sign
+ if abs(px) >= abs(py) then axis, sign = "Y", (px >= 0) and 1 or -1
+ else axis, sign = "X", (py >= 0) and 1 or -1 end
+ local corridor = CorridorBox(c.parentBox, nest, axis, sign, childPitch)
+
+ c.regions = { c.parentBox, nest, corridor }
+ end
+
+ return claims
+end
+
+-- An arc claim's j-th child (1-based across the whole claim, not just one
+-- ring) -> the radius and angle it is drawn at. Read by the drawing and by
+-- the needle's direction; HitTest walks the rings the other way, from a
+-- radius to a ring, but lands on this same row.start/row.step to turn the
+-- local index it finds back into the child it belongs to.
+function PaletteView:ChildRingPos(c, j)
+ local rows = c.rows
+ for r = 1, #rows do
+ local row = rows[r]
+ if j <= row.n then
+ return row.radius, row.start + (j - 1) * row.step
+ end
+ j = j - row.n
+ end
+end
+
+-- Angular step and starting angle for the arc layout, both clockwise from
+-- straight up. Returns the step, the angle of slot 1, and whether this is a
+-- full circle.
+--
+-- A full circle divides by the entry count and wraps: the last entry's far side
+-- is the first entry's near side, so there is no seam. An arc divides by count
+-- MINUS ONE instead, which puts the first and last entries ON its ends rather
+-- than leaving a step-wide gap at the seam that belongs to no entry at all.
+function PaletteView:ArcGeom(shown)
+ local p = self:P()
+ local deg = min(360, max(30, (p and p.arcSpan) or 360))
+ local rot = ((p and p.arcRotation) or 0) * pi / 180
+
+ if deg >= 359.5 then
+ return (shown > 0) and (TWO_PI / shown) or 0, rot, true
+ end
+
+ local span = deg * pi / 180
+ local step = (shown > 1) and (span / (shown - 1)) or 0
+ return step, rot - span * 0.5, false
+end
+
+-- A cursor-steered fan: every entry drawn at a fixed position, the nearest one
+-- zoomed. The editor follows the profile here like everything else, so what it
+-- lays out stays the arrangement the user actually plays with.
+function PaletteView:IsHoverFan()
+ if self:IsGrid() or not self:IsFan() then return false end
+ local p = self:P()
+ return (p and p.fanInput or "SCROLL") == "CURSOR"
+end
+
+-- Everything steered by pointing at a fixed arrangement, as opposed to the
+-- scroll fan's moving one. These all share the grid's geometry and its update.
+function PaletteView:IsPointerLayout()
+ return self:IsGrid() or self:IsHoverFan()
+end
+
+-------------------------------------------------------------------------------
+-- Fan layout
+--
+-- A coverflow strip: the selected entry sits at the centre at full size, and
+-- its neighbours shrink and fade by a fixed per-step ratio. Selection is
+-- whatever is centred, so there is no hit test at all -- the mouse wheel
+-- scrubs the strip and the centre is the answer.
+--
+-- Distance from the centre is the INTEGRAL of the scale curve plus a constant
+-- gap rather than a sum of discrete steps. Two reasons: the spacing then
+-- derives from the sizes it separates, so the strip tapers instead of leaving
+-- shrunken icons floating in dead space; and it stays defined for fractional
+-- offsets, which is what lets the strip slide smoothly between slots.
+-------------------------------------------------------------------------------
+
+-- Editor floors. The options preview draws the whole palette at once and every
+-- entry in it is a drag target, so the live floors -- which are tuned to let
+-- distant entries fade away -- would leave the ends of a long strip both
+-- unreadable and hard to hit.
+local FAN_EDIT_MIN_SCALE = 0.45
+local FAN_EDIT_MIN_ALPHA = 0.45
+
+-- Signed distance is applied by the caller; k is always >= 0 here.
+--
+-- minScale is not optional cosmetics: scale stops shrinking at the floor, so
+-- spacing has to stop shrinking there too. Integrating the raw curve past that
+-- point keeps closing the gaps under icons that have stopped getting smaller,
+-- and they overlap. Past the knee the strip is therefore evenly spaced at the
+-- floored size.
+-- The size and alpha falloffs in force: one per step away from the entry under
+-- the cursor. Every layout asks here rather than reading the profile itself, so
+-- the toggle means the same thing in all of them and none can be left drawing a
+-- depth cue the others have dropped.
+--
+-- Switched off, both answer 1 -- a no-op wherever they land. decay ^ k is 1 at
+-- every k, so nothing shrinks or fades, and FanOffset's even-spacing branch
+-- takes the strip out to full pitch. Nothing has to test the toggle twice.
+--
+-- ~= false, not == true: the default is ON, so a profile that has never seen
+-- the key gets the falloff.
+local function FalloffRatios(p)
+ if p and p.falloff == false then return 1, 1 end
+ -- Clamped away from 0: FanOffset takes log(decay), which a saved value of
+ -- zero would turn into a division by negative infinity.
+ return min(1, max(0.05, (p and p.fanScaleDecay) or 0.72)),
+ min(1, max(0.05, (p and p.fanAlphaDecay) or 0.62))
+end
+ns.FalloffRatios = function(paletteIndex) return FalloffRatios(PA(paletteIndex)) end
+
+-- Steps, flattened over the entry's own ground. Raw nearness is measured to an
+-- entry's CENTRE, so the entry under the cursor grew and shrank as the cursor
+-- crossed it -- it was at its largest only dead in the middle, and the one thing
+-- on screen that should hold still while you settle on it was the one thing
+-- moving. Everything inside an entry's own ground now reads as zero steps away.
+--
+-- Ground is half a step each side, which is exactly what the hit tests hand an
+-- entry: half a step of arc, half a cell of grid. So the entry drawn at full
+-- size and full alpha is precisely the entry a release would fire.
+--
+-- The identity past one full step is what keeps the settled drawing untouched:
+-- an entry a whole step out is still decay ^ 1, two steps decay ^ 2, a grid
+-- diagonal decay ^ sqrt 2. Only the half-step band between an entry's edge and
+-- its neighbour's centre is redrawn, at twice the rate, and the strip -- whose
+-- entries come to rest at whole steps -- never leaves the identity at all.
+--
+-- Fed ONE AXIS AT A TIME on the grid, then combined: flattening the 2D distance
+-- instead would leave the corners of a cell outside the flat disc, still
+-- breathing, and would pull the diagonal neighbour in off sqrt 2.
+local function FalloffK(k)
+ if k <= 0.5 then return 0 end
+ if k >= 1 then return k end
+ return (k - 0.5) * 2
+end
+
+local function FanOffset(k, size, gap, decay, minScale)
+ -- decay ~= 1 makes the integral degenerate (and 1 means "no falloff", so
+ -- even spacing is the right answer anyway).
+ if decay >= 0.999 then return (size + gap) * k end
+ local lnd = -log(decay)
+
+ minScale = minScale or 0
+ if minScale <= 0 then return size * (1 - decay ^ k) / lnd + gap * k end
+
+ -- decay ^ knee == minScale, which is what makes the two branches meet.
+ local knee = log(minScale) / log(decay)
+ if k <= knee then return size * (1 - decay ^ k) / lnd + gap * k end
+ return size * (1 - minScale) / lnd + gap * knee
+ + (size * minScale + gap) * (k - knee)
+end
+
+-- Half-length of the editor's strip: centre to the outer edge of the last
+-- entry, at the editor's own floors. Exported so the options preview can fit a
+-- strip to the panel without duplicating any of the constants above.
+--
+-- HALF the count, from the full count the caller counted: the strip is cyclic,
+-- and ApplyFanGeometry folds every offset into [-shown/2, shown/2], so the
+-- entry drawn farthest from the centre is half the palette out and not the
+-- whole of it. Measured over the whole count the strip was fitted to about
+-- twice its own drawn length -- the preview shrank its icons to half what the
+-- panel had room for. The hover reach next door counts the same way.
+function ns.FanReach(count, iconSize, gap, decay)
+ return FanOffset(count * 0.5, iconSize, gap, decay, FAN_EDIT_MIN_SCALE)
+ + iconSize + iconSize * (SelectedZoom() - 1) * 0.5
+end
+
+-- The same measurement for a hover fan, which is evenly spaced at full pitch
+-- because its zoomed entry is drawn at 1.0 and must not overlap its neighbours.
+function ns.FanHoverReach(count, iconSize, gap)
+ return count * 0.5 * (iconSize + gap) + iconSize * 0.5 * SelectedZoom()
+end
+
+-- Position every widget from self.fanVisual, the CONTINUOUS centre. Called
+-- from Layout and from every animation step; it never repaints icons, so it is
+-- cheap enough to run each frame while the strip settles.
+function PaletteView:ApplyFanGeometry()
+ local p = self:P()
+ if not p or not self:IsFan() then return end
+
+ local shown = self.shownCount
+ if shown < 1 then return end
+
+ local _, iconSize = self:Geom()
+ local gap = p.fanGap or 10
+ local decay, aDecay = FalloffRatios(p)
+ local minS = p.fanMinScale or 0.30
+ local minA = p.fanMinAlpha or 0.12
+ if self.opts.interactive then
+ minS = max(minS, FAN_EDIT_MIN_SCALE)
+ minA = max(minA, FAN_EDIT_MIN_ALPHA)
+ end
+ local horiz = self:FanHoriz()
+ -- An interactive view draws the whole palette: the editor cannot let a slot
+ -- be unreachable, so nothing is culled there and the floors carry it.
+ local window = self.opts.interactive and shown or (p.fanVisible or 3)
+
+ local frame = self.frame
+ local center = self.fanVisual or 1
+ local half = shown / 2
+
+ -- Half the width the selected entry gains, added to every offset past the
+ -- centre so magnifying it cannot close the gaps under its neighbours. A
+ -- CONSTANT, applied whichever entry is selected: making it follow the
+ -- selection would reflow the whole strip on every step.
+ --
+ -- Ramped in over the first step rather than switched on the moment k leaves
+ -- 0 -- see the offset below. The strip settles onto its entry CONTINUOUSLY,
+ -- so a term that appeared the instant k was nonzero held the centre entry a
+ -- few pixels out for the whole slide and then dropped it back as k reached
+ -- exactly 0: the whole strip came to rest and the middle icon twitched a
+ -- moment later, against the direction of travel. At every integer k the
+ -- ramp is already at full extra, so nothing about the settled strip moves.
+ local zoom = SelectedZoom()
+ local extra = iconSize * (zoom - 1) * 0.5
+ local sel = self.selection
+
+ for i = 1, shown do
+ local w = self.widgets[i]
+ -- Shortest cyclic path, so wrapping past the end slides forward
+ -- instead of rewinding the whole strip.
+ local d = (i - center) % shown
+ if d > half then d = d - shown end
+
+ local k = abs(d)
+ if k > window + 0.5 then
+ w:Hide()
+ else
+ local s = max(minS, decay ^ k)
+ local off = FanOffset(k, iconSize, gap, decay, minS)
+ off = off + extra * min(1, k)
+ if d < 0 then off = -off end
+
+ w:SetAlpha(max(minA, aDecay ^ k))
+ -- Depth is size, not scale: SetPoint offsets are read in the
+ -- widget's own scaled space, so scaling here would silently
+ -- multiply the spacing computed above. The selected entry is
+ -- magnified in the same breath, because these sizes are rewritten
+ -- on every animation step and would erase a zoom applied elsewhere.
+ local z = (i == sel) and zoom or 1
+ w.baseSize = iconSize * s
+ w:SetSize(iconSize * s * z, iconSize * s * z)
+ w:ClearAllPoints()
+ if horiz then
+ w:SetPoint("CENTER", frame, "CENTER", off, 0)
+ else
+ w:SetPoint("CENTER", frame, "CENTER", 0, -off)
+ end
+ w:Show()
+ end
+ end
+end
+
+-------------------------------------------------------------------------------
+-- Grid
+--
+-- Every entry at a fixed cell, the one nearest the pointer zoomed, everything
+-- else falling off by distance. A pointer-steered FAN is this same layout one
+-- entry deep -- a single row when it runs horizontally, a single column when
+-- it runs vertically -- so it routes here rather than into a parallel 1D
+-- implementation. This is the mode that scales -- pointer travel to the worst
+-- entry grows with the SQUARE ROOT of the count rather than linearly, and a
+-- fixed 2D arrangement is far easier to build muscle memory against than a
+-- position along a line.
+--
+-- Rows are centred individually, so a short final row sits under the middle of
+-- the one above it instead of hanging off the left edge.
+-------------------------------------------------------------------------------
+
+-- How far from EVERY entry, in cells, the pointer may stray before the grid
+-- deselects. This is the grid's cancel: it has no dead zone to release inside.
+local GRID_REACH = 1.0
+
+-- What the block behind an open nest is pushed back to, for the styles that put
+-- their children over it. Enough to read as "that layer is not the one you are
+-- on" while still showing the shape of what you came from.
+--
+-- Pushed further than before now that a nest only dims once its gate is
+-- actually armed (see ArmedClaim): the block used to fade on a geometric guess
+-- that the cursor was somewhere on the way to a nest, so a strong dim there
+-- would have punished a flick that only grazed the corridor. Arming means the
+-- cursor has gone through the parent entry itself, which is worth a clearer
+-- break between "the nest you are in" and "the palette behind it".
+local NEST_DIM_ALPHA = 0.15
+local NEST_DIM_SCALE = 0.7
+
+-- What an UNARMED nest is drawn at while the selection sits on its parent. Its
+-- children are placed and visible, so the palette still says that this entry
+-- opens a nest and where that nest will appear -- but none of them can be fired
+-- until a gate arms the claim, so they are drawn as something that has not
+-- happened yet. Drawn at full strength they promised a live nest and then
+-- answered nothing, which read as the sub-palette being broken.
+--
+-- The block behind a preview keeps its own alpha and its own size: the dim and
+-- the parent's draw-back belong to a nest you are IN, and spending them on a
+-- nest that is only being previewed leaves nothing left to say when it opens.
+local NEST_PREVIEW_ALPHA = 0.35
+
+-- The margin, in pitches, around a scroll-steered strip that the pointer may
+-- travel inside before it deselects. This is that layout's cancel, and it is
+-- the same gesture the grid cancels with -- throw the pointer clear of the
+-- icons -- rather than a rule of its own to learn.
+--
+-- Clear in ANY direction, but not the same distance in each: the box is this
+-- margin across the strip and the strip's own drawn length plus the margin
+-- along it. A strip is long one way and thin the other, and leaving it means
+-- passing its edge, wherever that edge happens to be.
+--
+-- Measured from where the pointer was when the palette opened, not from the
+-- strip, so it means the same thing in Fixed Position mode, where the strip is
+-- somewhere else on the screen entirely.
+--
+-- Note what this does NOT cover: while the right button holds the camera the
+-- cursor is frozen, so it cannot travel and the strip cannot be cancelled --
+-- and camera steering is the case this layout exists for. A player who wants
+-- out of a strip opened mid-turn has to let the camera go first.
+local FAN_CANCEL_REACH = 2.25
+
+-- The strip dims as the pointer travels toward the edge of that box, so leaving
+-- is something the player watches happen rather than a boundary they cross
+-- blind. The fade starts part of the way out -- steering a strip means moving,
+-- and a palette that dimmed on the first pixel would flicker on every gesture.
+--
+-- Eased rather than linear, and by a fair margin: the strip holds near full
+-- brightness through most of the travel and then drops away over the last of
+-- it. A linear ramp read as the palette dimming the moment the pointer moved,
+-- when what it has to say is "still here" until leaving is actually imminent.
+local FAN_FADE_START = 0.35
+local FAN_FADE_MIN = 0.25
+local FAN_FADE_POWER = 3
+
+-- Columns for a grid the user has not pinned. Near-square, because the whole
+-- point of a grid is to shorten the WORST pointer travel, and that is minimised
+-- when the two axes are balanced: nine entries want 3x3, not 4 + 4 + 1.
+--
+-- The remainder check is the one refinement on ceil(sqrt). A final row holding a
+-- single entry reads as a mistake rather than a layout, and widening by one
+-- column always absorbs it -- 3 becomes one row of three, 7 becomes 4 + 3.
+local function AutoGridColumns(shown)
+ local cols = ceil(sqrt(shown))
+ if cols < shown and shown % cols == 1 then cols = cols + 1 end
+ return min(MAX_SLOTS, max(1, cols))
+end
+
+-- A pointer-steered fan IS a grid one entry deep, so it resolves here rather
+-- than in a parallel 1D implementation: a horizontal strip is a single row, a
+-- vertical one a single column. Only the scroll-steered fan needs geometry of
+-- its own, because it cycles a compressed window rather than showing fixed
+-- positions.
+-- shownOverride lets a caller ask what the grid WOULD be for some other entry
+-- count. PushPalette needs exactly that: it runs while the palette is closed,
+-- when shownCount still describes whatever was drawn last.
+function PaletteView:GridDims(shownOverride)
+ local p = self:P()
+ local shown = max(1, shownOverride or self.shownCount)
+
+ local mode = self:LayoutMode()
+ if mode == "FAN" then
+ if self:FanHoriz() then return shown, 1 end
+ return 1, shown
+ end
+
+ local cols
+ if not p or p.gridAutoColumns ~= false then
+ -- Counted from the REAL entries, not from `shown`. An interactive view
+ -- draws one extra entry for the trailing "+", and letting that tip the
+ -- column count would make the editor lay a palette out differently from
+ -- the way it is played -- six actions previewing as 4 + 3 while the
+ -- live palette drew 3 + 3.
+ cols = AutoGridColumns(max(1, shownOverride or self.slotCount or shown))
+ else
+ cols = min(MAX_SLOTS, max(1, floor(p.gridColumns or 4)))
+ end
+ if cols > shown then cols = shown end
+ return cols, ceil(shown / cols)
+end
+
+-- Centre-relative position of slot i, in the frame's own units.
+function PaletteView:GridBase(i, cols, rows, pitch, shownOverride)
+ local r = floor((i - 1) / cols)
+ local c = (i - 1) % cols
+ local inRow = min(cols, (shownOverride or self.shownCount) - r * cols)
+ return (c - (inRow - 1) * 0.5) * pitch, -(r - (rows - 1) * 0.5) * pitch
+end
+
+-------------------------------------------------------------------------------
+-- Nested cells for a block layout
+--
+-- Every nested cell owns a BOX. Inside it, that child; outside every box, the
+-- palette's own nearest-cell search, exactly as if the nest were not there.
+-- That one rule is what makes a nest behave like a thing you are IN: leave the
+-- run in ANY direction -- along it, across it, back over the parent -- and you
+-- are out of it, because you are outside its boxes. Boxes are also what let a
+-- nest sit over ground the block is using, which the styles below need and a
+-- nearest-centre rule could never allow.
+--
+-- Boxes are tested in cell order and the FIRST hit wins, so two that overlap
+-- still have exactly one answer. The drawing and the snippet walk them in the
+-- same order, which is the whole requirement -- they need to agree, not to be
+-- disjoint.
+-------------------------------------------------------------------------------
+
+-- Clockwise from straight up: the eight positions around a cell.
+local HALO_DIRS = {
+ { 0, 1 }, { 1, 1 }, { 1, 0 }, { 1, -1 },
+ { 0, -1 }, { -1, -1 }, { -1, 0 }, { -1, 1 },
+}
+
+-- A point on the band that hugs the block, clockwise from the left end of its
+-- top edge. Returns the point, the axis the run travels along there, and which
+-- side of the block it is (+1 up/right). Wrapping a run around a corner costs
+-- nothing in this form: it is one coordinate, and a corner is just a place where
+-- the axis changes.
+--
+-- The corners are ROUNDED, and not for looks. Cells are spaced evenly along the
+-- path, and around a square corner the straight-line distance between two of
+-- them is shorter than the path between them by up to a third. An arc of the
+-- same radius as the band is deep spends the path length the turn needs, so a
+-- run keeps the spacing it asked for as it wraps instead of bunching at the
+-- bend. That is all the rounding buys: the icons drawn on those cells can still
+-- run into each other across a turn, and PerimeterNest is where they are sized
+-- down until they do not.
+local function PerimeterSpan(HX, HY, R)
+ local sx, sy = HX * 2 - R * 2, HY * 2 - R * 2
+ local arc = pi * 0.5 * R
+ return sx, sy, arc, 2 * (sx + sy) + 4 * arc
+end
+
+-- Also returns the OUTWARD normal, which is how a nest deep enough to need a
+-- second row finds where to put it: one row further out along the normal keeps
+-- the rows square with each other on a straight edge and fanned around a corner.
+local function PerimeterPoint(t, HX, HY, R)
+ local sx, sy, arc, L = PerimeterSpan(HX, HY, R)
+ t = t % L
+ if t < sx then return -HX + R + t, HY, "X", 1, 0, 1 end
+ t = t - sx
+ if t < arc then
+ local a = t / R
+ -- Half a turn each: a cell more than halfway round a corner belongs to
+ -- the side it is heading onto, so its box lies across the run it is
+ -- about to join rather than across the one it has left.
+ local ax = (a >= pi * 0.25) and "Y" or "X"
+ return HX - R + R * sin(a), HY - R + R * cos(a), ax, 1, sin(a), cos(a)
+ end
+ t = t - arc
+ if t < sy then return HX, HY - R - t, "Y", 1, 1, 0 end
+ t = t - sy
+ if t < arc then
+ local a = t / R
+ local ax, sg = "Y", 1
+ if a >= pi * 0.25 then ax, sg = "X", -1 end
+ return HX - R + R * cos(a), -HY + R - R * sin(a), ax, sg, cos(a), -sin(a)
+ end
+ t = t - arc
+ if t < sx then return HX - R - t, -HY, "X", -1, 0, -1 end
+ t = t - sx
+ if t < arc then
+ local a = t / R
+ local ax = (a >= pi * 0.25) and "Y" or "X"
+ return -HX + R - R * sin(a), -HY + R - R * cos(a), ax, -1, -sin(a), -cos(a)
+ end
+ t = t - arc
+ if t < sy then return -HX, -HY + R + t, "Y", -1, -1, 0 end
+ t = t - sy
+ local a = t / R
+ local ax, sg = "Y", -1
+ if a >= pi * 0.25 then ax, sg = "X", 1 end
+ return -HX + R - R * cos(a), HY - R + R * sin(a), ax, sg, -cos(a), sin(a)
+end
+
+-- The parameter that advances a run one child pitch of GROUND from ta, not one
+-- child pitch of path. The two agree along a straight edge. Around a turn the
+-- straight line between two cells is shorter than the path between them, so a
+-- run spaced by path alone lands the pair straddling the corner nearer each
+-- other than it asked -- near enough, at the radius this band can afford, that
+-- their icons collide and the shrink pass at the bottom of PerimeterNest takes
+-- the whole run down with them. The step is measured the way overlap is --
+-- the wider axis of the two -- so the icons it separates clear at full size
+-- however the turn lies between them.
+local function PerimeterStep(ta, HX, HY, R, pitch)
+ local ax, ay = PerimeterPoint(ta, HX, HY, R)
+ local dt = pitch
+ -- The point never outruns the parameter, so growing the guess by the
+ -- shortfall cannot overshoot, and each round closes most of what is left:
+ -- a straight edge is exact on the first try, a turn settles within a
+ -- fraction of a pixel well inside the bound.
+ for _ = 1, 8 do
+ local bx, by = PerimeterPoint(ta + dt, HX, HY, R)
+ local sep = max(abs(bx - ax), abs(by - ay))
+ if sep >= pitch - 0.5 then break end
+ dt = dt + (pitch - sep)
+ end
+ return dt
+end
+
+-- The parameter of the point on that same band NEAREST (px, py): PerimeterPoint
+-- read backwards. A lane centres its run here, which is what puts a nest
+-- opposite the entry that opens it however that entry sits in the block.
+--
+-- Worked out per side and per corner arc rather than by walking the path: a
+-- scan fine enough to place a run would cost more than the run does, and a
+-- coarse one would answer a different parameter to the drawing than to the
+-- push.
+--
+-- positive is nestSide, which answers wherever the projection genuinely ties,
+-- and ties are the ordinary case rather than the awkward one. The band stands
+-- the same distance off every edge, so a CORNER cell is exactly as far from
+-- both of the edges that meet there: two ADJACENT sides tie, and the answer is
+-- the corner between them -- a run centred there wraps its L around it. Two
+-- OPPOSITE sides tie for a cell on the block's own middle line, and all four
+-- tie for a cell dead centre, where there is no lean to read at all and the
+-- run belongs on the middle of the nestSide edge.
+--
+-- axisOnly keeps a degenerate one-row or one-column block breaking out ACROSS
+-- itself and only across itself: the sides in line with it are left out of the
+-- projection altogether rather than merely losing a tie-break.
+local function PerimeterNearest(px, py, HX, HY, R, positive, axisOnly)
+ local sx, sy, arc, L = PerimeterSpan(HX, HY, R)
+ -- Where the corner arcs are centred, and so also the corners of the region
+ -- in which some straight side is the nearest part of the path at all.
+ local cx, cy = HX - R, HY - R
+
+ -- Diagonally past one of those centres no straight side can answer, and the
+ -- nearest point is on that corner's own arc, at the angle the offset points
+ -- in. Nothing inside a block ever lands here -- the band stands off further
+ -- than it turns -- but an inverse that held only where the caller happens to
+ -- ask is a trap for the next caller.
+ if not axisOnly and abs(px) > cx and abs(py) > cy then
+ local dx = px - ((px > 0) and cx or -cx)
+ local dy = py - ((py > 0) and cy or -cy)
+ -- Each arc measured from its own start, the way PerimeterPoint runs it,
+ -- and the argument order per arc is that arc's own sin/cos pair there.
+ if px > 0 and py > 0 then return sx + atan2(dx, dy) * R end
+ if px > 0 then return sx + sy + arc + atan2(-dy, dx) * R end
+ if py < 0 then return 2 * sx + sy + 2 * arc + atan2(-dx, -dy) * R end
+ return 2 * sx + 2 * sy + 3 * arc + atan2(dy, -dx) * R
+ end
+
+ -- Distance to each side's straight run in path order -- top, right, bottom,
+ -- left -- with the parameter of the projected point alongside, and the
+ -- middle of the arc FOLLOWING each side, which is the answer whenever that
+ -- side and the next one tie. A side the caller ruled out is never nearest.
+ local far = L * 2
+ local acrossX = (not axisOnly) or axisOnly == "X"
+ local acrossY = (not axisOnly) or axisOnly == "Y"
+ local d = { acrossX and (HY - py) or far, acrossY and (HX - px) or far,
+ acrossX and (py + HY) or far, acrossY and (px + HX) or far }
+ local t = { px + cx,
+ sx + arc + (cy - py),
+ sx + sy + 2 * arc + (cx - px),
+ 2 * sx + sy + 3 * arc + (py + cy) }
+ local mid = { sx + arc * 0.5,
+ sx + sy + arc * 1.5,
+ 2 * sx + sy + 2 * arc + arc * 0.5,
+ 2 * sx + 2 * sy + 3 * arc + arc * 0.5 }
+
+ -- Exact wherever it decides anything -- every cell of a row stands the same
+ -- distance off the edge it is on -- but compared with a tolerance anyway,
+ -- the two distances arriving by different arithmetic.
+ local best = min(d[1], d[2], d[3], d[4])
+ local tie = {}
+ for si = 1, 4 do tie[si] = (d[si] - best) <= 1e-4 end
+
+ if tie[1] and tie[2] and tie[3] and tie[4] then
+ -- No lean in any direction: nestSide picks the edge and the run sits on
+ -- the middle of it.
+ if positive then return sx * 0.5 end
+ return sx * 1.5 + sy + 2 * arc
+ end
+ -- An opposite pair is nestSide's other question, and answering it here
+ -- leaves at most two sides standing, which can then only be adjacent.
+ if tie[1] and tie[3] then tie[positive and 3 or 1] = false end
+ if tie[2] and tie[4] then tie[positive and 4 or 2] = false end
+ for si = 1, 4 do
+ if tie[si] and tie[(si % 4) + 1] then return mid[si] end
+ end
+ for si = 1, 4 do
+ if tie[si] then return t[si] end
+ end
+end
+
+-- Everything the three styles measure from. Sizes are scaled by whatever this
+-- view scaled its geometry by, recovered from the icon size Geom handed back --
+-- the options preview fits a palette to its panel, and a band read at its
+-- literal profile size would draw nests at full distance around a shrunken one.
+function PaletteView:NestMetrics(shown)
+ local p = self:P()
+ local _, iconSize = self:Geom()
+ local pitch = self:Pitch()
+ local base = p.iconSize or 44
+ local k = (base > 0) and (iconSize / base) or 1
+ local cols, rows = self:GridDims(shown)
+
+ local m = {
+ icon = iconSize,
+ pitch = pitch,
+ cols = cols,
+ rows = rows,
+ band = max(0, p.nestBand or NEST_BAND_DEFAULT) * k,
+ gap = (p.fanGap or 10) * k,
+ positive = (p.nestSide or "POSITIVE") == "POSITIVE",
+ -- Everything that is not a halo is a lane. That includes the retired
+ -- POPOUT value a stored profile may still carry: its detached block
+ -- came to answer arming exactly the way the lane does, and the lane is
+ -- what it folded into.
+ style = (p.gridNestStyle == "HALO") and "HALO" or "PERIMETER",
+ }
+ m.halfX = (cols - 1) * 0.5 * pitch
+ m.halfY = (rows - 1) * 0.5 * pitch
+ -- Nested entries are drawn smaller than the palette's own, so a nest reads
+ -- as subordinate to the entry it hangs off rather than as a second block of
+ -- equals.
+ m.childIcon = iconSize * min(1, max(0.4, p.nestScale or 0.8))
+ m.childPitch = m.childIcon + m.gap
+ -- Across the run: how thick the band of boxes is. One icon plus the gap
+ -- either side of it, so the box reaches back to the block's own edge and a
+ -- pointer leaving the parent enters the nest without crossing dead ground.
+ -- The lane works its own out, its children standing off by a gap rather
+ -- than by a band -- see PerimeterNest.
+ m.depth = m.childIcon + m.band
+ -- Whatever Nest Distance was asked for BEYOND the value the profile ships
+ -- with. The lane hugs the block at the default and reads the slider as
+ -- extra clearance on top of that, so the two answers agree wherever the
+ -- user has moved it and the snug read is what an untouched profile gets.
+ m.bandExtra = max(0, (p.nestBand or NEST_BAND_DEFAULT) - NEST_BAND_DEFAULT) * k
+ -- A strip has no interior to displace and no corner to wrap, so the styles
+ -- that rearrange a block have nothing to rearrange: it is always a small
+ -- block of its own, centred on the parent and broken out perpendicular to
+ -- the strip, whatever gridNestStyle asks for.
+ if cols <= 1 or rows <= 1 then m.style = "STRIP" end
+ return m
+end
+
+-- Box for one cell of a run travelling on `axis`.
+local function RunBox(x, y, axis, along, across)
+ if axis == "X" then
+ return { x = x, y = y, hw = along * 0.5, hh = across * 0.5 }
+ end
+ return { x = x, y = y, hw = across * 0.5, hh = along * 0.5 }
+end
+
+-- (A) A halo hugging the block's own perimeter: one run of children, centred on
+-- the point of that perimeter NEAREST the entry they hang off, wrapping the
+-- corners when the run is long. A parent on the middle of an edge is served by
+-- the stretch of lane just outside it, a parent in the middle of the block by
+-- the middle of the nestSide edge, and a corner parent by the corner itself --
+-- the L around it, half the run down each of the two edges that meet there.
+--
+-- The band is ONE lane: two nests near each other sit side by side along it
+-- rather than stacking outward, which is what the eye expects when only one of
+-- them is ever drawn. Runs are packed along the perimeter as a single circular
+-- coordinate, so a run longer than the edge it started on wraps around the
+-- corner instead of shooting off into space.
+function PaletteView:PerimeterNest(claims, shown, m)
+ -- Snug against the block, which is the whole read of this style: the
+ -- children's inner edges stand one gap outside the block's own outer edge,
+ -- so the lane looks like a halo ON the grid rather than a second block of
+ -- entries floating off it. Nest Distance is honoured as clearance BEYOND
+ -- that, so a user who wants the children held further out can still say so.
+ local clear = m.gap + m.bandExtra
+ local standoff = m.icon * 0.5 + clear + m.childIcon * 0.5
+ -- Across the run: from the block's own outer edge to as far past the child
+ -- icon as the child icon is from the block. A pointer leaving the parent
+ -- enters the nest without crossing dead ground, and one that overruns an
+ -- icon on the way out is still inside its box.
+ local depth = m.childIcon + clear * 2
+ local HX, HY = m.halfX + standoff, m.halfY + standoff
+ -- The turn's radius, balanced between the two things it trades off. Too
+ -- small and two icons either side of it crowd, the straight line between
+ -- them being shorter than the path by up to a third around a square corner
+ -- (see PerimeterSpan); too large and the turn itself cuts diagonally in
+ -- across the block's own corner entry, which a lane this snug is close
+ -- enough in to do. Both are straight lines measured against the same floor
+ -- of one child icon, and this is the radius where the two meet. That floor
+ -- shapes the turn; it does not clear the icons on it. Two square icons
+ -- straddling a corner can sit a whole icon apart in a straight line and
+ -- still be short of one on BOTH axes, which is what overlap actually asks,
+ -- so the cells are SPACED by that same measure -- see PerimeterStep --
+ -- and a turn costs the run extra path rather than ground.
+ local turn = (standoff - m.childPitch * 0.5) * sqrt(2)
+ / (2 * sqrt(2) + 1 - sqrt(2) * pi * 0.25)
+ local R = min(depth * 0.5, min(HX, HY) * 0.5, max(0, turn))
+ local sx, sy, arc, L = PerimeterSpan(HX, HY, R)
+
+ local childPitch, childIcon = m.childPitch, m.childIcon
+ -- A strip breaks out ACROSS itself and only across itself: a row of entries
+ -- has an edge at both ends, and a nest hung off one of those would run in
+ -- line with the palette rather than out of it. NestMetrics sends those
+ -- shapes to STRIP before they reach this at all, so this only holds the rule
+ -- for a caller that arrives here anyway.
+ local axisOnly = (m.rows <= 1 and "X") or (m.cols <= 1 and "Y") or nil
+
+ for i = 1, #claims do
+ local c = claims[i]
+ local bx, by = self:GridBase(c.parent, m.cols, m.rows, m.pitch, shown)
+ -- c.icon is not set here: what this style may draw a child at depends
+ -- on how tightly that run ends up packed, which is not known until its
+ -- cells are placed below.
+ -- Along the lane, a caption is drawn at CHILD pitch rather than at the
+ -- pitch the palette's own entries get, so two neighbouring captions
+ -- overlap long before their icons do. Unlabelled, like every other
+ -- style's nest.
+ c.label = false
+ c._bx, c._by = bx, by
+ -- The clear distance this style actually held its children out by, which
+ -- is what CellChildGeom sizes the arming grace from. Nest Distance is
+ -- only part of it here, and at the bottom of that slider's travel none
+ -- of it -- a grace read off the slider instead would go on shrinking
+ -- after the children had stopped moving.
+ c.standoff = clear
+ -- The nearest point of the lane, from the parent's own CELL rather than
+ -- from anywhere on the screen: the push runs long before the open that
+ -- will use it, and the two have to agree.
+ c.t0 = PerimeterNearest(bx, by, HX, HY, R, m.positive, axisOnly)
+ end
+
+ -- How much of the lane each nest may take: the WHOLE of it. Only one nest is
+ -- ever open, and only one is ever armed, so two runs overlapping on the lane
+ -- is not an ambiguity -- the cursor can only be inside the armed claim's
+ -- cells, and the unarmed one is neither drawn nor answerable. Sharing the
+ -- lane out between the claims instead left two nests in neighbouring cells
+ -- with three quarters of a pitch each, which collapses cols to one or two
+ -- and stacks the run outward into rows: the user asked for a run round the
+ -- block and got a cluster hanging off the entry.
+ --
+ -- L is the bound that remains, and it is a real one: a run longer than the
+ -- lane would wrap past its own first cell and put two children on the same
+ -- ground.
+ for i = 1, #claims do
+ local c = claims[i]
+ -- A crowded nest EXTENDS along the perimeter first, as far as the lane
+ -- goes: wrapping further round the block costs the user nothing, and one
+ -- long run is the shape this style is for. Only a nest whose children do
+ -- not all fit on the lane spills the rest into a second row further out.
+ --
+ -- How many fit is WALKED rather than divided out of L: a step spends
+ -- more than a pitch of path at a turn, so a row sized by L / pitch
+ -- could wrap past its own first cell exactly when the lane is full
+ -- enough for it to matter.
+ local room, walked = 1, 0
+ while room < c.n do
+ local step = PerimeterStep(c.t0 + walked, HX, HY, R, childPitch)
+ if walked + step > L - childPitch then break end
+ walked, room = walked + step, room + 1
+ end
+ local cols = room
+ -- One row of parameters per row of cells, each row centred on t0 the
+ -- way the even spacing was: walked once from a guessed start to learn
+ -- the path length it really spends, then again from the start that
+ -- puts half of that either side of t0.
+ local rowTs = {}
+ for cr = 0, ceil(c.n / cols) - 1 do
+ local inRow = min(cols, c.n - cr * cols)
+ local start = c.t0 - (inRow - 1) * 0.5 * childPitch
+ local row
+ for _ = 1, 2 do
+ row = { start }
+ for jr = 2, inRow do
+ row[jr] = row[jr - 1]
+ + PerimeterStep(row[jr - 1], HX, HY, R, childPitch)
+ end
+ start = c.t0 - (row[inRow] - row[1]) * 0.5
+ end
+ rowTs[cr + 1] = row
+ end
+ c.cells = {}
+ -- The sides the run came down on, in the order it reached them. A run
+ -- that wrapped a corner has cells on two of them, and its regions are
+ -- one tight box per side rather than one across the L -- see
+ -- CellChildGeom, which is where that matters.
+ local groups, bySide = {}, {}
+ for j = 1, c.n do
+ local cr = floor((j - 1) / cols)
+ local cc = (j - 1) % cols
+ local t = rowTs[cr + 1][cc + 1]
+ local x, y, axis, sign, nx, ny = PerimeterPoint(t, HX, HY, R)
+ -- Rows past the first sit one row further out along the outward
+ -- normal, which keeps them square on a straight edge and fanned
+ -- around a corner.
+ local outw = cr * (childIcon + m.gap)
+ local box = RunBox(x + nx * outw, y + ny * outw,
+ axis, childPitch, depth)
+ c.cells[j] = box
+ -- PerimeterPoint hands a cell more than halfway round a corner to
+ -- the side it is heading onto, so these come out as the runs the eye
+ -- actually reads rather than as a split at the corner's own edge.
+ local key = axis .. sign
+ local g = bySide[key]
+ if not g then
+ g = { axis = axis, sign = sign, cells = {}, order = #groups + 1 }
+ bySide[key], groups[#groups + 1] = g, g
+ end
+ g.cells[#g.cells + 1] = box
+ end
+
+ -- The walk above holds every CONSECUTIVE pair a whole pitch apart, but
+ -- it says nothing about the pairs it never measured: a run that fills
+ -- the lane meets its own first cell across the seam, and a spilled row
+ -- crosses back over the one under it wherever the fan folds. So this
+ -- run's icons come down to the tightest pair it actually has. The
+ -- BOXES keep their pitch -- the run holds its length, its regions and
+ -- its gates, and the hit test answers exactly what it did -- and an
+ -- ordinary run has no pair nearer than a pitch, so it comes out at
+ -- the size it asks for. nestScale is the ceiling either way: this only
+ -- ever takes size away.
+ local tight = childIcon
+ for ja = 1, c.n - 1 do
+ for jb = ja + 1, c.n do
+ local pa, pb = c.cells[ja], c.cells[jb]
+ -- Square icons drawn on the cell centres, so a pair clears as
+ -- soon as ONE axis separates them by a whole icon.
+ local sep = max(abs(pa.x - pb.x), abs(pa.y - pb.y))
+ if sep < tight then tight = sep end
+ end
+ end
+ c.icon = tight
+
+ -- Nearest side first, measured to the nearest cell on it: that is the
+ -- side the claim reports as its own axis, and a run long enough to wrap
+ -- onto more sides than there are region gates for then loses the
+ -- FURTHEST of them rather than the one the reach actually crosses.
+ for gi = 1, #groups do
+ local near = math.huge
+ local cells = groups[gi].cells
+ for ci = 1, #cells do
+ local dx, dy = cells[ci].x - c._bx, cells[ci].y - c._by
+ near = min(near, dx * dx + dy * dy)
+ end
+ groups[gi].near = near
+ end
+ tsort(groups, function(g1, g2)
+ if g1.near ~= g2.near then return g1.near < g2.near end
+ return g1.order < g2.order
+ end)
+ c.groups = groups
+ c.axis, c.sign = groups[1].axis, groups[1].sign
+ end
+ return claims
+end
+
+-- (B) The eight positions around the parent's own cell, the block behind them
+-- faded and shrunk. The neighbours keep their centres -- the halo is drawn tight
+-- enough that they stay outside it -- so what they lose is the ground a pointer
+-- could have approached them across, not the entries themselves.
+function PaletteView:HaloNest(claims, shown, m)
+ -- Three boxes across must stay inside one pitch either side, or a
+ -- neighbouring entry's own centre would fall inside the halo and become
+ -- unselectable while the halo is up. That caps the ring at two thirds of a
+ -- pitch, and the ring is pushed right out to it: the parent icon sits in the
+ -- middle at full size and a ring any tighter has its children touching it.
+ local hp = m.pitch * 0.62
+ -- Small enough that a full-size parent still has clear ground around it,
+ -- which is the whole read of this style -- children AROUND an entry, not
+ -- crowding it.
+ local icon = min(m.childIcon, hp * 0.62)
+ for i = 1, #claims do
+ local c = claims[i]
+ local bx, by = self:GridBase(c.parent, m.cols, m.rows, m.pitch, shown)
+ -- No axis and no side: a halo surrounds its parent rather than coming
+ -- out of one edge of the block, so there is no "other side" for the hub
+ -- caption to move to and it keeps the placement it would have had.
+ c.icon, c.dim = icon, true
+ -- The parent draws back to leave the ring somewhere to be. It keeps its
+ -- full colour, unlike the rest of the block: it is what the ring is
+ -- about, and dimming it would leave nothing saying which entry opened.
+ c.parentScale = 0.6
+ -- Eight captions around one icon are eight captions on top of each
+ -- other. At this size the icon is the whole of what can be read.
+ c.label = false
+ c.cells = {}
+ for j = 1, min(c.n, #HALO_DIRS) do
+ local d = HALO_DIRS[j]
+ c.cells[j] = { x = bx + d[1] * hp, y = by + d[2] * hp,
+ hw = hp * 0.5, hh = hp * 0.5 }
+ end
+ -- The centre is left to the parent, which fires nothing: a pointer that
+ -- comes to rest back on the entry it opened does nothing, rather than
+ -- picking whichever child happened to be nearest.
+ c.n = #c.cells
+ end
+ return claims
+end
+
+-- (C) A single row or column's nest: a small block of its own, centred on the
+-- parent's own place along the strip and broken out perpendicular to it. A
+-- strip has only the one line every parent already sits on, so there is no
+-- interior for PERIMETER's lane to run around -- the crowding that style
+-- solves by wrapping corners never arises here, because every claim already
+-- owns a stretch of the line to itself the moment it owns a parent cell.
+-- nestSide answers the side question, there being no lean to read off a line.
+function PaletteView:StripCellNest(claims, shown, m)
+ -- rows <= 1 means the strip runs along X, so its nests break out along Y;
+ -- cols <= 1 is the other way round. NestMetrics only reaches this style
+ -- when one of the two is true.
+ local axis = (m.rows <= 1) and "X" or "Y"
+ local sign = m.positive and 1 or -1
+ local out = m.icon * 0.5 + m.band + m.childIcon * 0.5
+
+ for i = 1, #claims do
+ local c = claims[i]
+ local bx, by = self:GridBase(c.parent, m.cols, m.rows, m.pitch, shown)
+ c.icon = m.childIcon
+ -- Packed at child pitch like every other nest style's captions, a run
+ -- of them along a strip collides just as readily as PERIMETER's lane
+ -- does, so this style goes unlabelled too.
+ c.label = false
+ c.axis, c.sign = axis, sign
+ c._bx, c._by = bx, by
+ -- Position along the strip's own axis, so two claims that are close
+ -- together can be told apart from two that are not.
+ c.t0 = (axis == "X") and bx or by
+ end
+
+ for i = 1, #claims do
+ local c = claims[i]
+ -- Half the room this claim's block may spread into along the strip:
+ -- out to the midpoint with the nearest OTHER nest's own parent. A
+ -- strip is a straight line, not PERIMETER's closed loop, so this is a
+ -- plain distance rather than a distance around a wrap -- but the
+ -- answer it feeds into cols is the same one: a crowded nest gives up
+ -- columns and grows another row instead of colliding with its
+ -- neighbour.
+ local room = math.huge
+ for j = 1, #claims do
+ if j ~= i then room = min(room, abs(claims[j].t0 - c.t0) * 0.5) end
+ end
+ local ccols = min(MAX_SLOTS, max(1, ceil(sqrt(c.n))))
+ if room < math.huge then
+ ccols = min(ccols, max(1, floor(room * 2 / m.childPitch)))
+ end
+
+ c.cells = {}
+ for j = 1, c.n do
+ local cr = floor((j - 1) / ccols)
+ local cc = (j - 1) % ccols
+ local row = min(ccols, c.n - cr * ccols)
+ local a = (cc - (row - 1) * 0.5) * m.childPitch
+ local d = out + cr * m.childPitch
+ local x, y
+ if axis == "X" then x, y = c._bx + a, c._by + sign * d
+ else x, y = c._bx + sign * d, c._by + a end
+ c.cells[j] = { x = x, y = y,
+ hw = m.childPitch * 0.5, hh = m.childPitch * 0.5 }
+ end
+ end
+ return claims
+end
+
+-- A scroll-steered strip's nest. The wheel decides which entry is selected and
+-- that entry is always the one drawn at the CENTRE, so its children break out
+-- across the strip from there -- the same perpendicular row a pointer-steered
+-- strip gets, at the one place this layout can put it.
+--
+-- Every nest is built at that same centre. Nothing is lost by it: only the entry
+-- the wheel has landed on is ever live, so two nests can no more be reached at
+-- once than two entries can.
+--
+-- Measured from where the palette was OPENED rather than from where the strip is
+-- drawn, because that is what this layout's cancel is measured from and the two
+-- have to be one geometry. The drawing takes the difference out again.
+function PaletteView:StripNest(claims, shown)
+ local m = self:NestMetrics(shown)
+ local horiz = self:FanHoriz()
+ local axis = horiz and "X" or "Y"
+ local sign = m.positive and 1 or -1
+ local out = m.icon * 0.5 + m.band + m.childIcon * 0.5
+
+ for i = 1, #claims do
+ local c = claims[i]
+ c.icon = m.childIcon
+ c.axis, c.sign = axis, sign
+ -- Unlabelled, like the strip's own entries: at strip spacing the
+ -- captions of neighbouring icons collide, and a nest is drawn at the
+ -- same spacing or tighter.
+ c.label = false
+ c.cells = {}
+ for j = 1, c.n do
+ local a = (j - (c.n + 1) * 0.5) * m.childPitch
+ local x, y
+ if horiz then x, y = a, sign * out else x, y = sign * out, a end
+ c.cells[j] = RunBox(x, y, axis, m.childPitch, m.depth)
+ end
+ -- How far across the strip the pointer may travel toward this nest
+ -- before it counts as thrown clear. Without it the strip's own cancel
+ -- sits in the gap between an entry and its children, and reaching for
+ -- one of them closes the palette instead.
+ c.across = out + m.depth * 0.5
+ end
+ return claims
+end
+
+-- Nested cells for a block layout: the grid, and a pointer-steered strip, which
+-- is a grid one entry deep.
+function PaletteView:CellChildGeom(claims, shown)
+ local m = self:NestMetrics(shown)
+ if m.style == "STRIP" then
+ self:StripCellNest(claims, shown, m)
+ elseif m.style == "HALO" then
+ self:HaloNest(claims, shown, m)
+ else
+ self:PerimeterNest(claims, shown, m)
+ end
+
+ -- The ground between a parent and its children, so that crossing it keeps
+ -- the nest on screen. A nest sitting clear of the block has a gap in front
+ -- of it that belongs to no cell of its own, and a nest that vanished halfway
+ -- through the reach for it could not be reached at all.
+ --
+ -- c.regions also doubles as the claim's REGION gates (see EnsureGates):
+ -- the rects a secure OnLeave watches, geometrically, to know the cursor
+ -- has actually left this nest's ground, parent cell and all. c.parentBox
+ -- is the other one, the claim's own cell alone -- the gate whose OnEnter
+ -- arms it in the first place. Nothing here decides what a release FIRES,
+ -- only what is drawn and what is armable: an entry under either box stays
+ -- exactly as selectable as it was.
+ --
+ -- HALO sets neither axis nor sign -- its ring surrounds the parent on
+ -- every side, so there is no one direction to run a corridor in, and the
+ -- old single bounding box (parent cell plus every ring position) is
+ -- already close enough to the true shape that a second rect buys
+ -- nothing: the neighbour centres HaloNest leaves clear of the ring stay
+ -- clear of this box too. Every other style hangs its nest off ONE side
+ -- of the parent, so a box across the two would swallow whatever plain
+ -- ground of the block lies between them -- the dim-never-backs-out
+ -- complaint. Those get the true union instead: the parent's own cell,
+ -- the nest's own tight box, and a corridor one child cell wide
+ -- connecting them, so standing on the block's own ground either side of
+ -- that corridor is standing outside the nest. Those also get the
+ -- overshoot grace (see GraceBox); the halo's box does NOT, because it
+ -- already reaches to just short of its neighbours' centres and a grace on
+ -- top of that would swallow one, leaving that entry unselectable while
+ -- the ring is up.
+ --
+ -- A lane brings the same complaint back in a second shape: a run that
+ -- wrapped a corner has cells on two edges of the block, and ONE box
+ -- around those swallows the block's own corner ground between them. So a
+ -- run that carries its sides (c.groups) gets one box per side, each with
+ -- its own way back to the parent folded in -- see RunReach.
+ -- Every parent cell first: the regions below take the OTHER claims' cells
+ -- out of their own coverage (see ParentHoles), so they all have to exist
+ -- before the first of them is built.
+ for i = 1, #claims do
+ local c = claims[i]
+ local bx, by = self:GridBase(c.parent, m.cols, m.rows, m.pitch, shown)
+ c.parentBox = { x = bx, y = by, hw = m.pitch * 0.5, hh = m.pitch * 0.5 }
+ end
+
+ for i = 1, #claims do
+ local c = claims[i]
+ local holes = ParentHoles(claims, i)
+
+ if c.axis then
+ -- How far a claim's ground reaches past its own edges. c.standoff
+ -- is a lane saying how far out it actually put its children, which
+ -- for that style is not the Nest Distance at all -- it hugs the
+ -- block, and the slider only adds to that -- and a grace read off
+ -- the slider there would leave the bottom of its travel moving
+ -- nothing but invisible slack.
+ local grace = max(c.standoff or m.band, 0.75 * m.childPitch)
+ local sides = c.groups
+ c.regions = { c.parentBox }
+ if sides then
+ -- Nearest side first, PerimeterNest having ordered them: a run
+ -- that reached more sides than there are region gates for then
+ -- drops the far ones rather than the one the reach crosses.
+ for gi = 1, #sides do
+ local run = RunReach(c.parentBox, NestBBox(sides[gi].cells))
+ AddRegion(c, GraceBox(run, grace, sides[gi].axis, sides[gi].sign),
+ sides[gi].axis, holes)
+ end
+ else
+ local nest = NestBBox(c.cells)
+ -- Measured from the TIGHT box, before the grace widens it: the
+ -- corridor is as wide as the nest it leads to, and inflating
+ -- the nest first would spread the corridor sideways across the
+ -- block's own ground as well.
+ local corridor = CorridorBox(c.parentBox, nest, c.axis, c.sign,
+ m.childPitch)
+ AddRegion(c, GraceBox(nest, grace, c.axis, c.sign), c.axis, holes)
+ AddRegion(c, corridor, c.axis, holes)
+ end
+ else
+ local pb = c.parentBox
+ local x0, x1 = pb.x - m.pitch * 0.5, pb.x + m.pitch * 0.5
+ local y0, y1 = pb.y - m.pitch * 0.5, pb.y + m.pitch * 0.5
+ for j = 1, c.n do
+ local b = c.cells[j]
+ x0, x1 = min(x0, b.x - b.hw), max(x1, b.x + b.hw)
+ y0, y1 = min(y0, b.y - b.hh), max(y1, b.y + b.hh)
+ end
+ c.regions = {}
+ AddRegion(c, EdgeBox(x0, x1, y0, y1), c.axis, holes)
+ end
+ end
+ return claims
+end
+
+-- The nested cell whose box holds this offset, WITHIN THE ARMED CLAIM only.
+-- Read by the drawing; the snippet carries the same test over the same
+-- numbers, gated the same way -- see ArmedClaim and the release branch of
+-- SNIPPET_PRE. An unarmed claim answers nothing here at all: the whole point
+-- of arming is that a nest's ground is not live until the cursor has actually
+-- passed through the entry that opens it.
+function PaletteView:NestHit(dx, dy, armed)
+ local c = armed and self.claims and self.claims[armed]
+ if not c or not c.cells then return end
+ for j = 1, c.n do
+ local b = c.cells[j]
+ if abs(dx - b.x) <= b.hw and abs(dy - b.y) <= b.hh then
+ return c.base + j, c
+ end
+ end
+end
+
+-- Lay the grid out and select the entry nearest the pointer. noPointer draws it
+-- evenly with nothing selected, which is what Layout and the editor want.
+function PaletteView:AdvanceGrid(noPointer)
+ local p = self:P()
+ local shown = self.shownCount
+ if not p or shown < 1 then
+ self:SetSelection(nil)
+ return
+ end
+
+ local _, iconSize = self:Geom()
+ local pitch = iconSize + (p.fanGap or 10)
+ local decay, aDecay = FalloffRatios(p)
+ local minS = p.fanMinScale or 0.30
+ local minA = p.fanMinAlpha or 0.12
+ if self.opts.interactive then
+ minS = max(minS, FAN_EDIT_MIN_SCALE)
+ minA = max(minA, FAN_EDIT_MIN_ALPHA)
+ end
+
+ local cols, rows = self:GridDims()
+ local frame = self.frame
+
+ -- Pointer offset from the grid's centre, or nil while the movement gate is
+ -- still armed -- without it an entry is selected the instant the grid opens
+ -- and "open and release" would fire instead of cancelling.
+ local dx, dy
+ local fx, fy = frame:GetCenter()
+ if fx and not noPointer then
+ local es = frame:GetEffectiveScale()
+ local mx, my = GetCursorPosition()
+ mx, my = mx / es, my / es
+ if not self._steered
+ and (abs(mx - self._gateX) >= 1 or abs(my - self._gateY) >= 1) then
+ self._steered = true
+ end
+ if self._steered then dx, dy = mx - fx, my - fy end
+ end
+
+ -- Nested cells first, and by CONTAINMENT rather than by nearness: a nest is
+ -- somewhere you are in or out of. Inside a box, that child regardless of
+ -- what the block holds underneath -- which is what lets a halo sit over the
+ -- entries around its parent. Outside every box, the block answers as though
+ -- the nest were not there, so leaving a run in any direction leaves the nest.
+ --
+ -- ONLY the armed claim, though: this is the pass-through rule. A nest
+ -- earns the right to answer here by having actually had the cursor pass
+ -- over its parent entry first -- see ArmedClaim and the gate frames
+ -- EnsureGates builds. An unarmed claim's ground answers as though it held
+ -- no nest at all, which is exactly what lets two claims share ground
+ -- without one springing open behind the other's back.
+ local best, bestK
+ local armed = self:ArmedClaim()
+ if dx then best = self:NestHit(dx, dy, armed) end
+
+ -- Nearest of the palette's own, once the nests have declined. Past
+ -- GRID_REACH cells from every one of them nothing is selected -- this
+ -- layout's cancel, and it has no dead zone, a grid's centre being an
+ -- ordinary cell.
+ if dx and not best then
+ for i = 1, shown do
+ local bx, by = self:GridBase(i, cols, rows, pitch)
+ local ox, oy = (dx - bx) / pitch, (dy - by) / pitch
+ -- ^0.5, not sqrt: the snippet has no sqrt and must use the power
+ -- form, and the two are not bit-identical in Lua 5.1. Matching them
+ -- keeps a cursor exactly on the reach boundary from selecting one
+ -- entry on screen and firing another.
+ local k = (ox * ox + oy * oy) ^ 0.5
+ if not bestK or k < bestK then best, bestK = i, k end
+ end
+ if bestK and bestK > GRID_REACH then best = nil end
+ end
+
+ -- Which nest is open, settled before anything is drawn: a style that fades
+ -- the block behind it has to know while the block is being painted, not a
+ -- frame later. SetSelection's own call then finds nothing left to do.
+ self:UpdateNestShown(best)
+ local open = self._openClaim
+ local preview = self._previewClaim
+ local dim = (open and open.dim) and NEST_DIM_ALPHA or 1
+ local shrink = (open and open.dim) and NEST_DIM_SCALE or 1
+
+ for i = 1, shown do
+ local w = self.widgets[i]
+ local bx, by = self:GridBase(i, cols, rows, pitch)
+
+ -- Falloff is the 2D distance, in cells. A grid has no privileged axis,
+ -- so projecting onto one -- as the strip does -- would make the zoom
+ -- respond to sideways movement it should ignore.
+ --
+ -- Each axis is flattened over the cell before they are combined, so the
+ -- whole of a cell -- corners included -- reads as zero cells away. See
+ -- FalloffK.
+ local s, a = max(minS, decay), 1
+ if dx then
+ local ox = FalloffK(abs(dx - bx) / pitch)
+ local oy = FalloffK(abs(dy - by) / pitch)
+ local k = (ox * ox + oy * oy) ^ 0.5
+ s = max(minS, decay ^ k)
+ a = max(minA, aDecay ^ k)
+ end
+ -- The entry a nest hangs off keeps its colour: it is what the nest is
+ -- about, and dimming it would leave nothing on screen saying which entry
+ -- was opened. It may still draw back to make room -- the halo needs the
+ -- ground its parent would otherwise be standing on.
+ if open then
+ if i ~= open.parent then
+ s, a = s * shrink, a * dim
+ elseif open.parentScale then
+ s = s * open.parentScale
+ end
+ end
+
+ w:SetAlpha(a)
+ w.baseSize = iconSize * s
+ w:SetSize(iconSize * s, iconSize * s)
+ w:ClearAllPoints()
+ w:SetPoint("CENTER", frame, "CENTER", bx, by)
+ w:Show()
+ end
+
+ -- Nested cells are drawn at a flat size. They live inside boxes rather than
+ -- on a falloff, and a child shrinking as the pointer crossed its own box
+ -- would suggest a nearness that decides nothing here.
+ --
+ -- The preview's alpha is applied here as well as in UpdateNestShown, for the
+ -- same reason the zoom below is: this pass rewrites every cell's alpha every
+ -- frame, so one set only where the state changed would last a single frame.
+ local claims = self.claims
+ for ck = 1, (claims and #claims or 0) do
+ local c = claims[ck]
+ for j = 1, c.n do
+ local cell = c.cells and c.cells[j]
+ local w = c.base and self.widgets[c.base + j]
+ if cell and w then
+ w:SetAlpha((c == preview) and NEST_PREVIEW_ALPHA or 1)
+ w.baseSize = c.icon
+ w:SetSize(c.icon, c.icon)
+ w:ClearAllPoints()
+ w:SetPoint("CENTER", frame, "CENTER", cell.x, cell.y)
+ end
+ end
+ end
+
+ -- Magnify the chosen cell where it stands. Applied here rather than left to
+ -- the selection paint because the sizes above are rewritten every frame,
+ -- which would erase a zoom applied only when the selection changed.
+ if best then
+ local w = self.widgets[best]
+ local z = SelectedZoom()
+ w:SetSize(w.baseSize * z, w.baseSize * z)
+ end
+ self:SetSelection(best)
+end
+
+-- Centre the strip on a slot with no animation. The options preview uses this
+-- to follow the entry the user has clicked.
+function PaletteView:SetFanCenter(index)
+ if not index or self.shownCount < 1 then return end
+ self.fanTarget = index
+ self.fanVisual = index
+ self:ApplyFanGeometry()
+ self:SetSelection(index)
+end
+
+-- Half the drawn strip, along its own axis, out to the far edge of the last
+-- visible entry. Sizes the frame and bounds the cancel, from one number: a
+-- second copy of this would drift the moment either falloff setting moved.
+function PaletteView:FanHalfLength()
+ local p = self:P()
+ local _, iconSize = self:Geom()
+ local shown = self.shownCount
+ -- The editor culls nothing, so its strip carries the whole palette -- but
+ -- the strip is cyclic and ApplyFanGeometry folds every offset into
+ -- [-shown/2, shown/2], so the farthest entry drawn is half the palette out.
+ -- Measured over the whole of it, the editor's frame came out twice as long
+ -- as the strip inside it.
+ local window = self.opts.interactive and (shown * 0.5)
+ or ((p and p.fanVisible) or 3)
+ local minS = self.opts.interactive and FAN_EDIT_MIN_SCALE
+ or ((p and p.fanMinScale) or 0.30)
+ -- Plus the room ApplyFanGeometry leaves for the selected entry to grow into,
+ -- which every offset past the centre carries. Left out, the frame would be
+ -- narrower than the strip drawn in it and the cancel box would sit inside
+ -- the last entry rather than beyond it.
+ return FanOffset(window, iconSize, (p and p.fanGap) or 10,
+ (FalloffRatios(p)), minS)
+ + iconSize + iconSize * (SelectedZoom() - 1) * 0.5
+end
+
+-- How far the pointer has been carried toward leaving the strip: 0 while it is
+-- still on it, 1 at the edge of the cancel box and beyond. Answers 0 for any
+-- view with no gate origin -- the options preview, which has no pointer gesture
+-- at all -- so only the live palette can be cancelled this way.
+--
+-- The cancel and the fade read this one number, so the strip is at its dimmest
+-- exactly where a release stops firing anything.
+-- Pointer offset from the point the palette was opened at, which is what the
+-- strip's cancel and its nests are both measured from.
+function PaletteView:StripOffset()
+ if not self._gateX then return nil end
+ local es = self.frame:GetEffectiveScale()
+ local mx, my = GetCursorPosition()
+ return mx / es - self._gateX, my / es - self._gateY
+end
+
+-- The nest the entry at `index` opens, if it opens one.
+function PaletteView:ClaimFor(index)
+ local claims = self.claims
+ for k = 1, (index and claims and #claims or 0) do
+ if claims[k].parent == index then return claims[k] end
+ end
+end
+
+-- Which entry the wheel has landed on, folded into range.
+function PaletteView:StripTarget()
+ local shown = self.shownCount
+ if not self.fanTarget or shown < 1 then return nil end
+ return ((self.fanTarget - 1) % shown) + 1
+end
+
+function PaletteView:FanCancelProgress()
+ if not self._gateX then return 0 end
+ local p = self:P()
+ local _, iconSize = self:Geom()
+ local along, across = self:StripOffset()
+ if not self:FanHoriz() then along, across = across, along end
+
+ local margin = FAN_CANCEL_REACH * (iconSize + ((p and p.fanGap) or 10))
+ -- Travel toward the nest the selected entry opens does not count as leaving:
+ -- its children sit past the ordinary margin, so measuring them by it would
+ -- cancel the palette on the way to reaching them. Only on the side the nest
+ -- is on, and only while that entry is the one the wheel is on.
+ local acrossMargin = margin
+ local claim = self:ClaimFor(self:StripTarget())
+ if claim and claim.across and (across > 0) == (claim.sign > 0) then
+ acrossMargin = max(margin, claim.across)
+ end
+
+ return max(abs(across) / acrossMargin,
+ abs(along) / (self:FanHalfLength() + margin))
+end
+
+-- Has the pointer been thrown clear of the strip?
+function PaletteView:FanCancelled()
+ return self:FanCancelProgress() > 1
+end
+
+-- The strip's own alpha, fading toward FAN_FADE_MIN as the pointer approaches
+-- the cancel box. It never reaches zero: a strip the player has left still has
+-- to be findable, because bringing the pointer back re-selects the entry it is
+-- centred on.
+function PaletteView:FanCancelAlpha()
+ local k = self:FanCancelProgress()
+ if k <= FAN_FADE_START then return 1 end
+ if k >= 1 then return FAN_FADE_MIN end
+ local t = (k - FAN_FADE_START) / (1 - FAN_FADE_START)
+ return 1 - (1 - FAN_FADE_MIN) * t ^ FAN_FADE_POWER
+end
+
+-- Advance the settle animation and publish the centred entry as the selection.
+-- The LOGICAL index moves the instant the tick arrives; only the geometry is
+-- interpolated. A release mid-animation therefore always fires what the user
+-- last scrolled to, never whatever the strip happens to be sliding past.
+function PaletteView:AdvanceFan(elapsed)
+ local shown = self.shownCount
+ if shown < 1 then
+ self:SetSelection(nil)
+ return
+ end
+
+ -- The live strip's index is owned by the secure snippet: an addon may not
+ -- write a secure button's attributes in combat, so the mouse wheel is
+ -- handled in the sandbox and left here to be read. Reading an attribute
+ -- from Lua is unrestricted, so this works in combat and out. Other views
+ -- (the options preview) keep driving fanTarget themselves.
+ if self.opts.live and scrollCatcher then
+ self.fanTarget = tonumber(scrollCatcher:GetAttribute("eapFanTarget"))
+ end
+
+ -- Published BEFORE the geometry below, which magnifies whichever entry is
+ -- selected as it places it. The strip keeps sliding to wherever the wheel
+ -- has left it while the pointer is clear of it, so bringing the pointer back
+ -- shows the entry that would fire, already settled.
+ local target = self:StripTarget()
+ local claim = self:ClaimFor(target)
+ local sel
+ -- Into the nest the wheel's entry opens, if the pointer has gone there. The
+ -- wheel says WHICH nest; the pointer only says which of its children, and
+ -- says nothing at all when the entry the wheel is on does not nest.
+ local dx, dy = self:StripOffset()
+ if claim and dx and claim.base then
+ for j = 1, claim.n do
+ local b = claim.cells[j]
+ if abs(dx - b.x) <= b.hw and abs(dy - b.y) <= b.hh then
+ sel = claim.base + j
+ break
+ end
+ end
+ end
+ if not sel and target and not self:FanCancelled() then sel = target end
+ self:SetSelection(sel)
+
+ -- Nested cells, placed against the point the palette was opened at rather
+ -- than against the frame -- the difference is nothing when the palette opens
+ -- under the cursor and everything when it is pinned to the screen.
+ local claims = self.claims
+ if claims and dx then
+ local fx, fy = self.frame:GetCenter()
+ local ox, oy = 0, 0
+ if fx then ox, oy = self._gateX - fx, self._gateY - fy end
+ for k = 1, #claims do
+ local c = claims[k]
+ for j = 1, c.n do
+ local w = c.base and self.widgets[c.base + j]
+ if w then
+ w:ClearAllPoints()
+ w:SetPoint("CENTER", self.frame, "CENTER",
+ ox + c.cells[j].x, oy + c.cells[j].y)
+ end
+ end
+ end
+ end
+
+ local target = self.fanTarget or 1
+ local cur = self.fanVisual or target
+ if cur ~= target then
+ local p = self:P()
+ local t = (p and p.fanAnimTime) or 0.10
+ if t <= 0 then
+ cur = target
+ else
+ cur = cur + (target - cur) * min(1, (elapsed or 0) / t)
+ -- Snap the tail: an asymptote would keep this view dirty forever.
+ if abs(target - cur) < 0.001 then cur = target end
+ end
+ self.fanVisual = cur
+ self:ApplyFanGeometry()
+ end
+end
+
+-- This view's centre as a delta from UIParent's centre, in UIParent-logical
+-- units. Both sides are converted through their effective scales because the
+-- strip carries the user's own Scale setting while UIParent carries the game's.
+function PaletteView:ScreenOffset()
+ local frame = self.frame
+ local cx, cy = frame:GetCenter()
+ if not cx then return 0, 0 end
+ local ux, uy = UIParent:GetCenter()
+ if not ux then return 0, 0 end
+
+ local k = frame:GetEffectiveScale() / UIParent:GetEffectiveScale()
+ return cx * k - ux, cy * k - uy
+end
+
+-- Hang the caption on the side that faces the middle of the screen, so a strip
+-- opened near an edge writes inward -- where there is room -- instead of off
+-- the edge. Justification follows, always hugging the icon it belongs to: the
+-- text grows away from the strip, never back across it.
+--
+-- Called after the frame is POSITIONED, not from Layout alone: in cursor mode
+-- the strip lands somewhere new on every open, so the quadrant is only known
+-- once PositionPalette has run.
+function PaletteView:PlaceHubText()
+ local hub = self.hub
+ local mode = self:LayoutMode()
+ local _, iconSize = self:Geom()
+
+ hub.text:ClearAllPoints()
+ hub.hint:ClearAllPoints()
+
+ if mode == "ARC" then
+ hub.text:SetJustifyH("CENTER")
+ hub.text:SetPoint("CENTER", hub, "CENTER", 0, 0)
+ hub.hint:SetPoint("TOP", hub.text, "BOTTOM", 0, -2)
+ return
+ end
+
+ -- Half the extent the caption has to clear on its own axis. A strip is one
+ -- entry deep, but a grid is as deep as it has rows.
+ local pad = iconSize * 0.5 + 14
+ if mode == "GRID" then
+ local p = self:P()
+ local _, rows = self:GridDims()
+ pad = rows * (iconSize + ((p and p.fanGap) or 10)) * 0.5 + 14
+ end
+ -- The editor is pinned rather than quadrant-tested: its block sits wherever
+ -- the options page happens to be scrolled to, and a caption that jumped
+ -- sides as the user scrolled would read as a glitch.
+ local dx, dy = 0, 0
+ if not self.opts.interactive then dx, dy = self:ScreenOffset() end
+
+ -- A nest has already claimed one side of the block, so the caption takes the
+ -- other -- overriding the quadrant test below, which is about screen room
+ -- rather than about what is already sitting there. Written as a nudge to
+ -- dx/dy so there is still ONE placement rule underneath: the nest simply
+ -- decides which way the block is "facing".
+ -- Read against the tests below, which are the other way round from how they
+ -- sound: dy < 0 puts the caption ABOVE, so a nest above wants dy positive.
+ if self.nestAxis == "X" then
+ dy = (self.nestSign > 0) and 1 or -1
+ elseif self.nestAxis == "Y" then
+ dx = (self.nestSign > 0) and 1 or -1
+ end
+
+ -- A grid captions like a horizontal strip: it is as wide as it is tall, so
+ -- there is no side with obviously more room, and above/below keeps the text
+ -- clear of every cell rather than only of the middle column.
+ if mode == "GRID" or (mode == "FAN" and self:FanHoriz()) then
+ -- Below the middle of the screen -> caption above the strip.
+ hub.text:SetJustifyH("CENTER")
+ if dy < 0 then
+ hub.text:SetPoint("BOTTOM", hub, "CENTER", 0, pad)
+ hub.hint:SetPoint("BOTTOM", hub.text, "TOP", 0, 2)
+ else
+ hub.text:SetPoint("TOP", hub, "CENTER", 0, -pad)
+ hub.hint:SetPoint("TOP", hub.text, "BOTTOM", 0, -2)
+ end
+ else
+ -- Right of the middle of the screen -> caption to the LEFT, right
+ -- justified so its last character sits against the icon.
+ if dx > 0 then
+ hub.text:SetJustifyH("RIGHT")
+ hub.text:SetPoint("RIGHT", hub, "CENTER", -pad, 0)
+ hub.hint:SetPoint("TOPRIGHT", hub.text, "BOTTOMRIGHT", 0, -2)
+ else
+ hub.text:SetJustifyH("LEFT")
+ hub.text:SetPoint("LEFT", hub, "CENTER", pad, 0)
+ hub.hint:SetPoint("TOPLEFT", hub.text, "BOTTOMLEFT", 0, -2)
+ end
+ end
+end
+
+function ns.CreatePaletteView(parent, opts)
+ local view = setmetatable({
+ opts = opts or {},
+ widgets = {},
+ paletteIndex = 1,
+ slotCount = 0,
+ shownCount = 0,
+ -- Only the live palette arms the movement gate (see HitTest); anything
+ -- else is steered from the moment it exists.
+ _steered = true,
+ }, PaletteViewMeta)
+
+ -- A caller can hand in the frame instead of naming one. The live view does,
+ -- because where a frame is created decides which addon its handlers are
+ -- billed to -- see the top of this file.
+ local frame = view.opts.frame
+ if frame then
+ frame:SetParent(parent)
+ else
+ frame = CreateFrame("Frame", view.opts.frameName, parent)
+ end
+ frame:SetSize(1, 1)
+ frame:EnableMouse(false)
+ view.frame = frame
+
+ -- Hub: the center disc. Shows the selected action's name, or the palette
+ -- name when nothing is selected, which is also the "release now cancels"
+ -- signal.
+ local hub = CreateFrame("Frame", nil, frame)
+ hub:SetSize(2, 2)
+ hub:SetPoint("CENTER")
+ view.hub = hub
+
+ hub.dot = hub:CreateTexture(nil, "ARTWORK")
+ hub.dot:SetTexture("Interface\\Cooldown\\star4")
+ hub.dot:SetBlendMode("ADD")
+ hub.dot:SetPoint("CENTER")
+ hub.dot:SetSize(26, 26)
+ hub.dot:SetAlpha(0.5)
+
+ -- The logo alternative to the star. Left on the default blend mode, unlike
+ -- the star: this is real artwork with its own alpha, and ADD would wash out
+ -- its dark areas into whatever is behind the palette. It stays on ARTWORK so
+ -- the hub's OVERLAY text still reads on top of it.
+ hub.logo = hub:CreateTexture(nil, "ARTWORK")
+ hub.logo:SetTexture((EllesmereUI.MEDIA_PATH or "Interface\\AddOns\\EllesmereUI\\media\\")
+ .. "eg-logo.tga")
+ hub.logo:SetPoint("CENTER")
+ hub.logo:Hide()
+
+ -- Needle: a thin bar whose center is placed halfway out along the
+ -- selected direction and rotated to match it, so it reads as a pointer
+ -- emanating from the hub.
+ hub.needle = hub:CreateTexture(nil, "OVERLAY")
+ hub.needle:SetTexture("Interface\\Buttons\\WHITE8X8")
+ hub.needle:SetSize(3, 30)
+ hub.needle:Hide()
+
+ hub.text = hub:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ AdoptFontString(hub.text)
+ hub.text:SetPoint("CENTER", hub, "CENTER", 0, 0)
+ hub.text:SetWidth(150)
+ hub.text:SetWordWrap(false)
+
+ hub.hint = hub:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
+ AdoptFontString(hub.hint)
+ hub.hint:SetPoint("TOP", hub.text, "BOTTOM", 0, -2)
+
+ -- The palette's own entries exist from the outset; nested ones are made on
+ -- demand, because most palettes hold none and a full set would be another
+ -- ninety-six frames per view.
+ for i = 1, MAX_SLOTS do view.widgets[i] = CreateSlotWidget(view, i) end
+
+ views[#views + 1] = view
+ return view
+end
+
+-- A cell's widget, created if this view has never drawn a cell that far out.
+function PaletteView:Widget(index)
+ local w = self.widgets[index]
+ if not w then
+ w = CreateSlotWidget(self, index)
+ self.widgets[index] = w
+ end
+ return w
+end
+
+-- Paint one cell from its slot. Shared by the palette's own entries and by the
+-- nested ones, which differ only in where they are placed and when they are
+-- shown -- a second copy of this is how a nested entry ends up with no cooldown
+-- swirl or the wrong label the first time either option moves.
+-- iconSize is the size the LAYOUT gave this cell, before any falloff or
+-- selection zoom: the corner count is sized off it, and reading the widget's
+-- current size instead would make the number breathe with the entry.
+local function PaintCell(w, slot, placeholder, showLabels, showCooldowns, wantLabel,
+ iconSize, showUsability)
+ w.isPlaceholder = placeholder
+
+ -- Read once per paint, which is once per open: range and resources do move
+ -- while a palette is up, but a hold lasts a fraction of a second and a tint
+ -- that changed under a settled hand would read as a flicker rather than as
+ -- information. ApplySlotVisual is what turns this into a colour.
+ w.usability = (showUsability and not placeholder) and SlotUsability(slot) or nil
+
+ local icon, name = SlotDisplay(slot)
+ w.icon:SetTexture(icon or QUESTION_MARK)
+ w.icon:SetShown(not placeholder)
+ w.plus:SetShown(placeholder)
+
+ local labelled = showLabels and wantLabel and name ~= nil
+ w.label:SetText((labelled and name) or "")
+ w.label:SetShown(labelled or false)
+
+ -- A palette has no cooldown of its own, and borrowing its first entry's
+ -- would be a lie the moment the user pointed at any of the others.
+ if showCooldowns and slot and slot.kind ~= "palette" then
+ local durObj, start, duration, enable = SlotCooldown(slot)
+ if durObj then
+ -- clearIfZero defaults true, so an idle spell clears itself.
+ w.cd:SetCooldownFromDurationObject(durObj)
+ elseif start then
+ CooldownFrame_Set(w.cd, start, duration, enable)
+ else
+ w.cd:Clear()
+ end
+ w.cd:Show()
+ else
+ w.cd:Clear()
+ w.cd:Hide()
+ end
+
+ -- The value is written and shown, never read back or tested -- not even
+ -- for nil, which is why SlotCount answers WHETHER separately from WHAT. A
+ -- placeholder has no slot to count, and a palette entry's count would be
+ -- whichever of its children happened to be first.
+ local hasCount, count = false, nil
+ if not placeholder and slot and slot.kind ~= "palette" then
+ hasCount, count = SlotCount(slot)
+ end
+ if hasCount then
+ w.count:SetTextHeight(max(8, floor((iconSize or 44) * 0.34)))
+ w.count:SetText(count)
+ end
+ w.count:SetShown(hasCount)
+
+ ApplySlotVisual(w, false)
+end
+
+-- Lay the palette out and paint every widget from the stored slot data.
+function PaletteView:Layout(paletteIndex)
+ -- Clamped to what can be STORED rather than to what can be bound: a nested
+ -- palette is opened through its parent and may well have no key of its own.
+ paletteIndex = min(MAX_PALETTES, max(1, paletteIndex or self.paletteIndex or 1))
+ -- PA(paletteIndex), not self:P(): this call is what MOVES the view onto a
+ -- palette, so the index it was pointed at last says nothing about the
+ -- appearance being laid out here.
+ local p, palette = PA(paletteIndex), EnsurePalette(paletteIndex)
+ if not p or not palette then return end
+
+ local opts = self.opts
+ -- Everything the steering passes read that is NOT the cursor is rewritten
+ -- from here down -- the claim boxes, the entry count, every option a
+ -- Refresh mid-hold can move -- so the snapshot SteerUnchanged took against
+ -- the previous geometry says nothing about this one.
+ self._steerX = nil
+ self.paletteIndex = paletteIndex
+ -- Derived, never stored: the palette is exactly as big as what is on it.
+ local n = #palette.slots
+ -- An interactive view draws one entry more than the palette holds: the "+"
+ -- placeholder. It is a real entry, so adding an action visibly re-fans the
+ -- palette instead of filling a gap that was reserved for it all along.
+ local shown = (opts.interactive and n < MAX_SLOTS) and (n + 1) or n
+ self.slotCount, self.shownCount = n, shown
+
+ local step, arcStart = self:ArcGeom(shown)
+ local radius, iconSize = self:Geom()
+ local fan = self:IsFan()
+
+ -- Worked out before the frame is sized, not with the entries it places: a
+ -- nested arc reaches further out than the palette's own ring, and a frame
+ -- sized to the ring alone would clip every child drawn beyond it.
+ local claims = self:ChildGeom(shown, palette)
+ local outer = radius
+ -- Half-extents a block layout's nests reach to, in the frame's own units.
+ local nestX, nestY = 0, 0
+ for k = 1, (claims and #claims or 0) do
+ local c = claims[k]
+ if c.cells then
+ for j = 1, c.n do
+ local b = c.cells[j]
+ -- The BOX, not the icon: it is the box a pointer has to be able
+ -- to reach, and a frame sized to the icons alone would put part
+ -- of a nest's own ground outside the palette.
+ nestX = max(nestX, abs(b.x) + max(b.hw, c.icon * 0.5))
+ nestY = max(nestY, abs(b.y) + max(b.hh, c.icon * 0.5))
+ end
+ else
+ -- Plus the child's own half-width: a ring of icons reaches further
+ -- than the circle their centres sit on, and that is what clips.
+ outer = max(outer, c.radius + c.icon * 0.5 - iconSize * 0.5)
+ end
+ end
+
+ local frame = self.frame
+ -- p.scale is the user's live sizing; a fitted preview supplies its own
+ -- geometry instead and must not be scaled a second time.
+ if not opts.interactive then frame:SetScale(p.scale or 1) end
+ if self:IsPointerLayout() then
+ -- One sizing rule for the grid and both pointer-steered strips: a strip
+ -- is just a grid one entry deep, so GridDims has already reduced it to
+ -- the same cols/rows the extent is measured from.
+ local pitch = iconSize + (p.fanGap or 10)
+ local cols, rows = self:GridDims()
+ -- Whichever is wider: the block itself, or a nest hanging off it.
+ frame:SetSize(max(cols * pitch, nestX * 2) + 40,
+ max(rows * pitch, nestY * 2) + 60)
+ elseif fan then
+ local along = self:FanHalfLength() * 2 + 40
+ local across = iconSize + 60 -- room for the hub caption
+ -- Whichever is bigger: the strip, or a nest broken out across it.
+ if self:FanHoriz() then
+ frame:SetSize(max(along, nestX * 2 + 40), max(across, nestY * 2 + 40))
+ else
+ frame:SetSize(max(across, nestX * 2 + 40), max(along, nestY * 2 + 40))
+ end
+ else
+ -- Sized generously so labels and the selected-slot zoom never clip.
+ local span = (outer + iconSize) * 2 + 40
+ frame:SetSize(span, span)
+ end
+
+ -- == true, not the raw value: a profile that has never seen the key must
+ -- resolve to the DEFAULT, which is off.
+ local showLabels = opts.showLabels
+ if showLabels == nil then showLabels = p.showLabels == true end
+ local showCooldowns = opts.showCooldowns
+ if showCooldowns == nil then showCooldowns = p.showCooldowns end
+ -- ~= false, not == true: on by default, so a profile that has never seen
+ -- the key gets the tint.
+ local showUsability = opts.showUsability
+ if showUsability == nil then showUsability = p.showUsability ~= false end
+
+ for i = 1, shown do
+ local w = self.widgets[i]
+ -- Switching modes leaves the other mode's depth cues behind.
+ w:SetAlpha(1)
+ w:SetScale(1)
+ -- The size a selection zoom is measured from. Every steered layout
+ -- publishes its own, entry by entry, in the geometry passes below.
+ w.baseSize = iconSize
+ if not fan then
+ local a = arcStart + (i - 1) * step
+ w:ClearAllPoints()
+ w:SetPoint("CENTER", frame, "CENTER", radius * sin(a), radius * cos(a))
+ w:SetSize(iconSize, iconSize)
+ end
+ w:EnableMouse(opts.interactive == true)
+
+ -- A nil slot is only reachable on an interactive view, whose trailing
+ -- "+" placeholder is drawn as a real entry: shown == n otherwise.
+ -- The fan never labels its entries: at strip spacing the captions of
+ -- neighbouring icons collide, and the centre entry -- the only one that
+ -- can be fired -- is already named on the hub.
+ PaintCell(w, palette.slots[i], palette.slots[i] == nil,
+ showLabels, showCooldowns, not fan, iconSize, showUsability)
+ w:Show()
+ end
+
+ -- Nested entries, laid out past the palette's own on the same index line, so
+ -- a cell index is all the hit test and the secure push ever have to carry.
+ local cells = shown
+ if claims then
+ for k = 1, #claims do
+ local c = claims[k]
+ c.base = cells
+ for j = 1, c.n do
+ cells = cells + 1
+ local w = self:Widget(cells)
+ w:SetAlpha(1)
+ w:SetScale(1)
+ w.baseSize = c.icon
+ w:ClearAllPoints()
+ if c.cells then
+ -- A block layout rewrites these every frame in AdvanceGrid,
+ -- along with the falloff; placing them here too is what a
+ -- view that never steers -- a static frame -- shows.
+ w:SetPoint("CENTER", frame, "CENTER", c.cells[j].x, c.cells[j].y)
+ else
+ local r, a = self:ChildRingPos(c, j)
+ w:SetPoint("CENTER", frame, "CENTER", r * sin(a), r * cos(a))
+ end
+ w:SetSize(c.icon, c.icon)
+ w:EnableMouse(false)
+ PaintCell(w, c.slots[j], false, showLabels, showCooldowns,
+ c.label ~= false, c.icon, showUsability)
+ -- Hidden until its own claim is previewed or opened -- see
+ -- UpdateNestShown.
+ w:Hide()
+ end
+ end
+ end
+ self.claims = claims
+ self.cellCount = cells
+ -- Every cell was just hidden and every entry repainted plain, so all three
+ -- of these describe a drawing that no longer exists.
+ self._openClaim, self._previewClaim, self._armedParent = nil, nil, nil
+
+ -- Which way the nests went, so the caption can hang on the other side. Taken
+ -- from the first claim that placed: with several nests on different sides
+ -- there is no one answer, and the first is the one the palette leads with.
+ self.nestAxis, self.nestSign = nil, nil
+ for k = 1, (claims and #claims or 0) do
+ if claims[k].axis then
+ self.nestAxis, self.nestSign = claims[k].axis, claims[k].sign
+ break
+ end
+ end
+
+ for i = cells + 1, #self.widgets do
+ self.widgets[i]:Hide()
+ self.widgets[i]:EnableMouse(false)
+ end
+
+ -- Every widget was just repainted unselected, so the recorded selection is
+ -- stale by construction; callers that want it back re-apply it afterwards.
+ self.selection = nil
+
+ if self:IsPointerLayout() then
+ self:AdvanceGrid(true)
+ -- AdvanceGrid publishes a selection; Layout's contract is that it does
+ -- not, and the caller re-applies one afterwards.
+ self.selection = nil
+ elseif fan then
+ self:ApplyFanGeometry()
+ end
+
+ local hub = self.hub
+ hub.needle:SetShown(false)
+ -- In fan modes the centre of the frame is occupied by the selected entry,
+ -- so the hub's disc would sit under it and its caption on top of it. Drop
+ -- the disc and hang the caption clear of the strip instead.
+ -- Exactly one piece of hub art, and only where the centre is empty.
+ -- ~= false, not == true: the default is ON, so a profile that has never
+ -- seen the key gets the logo.
+ local useLogo = p.hubIcon ~= false
+ hub.dot:SetShown(not fan and not useLogo)
+ hub.logo:SetShown(not fan and useLogo)
+ if not fan and useLogo then
+ -- Scaled by whatever the view scaled its geometry by, recovered from
+ -- the icon size Geom actually handed back. The options preview fits the
+ -- palette to its panel, and a hub drawn at the profile's literal pixel
+ -- size would swamp a palette that had been shrunk to two-thirds.
+ local _, viewIcon = self:Geom()
+ local base = p.iconSize or 44
+ local k = (base > 0) and (viewIcon / base) or 1
+ local sz = max(8, (p.hubIconSize or 46) * k)
+ hub.logo:SetSize(sz, sz)
+ hub.logo:SetAlpha(min(1, max(0, p.hubIconAlpha or 0.55)))
+ end
+ self:PlaceHubText()
+ hub.text:SetText(palette.name or AutoPaletteName(paletteIndex))
+ hub.text:SetTextColor(0.8, 0.8, 0.8)
+
+ if opts.hintText then
+ hub.hint:SetText(opts.hintText(n) or "")
+ elseif n == 0 then
+ -- An empty palette is a real state now, and a bare hub with no explanation
+ -- looks like a bug rather than "you haven't filled this in yet".
+ hub.hint:SetText("no actions assigned")
+ else
+ local k1 = GetBindingKey(BINDING_PREFIX .. paletteIndex)
+ hub.hint:SetText(p.showHubText and (k1 or "") or "")
+ end
+ -- What the hint says while nothing special is selected, so SetSelection can
+ -- borrow the line for an entry that needs one and hand it straight back.
+ self.hintBase = hub.hint:GetText() or ""
+end
+
+-- The slot a cell index draws, and the claim it belongs to for a nested one.
+-- Every cell past shownCount is somebody's child; the palette's own entries map
+-- straight through.
+function PaletteView:CellSlot(index)
+ if not index then return nil end
+ -- ReadPalette, not EnsurePalette: this is reached from every selection
+ -- change, i.e. every time the cursor crosses an entry boundary during a
+ -- hold, and compacting the slot array there would allocate and write into
+ -- the profile for a pure read.
+ local palette = ReadPalette(self.paletteIndex)
+ if not palette then return nil end
+ if index <= self.shownCount then return palette.slots[index] end
+ local claims = self.claims
+ for k = 1, (claims and #claims or 0) do
+ local c = claims[k]
+ if c.base and index > c.base and index <= c.base + c.n then
+ return c.slots[index - c.base], c, index - c.base
+ end
+ end
+ return nil
+end
+
+-- Which claim, if any, the secure button says a gate has armed -- the claim
+-- index NestHit, HitTest and UpdateNestShown all key their nest off, and the
+-- same index the release branch of SNIPPET_PRE tests against. Reading an
+-- attribute off a protected frame is unrestricted even in combat, so this
+-- works whether or not the player can currently write one.
+--
+-- nil for any view with no secure button of its own -- an interactive view
+-- (the options preview) draws no nests at all (ChildGeom answers nil for it),
+-- so the claims table this would index into does not exist regardless.
+function PaletteView:ArmedClaim()
+ local btn = not self.opts.interactive and secureButtons[self.paletteIndex]
+ return btn and tonumber(btn:GetAttribute("eapArmed")) or nil
+end
+
+-- Views whose nests open on selection alone, with no arming in the way: an
+-- interactive view (the options preview), which has no secure button and fires
+-- nothing at all, and the scroll-steered fan, whose nests are picked with the
+-- wheel and never arm a gate. Everywhere else a nest is live only once a gate
+-- says so, and the drawing has to say the same.
+function PaletteView:NestsFollowSelection()
+ return self.opts.interactive == true or (self:IsFan() and not self:IsPointerLayout())
+end
+
+-- Repaint one of the palette's own entries in whatever state it is currently
+-- in. Used when the armed claim moves: that changes how an entry is drawn
+-- without the selection having moved at all.
+function PaletteView:RepaintEntry(index)
+ local w = index and self.widgets[index]
+ if w then
+ ApplySlotVisual(w, index == self.selection, index == self._armedParent)
+ end
+end
+
+-- Which nest is open, and which is only being previewed. One at a time either
+-- way -- every nest drawn at once would bury the palette it hangs off.
+--
+-- A nest is OPEN when its claim is ARMED, which means the cursor has actually
+-- passed through the entry that opens it -- see ArmedClaim and the gate frames
+-- EnsureGates builds. That is what keeps a nest open across the ground between
+-- its parent and its children without two neighbouring claims fighting over
+-- ground they both think they own, and it is the same claim NestHit, HitTest
+-- and the release branch of SNIPPET_PRE answer for, so what is drawn and what a
+-- release fires cannot disagree about which nest is live.
+--
+-- Selection landing on a claim's parent is NOT that. It used to be, and an
+-- unarmed nest then drew exactly like an armed one -- children at full strength,
+-- the block behind them faded -- while every one of those children was dead.
+-- Such a claim is drawn as a PREVIEW instead: placed and visible, but plainly
+-- not somewhere you are yet. See NEST_PREVIEW_ALPHA.
+--
+-- On a view with no arming of its own the selection is still the whole of the
+-- answer -- see NestsFollowSelection.
+function PaletteView:UpdateNestShown(index)
+ local claims = self.claims
+ if not claims then return end
+
+ -- The claim the selection is standing on or inside.
+ local touched
+ for k = 1, #claims do
+ local c = claims[k]
+ if index and c.base
+ and (index == c.parent or (index > c.base and index <= c.base + c.n)) then
+ touched = c
+ end
+ end
+
+ local open, preview
+ if self:NestsFollowSelection() then
+ open = touched
+ else
+ local armed = self:ArmedClaim()
+ open = armed and claims[armed] or nil
+ -- Only ever the one the selection touches: a claim nobody is pointing at
+ -- has nothing to preview, and previewing every nest at once is the
+ -- burial this draws one at a time to avoid.
+ if touched and touched ~= open then preview = touched end
+ end
+
+ -- Ahead of the unchanged check below, and off open rather than off the
+ -- selection: the parent of an armed claim keeps its mark while the cursor
+ -- moves on into the children.
+ local armedParent = (not self:NestsFollowSelection()) and open and open.parent or nil
+ if self._armedParent ~= armedParent then
+ local previous = self._armedParent
+ self._armedParent = armedParent
+ self:RepaintEntry(previous)
+ self:RepaintEntry(armedParent)
+ end
+
+ -- Armed and previewed are two states of the SAME claim, so the open claim
+ -- staying put is not on its own a reason to draw nothing: a nest that arms
+ -- under a stationary cursor has to stop being a preview the frame it does.
+ if self._openClaim == open and self._previewClaim == preview then return end
+ self._openClaim, self._previewClaim = open, preview
+
+ for k = 1, #claims do
+ local c = claims[k]
+ local a = (c == open) and 1 or (c == preview) and NEST_PREVIEW_ALPHA or nil
+ for j = 1, c.n do
+ local w = c.base and self.widgets[c.base + j]
+ if w then
+ if a then w:SetAlpha(a) end
+ w:SetShown(a ~= nil)
+ end
+ end
+ end
+end
+
+-- Paint selection state. Called from OnUpdate whenever the hovered slot
+-- changes, and once from Open so the initial state is drawn.
+function PaletteView:SetSelection(index)
+ -- Ahead of the unchanged-selection return: a claim can arm and disarm under
+ -- a cursor that is holding still on one entry, and the nest state has to
+ -- follow that. UpdateNestShown makes its own decision about whether there
+ -- is anything left to draw.
+ self:UpdateNestShown(index)
+ if self.selection == index then return end
+
+ local widgets = self.widgets
+ if self.selection and widgets[self.selection] then
+ ApplySlotVisual(widgets[self.selection], false,
+ self.selection == self._armedParent)
+ end
+ self.selection = index
+
+ local p = self:P()
+ local hub = self.hub
+
+ if index then
+ local w = widgets[index]
+ ApplySlotVisual(w, true, index == self._armedParent)
+
+ local slot, claim, childIndex = self:CellSlot(index)
+ local _, name = SlotDisplay(slot)
+ local r, g, b = SelectColor()
+ -- A nested entry is captioned under the palette it came from, so the
+ -- hub still says where in the palette the cursor actually is.
+ if claim then
+ local _, parentName = SlotDisplay(self:CellSlot(claim.parent))
+ if parentName and name then
+ name = parentName .. " \194\187 " .. name
+ end
+ end
+ hub.text:SetText(name or (w.isPlaceholder and "Add Action") or ("Slot " .. index))
+ hub.text:SetTextColor(r, g, b)
+
+ -- An entry that OPENS a palette fires nothing: it is a door, and
+ -- stopping on the door is a cancel. The release path has always read
+ -- it that way -- the snippet refuses on eapPal before it looks at any
+ -- action -- but on screen the entry was captioned in the selection
+ -- colour like any other, so the cancel landed as the palette simply
+ -- not working. The hint line says it instead, and takes over the line
+ -- the keybind was on because nothing else there matters while the
+ -- cursor is standing on a door.
+ -- Not on an editor, which releases nothing and has its own hint to
+ -- give.
+ hub.hint:SetText((ChildIndex(slot) and not self.opts.interactive)
+ and "release cancels" or (self.hintBase or ""))
+
+ -- The needle points along a entry angle; the fan has no angles.
+ if p and p.showNeedle and not self:IsFan() then
+ local radius, iconSize, deadZone = self:Geom()
+ local step, arcStart = self:ArcGeom(self.shownCount)
+ local a = arcStart + (index - 1) * step
+ -- A block layout's claim has no rings -- its needle direction, if
+ -- it drew one, would come from cells rather than an angle.
+ if claim and claim.rows then a = select(2, self:ChildRingPos(claim, childIndex)) end
+ local mid = (deadZone + radius - iconSize * 0.5) * 0.5
+ hub.needle:ClearAllPoints()
+ hub.needle:SetPoint("CENTER", hub, "CENTER", mid * sin(a), mid * cos(a))
+ hub.needle:SetRotation(-a)
+ hub.needle:SetVertexColor(r, g, b, 0.9)
+ hub.needle:Show()
+ end
+ else
+ local palette = ReadPalette(self.paletteIndex)
+ hub.text:SetText((palette and palette.name) or "")
+ hub.text:SetTextColor(0.8, 0.8, 0.8)
+ hub.hint:SetText(self.hintBase or "")
+ hub.needle:Hide()
+ end
+end
+
+-- Baseline for the movement gate in HitTest. Read AFTER the frame is placed so
+-- the scale used here is the one the hit test will use.
+function PaletteView:ArmMovementGate()
+ local es = self.frame:GetEffectiveScale()
+ local x, y = GetCursorPosition()
+ self._gateX, self._gateY = x / es, y / es
+ self._steered = false
+end
+
+-- Where the cursor is in the arc's own terms: the angle clockwise from straight
+-- up, and the distance from the centre, both in the frame's units. nil while the
+-- movement gate is still armed, or before the frame has been placed.
+--
+-- One copy, read by the hit test and by the falloff alike, so what the arc DRAWS
+-- as nearest and what a release actually FIRES cannot part company.
+function PaletteView:PointerPolar()
+ local frame = self.frame
+ local es = frame:GetEffectiveScale()
+ local mx, my = GetCursorPosition()
+ mx, my = mx / es, my / es
+
+ if not self._steered then
+ if abs(mx - self._gateX) < 1 and abs(my - self._gateY) < 1 then return nil end
+ self._steered = true
+ end
+
+ local cx, cy = frame:GetCenter()
+ if not cx then return nil end
+
+ local dx, dy = mx - cx, my - cy
+ -- atan2(dx, dy) measures clockwise from straight up, matching the layout
+ -- (slot 1 at 12 o'clock, index increasing clockwise).
+ local theta = atan2(dx, dy)
+ if theta < 0 then theta = theta + TWO_PI end
+ return theta, sqrt(dx * dx + dy * dy)
+end
+
+-- Cursor -> entry index. nil inside the dead zone, and -- while the movement
+-- gate is armed -- until the cursor has actually moved. The gate is what makes
+-- "open and release without moving" a cancel in FIXED-POSITION mode, where the
+-- cursor starts at some arbitrary point on the palette rather than at the center
+-- and would otherwise have a slot pre-selected the instant the palette opens.
+--
+-- theta/dist may be handed in by a caller that has already read the cursor for
+-- the same frame; without them the cursor is read here, so this stays callable
+-- on its own.
+function PaletteView:HitTest(theta, dist)
+ local shown = self.shownCount
+ if shown < 1 then return nil end
+ local _, _, deadZone = self:Geom()
+
+ if not theta then theta, dist = self:PointerPolar() end
+ if not theta then return nil end
+
+ -- The armed claim's rings, and no other's. A child sector reaches past its
+ -- parent entry's own, so answering the parent first
+ -- would settle the question before the child was ever considered -- but
+ -- that only matters for the ONE claim the cursor has actually armed by
+ -- passing through its parent entry; every other claim's ground answers as
+ -- though it held no nest at all. See ArmedClaim.
+ local claims = self.claims
+ local armed = self:ArmedClaim()
+ local c = armed and claims and claims[armed]
+ if c and c.base and dist >= c.band then
+ -- Which ring dist falls in -- lo/hi are set so consecutive rings
+ -- share a boundary at their midpoint radius, and the last ring's
+ -- hi is nil, i.e. everything past the second-to-last ring's
+ -- midpoint. A miss here (angularly outside the ring it landed in)
+ -- falls out of the claim entirely rather than trying another
+ -- ring: the rings partition the RADIUS, not the angle.
+ for r = 1, #c.rows do
+ local row = c.rows[r]
+ if dist >= row.lo and (not row.hi or dist < row.hi) then
+ if row.step > 0 then
+ local rel = (theta - row.start + row.step * 0.5) % TWO_PI
+ if rel < row.n * row.step then
+ return c.base + row.base + floor(rel / row.step) + 1
+ end
+ end
+ break
+ end
+ end
+ end
+
+ if dist < deadZone then return nil end
+
+ local step, arcStart, full = self:ArcGeom(shown)
+ if step == 0 then return 1 end
+
+ local rel = theta - arcStart
+ if full then return (floor(rel / step + 0.5) % shown) + 1 end
+
+ -- Resolved into [0, TWO_PI) from the arc's start, NOT into (-pi, pi]: an arc
+ -- may span up to a full turn, so an offset of more than half a turn is a
+ -- legitimate position near its end rather than a negative one near its
+ -- start. Folding it would silently amputate everything past 180 degrees.
+ rel = rel % TWO_PI
+
+ -- The arc owns half a step past its last entry, the same width every
+ -- interior entry gets. Beyond that is a miss, not a clamp: outside the arc
+ -- is the only place its cancel can live once the dead zone has been left.
+ if rel > (shown - 1) * step + step * 0.5 then return nil end
+
+ local idx = floor(rel / step + 0.5) + 1
+ if idx < 1 or idx > shown then return nil end
+ return idx
+end
+
+-- Lay the falloff over the ring and select the entry the cursor points at. The
+-- arc's answer to AdvanceGrid, and it reads the same two settings: an entry one
+-- step off the cursor is drawn at the same fraction of full size and full alpha
+-- whichever layout it is standing in.
+--
+-- Nearness on a ring is an ANGLE, not a distance -- every entry is the same
+-- distance out, so a radial measure would say nothing -- and it is counted in
+-- STEPS, which is what makes the falloff mean "each entry along from the one
+-- under the cursor" here as it does everywhere else.
+--
+-- Positions are left exactly where Layout put them. Only size and alpha move:
+-- an entry that also slid along the ring would drag itself out from under the
+-- cursor that had just reached it.
+function PaletteView:AdvanceArc()
+ local p = self:P()
+ local shown = self.shownCount
+ if not p or shown < 1 then
+ self:SetSelection(nil)
+ return
+ end
+
+ -- One read of the cursor for the whole pass, handed to the hit test and
+ -- used for the falloff below: what the ring DRAWS as nearest and what a
+ -- release actually FIRES have to come from the same position.
+ local theta, dist = self:PointerPolar()
+ local best = self:HitTest(theta, dist)
+
+ local _, iconSize, deadZone = self:Geom()
+ local decay, aDecay = FalloffRatios(p)
+ local minS = p.fanMinScale or 0.30
+ local minA = p.fanMinAlpha or 0.12
+
+ local step, arcStart = self:ArcGeom(shown)
+ -- Inside the dead zone the cursor is not pointing anywhere yet: an angle
+ -- read a pixel from the centre swings wildly on the smallest movement, and
+ -- the ring would strobe under a hand that had barely left the middle. The
+ -- palette is drawn evenly there, which is also what it opens as.
+ local steer = theta and step > 0 and dist >= deadZone
+
+ local zoom = SelectedZoom()
+ for i = 1, shown do
+ local w = self.widgets[i]
+ local s, a = 1, 1
+ if steer then
+ -- Shortest way round, so an entry just anticlockwise of slot 1 is
+ -- one step from it rather than a whole turn away. Flattened over
+ -- the entry's own sector -- see FalloffK -- so the entry the cursor
+ -- is on holds still while the cursor moves about inside it.
+ local d = (theta - (arcStart + (i - 1) * step)) % TWO_PI
+ if d > pi then d = TWO_PI - d end
+ local k = FalloffK(d / step)
+ s = max(minS, decay ^ k)
+ a = max(minA, aDecay ^ k)
+ end
+
+ -- Magnified here rather than left to the selection paint: these sizes
+ -- are rewritten every frame and would erase a zoom applied only where
+ -- the selection changed. Same reason the strip and the grid do it.
+ local z = (i == best) and zoom or 1
+ w:SetAlpha(a)
+ w.baseSize = iconSize * s
+ w:SetSize(iconSize * s * z, iconSize * s * z)
+ end
+
+ self:SetSelection(best)
+end
+
+-- Has anything the steering passes read moved since the last frame?
+--
+-- All three of them are pure functions of the geometry Layout worked out and
+-- of four running inputs: where the cursor is, which claim the gates have
+-- armed, where the wheel has left the strip, and whether the strip is still
+-- sliding toward it. Given the same four they rewrite every entry's point,
+-- size and alpha to exactly what is already on screen -- so a frame that
+-- brings none of them in new can skip the pass outright. A hold lasts many
+-- frames and the hand is still on most of them, which is what makes this
+-- worth asking.
+--
+-- Not the alpha, though: the flick-ahead fade and the strip's cancel fade are
+-- both time-based, so UpdatePaletteAlpha runs on every frame regardless.
+--
+-- The snapshot is cleared by Layout, the one place the geometry underneath it
+-- can change while the palette is open.
+function PaletteView:SteerUnchanged()
+ local x, y = GetCursorPosition()
+ local armed = self:ArmedClaim()
+ -- Read the same way AdvanceFan reads it, so a wheel tick the pass has not
+ -- picked up yet still counts as a change.
+ local wheel = self.opts.live and scrollCatcher
+ and scrollCatcher:GetAttribute("eapFanTarget") or nil
+ -- Mid-settle the strip's geometry moves on its own. Both are nil on the
+ -- layouts that have no settle at all, which compares equal -- which is why
+ -- ns.Close has to clear the pair rather than just the target.
+ local settling = self.fanVisual ~= self.fanTarget
+
+ local same = not settling
+ and x == self._steerX and y == self._steerY
+ and armed == self._steerArmed and wheel == self._steerWheel
+ self._steerX, self._steerY = x, y
+ self._steerArmed, self._steerWheel = armed, wheel
+ return same
+end
+
+-------------------------------------------------------------------------------
+-- The live palette
+-------------------------------------------------------------------------------
+local function CreateLiveView()
+ if liveView then return liveView end
+ liveView = ns.CreatePaletteView(UIParent, { frame = liveFrame, live = true })
+ local f = liveView:GetFrame()
+ f:SetFrameStrata(LIVE_STRATA)
+ f:Hide()
+ return liveView
+end
+
+-- Scroll capture. The wheel is camera zoom by default, so the fan modes have
+-- to take it while the strip is open. A frame only sees OnMouseWheel when the
+-- cursor is over it, and in CURSOR mode the strip is drawn AT the cursor -- but
+-- the cursor can also be parked anywhere in SCREEN mode, so the catcher is
+-- full-screen rather than the strip itself.
+--
+-- An override binding on MOUSEWHEELUP/DOWN would be the other way to do this,
+-- and is not an option: those are protected and could not be claimed at open
+-- time in combat, which is exactly when the palette gets used.
+--
+-- Mouse WHEEL only, never EnableMouse: a full-screen mouse-enabled frame would
+-- sit between the player and the world, and would swallow the very button
+-- presses the secure activation path depends on.
+local function EnsureSecureHeader()
+ if not secureHeader then
+ secureHeader = CreateFrame("Frame", "EUIActionPaletteSecureHeader",
+ UIParent, "SecureHandlerBaseTemplate")
+ end
+ return secureHeader
+end
+
+-- One wheel tick. This is the ONLY place the strip's index is advanced: the Lua
+-- handler that used to do it is gone, because an addon may not write these
+-- attributes once the player is in combat. The options preview does not scroll
+-- at all -- it jumps with SetFanCenter -- so nothing else needs the old path.
+local SNIPPET_WHEEL = [==[
+ if not self:GetAttribute("eapOpen") then
+ -- Nothing has this open, so it must stop eating camera zoom. This is the
+ -- self-heal for a palette that never saw its key-up -- a zone change or
+ -- a taxi swallowing the release -- and it costs one notch of zoom.
+ self:Hide()
+ return false
+ end
+ local n = tonumber(self:GetAttribute("eapShown")) or 0
+ if n < 1 then return false end
+
+ local delta = offset
+ if self:GetAttribute("eapInvert") then delta = -delta end
+ -- Scrolling up travels toward earlier entries, the direction they are drawn
+ -- in for a vertical strip, and the natural reading order for a horizontal
+ -- one.
+ local step = -1
+ if delta <= 0 then step = 1 end
+
+ -- The press seeds this at 1, the entry the strip opens centred on, so every
+ -- tick is a plain step from wherever the strip already is. The `or 1` is for
+ -- a tick that arrives with no press behind it at all.
+ local t = (tonumber(self:GetAttribute("eapFanTarget")) or 1) + step
+ self:SetAttribute("eapFanTarget", t)
+ return false
+]==]
+
+local function EnsureScrollCatcher()
+ if scrollCatcher then return scrollCatcher end
+ local f = CreateFrame("Frame", "EUIActionPaletteScrollCatcher", UIParent,
+ "SecureHandlerBaseTemplate")
+ f:SetAllPoints(UIParent)
+ f:SetFrameStrata(LIVE_STRATA)
+ f:SetFrameLevel(1)
+ f:EnableMouseWheel(true)
+ SecureHandlerWrapScript(f, "OnMouseWheel", EnsureSecureHeader(), SNIPPET_WHEEL)
+ f:Hide()
+ scrollCatcher = f
+ return f
+end
+
+-- Flick-ahead. The palette is held invisible for a moment after the key goes down
+-- and then fades in, so a gesture finished inside that window never summons a
+-- menu at all. It is a DRAWING delay only: the frame is shown and its OnUpdate
+-- is running the whole time, so the selection a fast flick lands on is exactly
+-- the one a slow one would have.
+--
+-- Arc only. A fan has to be read before it can be steered, and a scroll fan
+-- cannot even be entered without seeing where the strip starts.
+local function FlickAlpha()
+ local p = P()
+ if not p or not p.flickAhead or liveView:IsFan() then return 1 end
+
+ local delay = p.flickDelay or 0.12
+ local fade = p.flickFade or 0.10
+ local t = GetTime() - openedAt
+ if t <= delay then return 0 end
+ if fade <= 0 or t >= delay + fade then return 1 end
+ return (t - delay) / fade
+end
+
+-- One alpha for the whole palette, so the flick-ahead fade-in and the
+-- cancel fade cannot fight over the frame. Only the scroll-steered strip has a
+-- cancel box to approach; the others answer 1.
+--
+-- The last alpha actually applied. Nothing else writes the live frame's alpha,
+-- so a repeat is a redraw of the frame and everything under it for a value it
+-- already carries -- and past the flick-ahead fade every frame of a hold is a
+-- repeat.
+local paletteAlpha = nil
+
+local function UpdatePaletteAlpha()
+ local a = FlickAlpha()
+ if liveView:IsFan() and not liveView:IsPointerLayout() then
+ a = a * liveView:FanCancelAlpha()
+ end
+ if a == paletteAlpha then return end
+ paletteAlpha = a
+ liveView:GetFrame():SetAlpha(a)
+end
+
+local function OnPaletteUpdate(_, elapsed)
+ if GetTime() - openedAt > OPEN_TIMEOUT then
+ ns.Close()
+ return
+ end
+ if not liveView:SteerUnchanged() then
+ if liveView:IsPointerLayout() then
+ liveView:AdvanceGrid()
+ elseif liveView:IsFan() then
+ liveView:AdvanceFan(elapsed)
+ else
+ liveView:AdvanceArc()
+ end
+ end
+ -- After the steering, not before: the cancel fade reads the same pointer
+ -- position the selection was just decided from.
+ UpdatePaletteAlpha()
+end
+
+-- forceFixed: ignore CURSOR mode and place the palette at its fixed position.
+-- Nothing passes it since the full-screen editor was retired; it stays because
+-- on-screen drag positioning is being reworked and needs exactly this. Fixed
+-- Position mode itself goes through the same branch via p.centerMode.
+local function PositionPalette(forceFixed)
+ -- The palette that is about to be shown, so a palette pinned to a corner
+ -- and one that opens at the cursor can sit side by side in one profile.
+ -- Layout has already moved the view onto it.
+ local p = liveView:P()
+ local palette = liveView:GetFrame()
+ palette:ClearAllPoints()
+ if forceFixed or p.centerMode == "SCREEN" then
+ local s = p.scale or 1
+ if s == 0 then s = 1 end
+ palette:SetPoint("CENTER", UIParent, "CENTER", (p.posX or 0) / s, (p.posY or 0) / s)
+ else
+ local es = palette:GetEffectiveScale()
+ local x, y = GetCursorPosition()
+ palette:SetPoint("CENTER", UIParent, "BOTTOMLEFT", x / es, y / es)
+ end
+end
+
+function ns.Open(paletteIndex)
+ local p = P()
+ if not p or not p.enabled then return end
+
+ CreateLiveView()
+ liveView:Layout(paletteIndex)
+ PositionPalette()
+ -- After PositionPalette, never before: which side the caption hangs on is
+ -- decided by where on the screen this open actually landed, and in cursor
+ -- mode that is different every time.
+ liveView:PlaceHubText()
+
+ openedAt = GetTime()
+
+ if liveView:IsPointerLayout() then
+ -- Nothing selected until the pointer moves, and straying more than a
+ -- cell from every entry deselects again -- these layouts' dead zone.
+ liveView:ArmMovementGate()
+ liveView:AdvanceGrid(true)
+ liveView:SetSelection(nil)
+ elseif liveView:IsFan() then
+ -- Open with the centred entry ALREADY selected, so the entry the strip
+ -- opens on costs no ticks at all and the first tick moves by one. The
+ -- strip used to open on nothing and be entered by that first tick, which
+ -- made its own starting entry the one entry that could not be chosen
+ -- without scrolling off it and back. Cancelling is FanCancelled's job
+ -- now -- throw the pointer clear of the strip.
+ liveView:ArmMovementGate()
+ liveView.fanTarget = 1
+ liveView.fanVisual = 1
+ liveView:ApplyFanGeometry()
+ -- Guarded: an empty palette has no entry 1 to select, and painting one
+ -- would caption the hub with a slot that is not drawn.
+ liveView:SetSelection(liveView:ShownCount() > 0 and 1 or nil)
+ else
+ -- Through the same pass every later frame goes through, so the ring is
+ -- drawn on the first frame exactly as the tick would draw it. With the
+ -- gate just armed that is evenly, and nothing selected.
+ liveView:ArmMovementGate()
+ liveView:AdvanceArc()
+ end
+
+ local palette = liveView:GetFrame()
+ -- Applied before the first frame rather than left to OnUpdate: the palette is
+ -- shown on this one, and the previous open's alpha would flash through.
+ UpdatePaletteAlpha()
+ palette:SetScript("OnUpdate", OnPaletteUpdate)
+ palette:Show()
+end
+
+-- ESCAPE belongs to the game menu again. The release snippet drops this binding
+-- on every ordinary close; this is for the closes that never see a release --
+-- the open timeout, a zone change -- and it runs whether or not the palette is
+-- still up, because the press that finds ESCAPE still bound is exactly the one
+-- that has to hand it back.
+--
+-- Protected in combat, so a close mid-fight leaves the binding standing. That is
+-- why PLAYER_REGEN_ENABLED tries again: ESCAPE bound to a palette that closed
+-- half an hour ago is a dead key, with no gesture left to free it.
+local function ReleaseEscape()
+ if cancelButton and not InCombatLockdown() then
+ ClearOverrideBindings(cancelButton)
+ end
+end
+
+-- The rest of what a release puts away -- the scroll catcher, the ownership
+-- stamp, and every arming gate the palette had up -- for the closes that never
+-- see a key-up. Assigned further down, beside the gate pool it hides, and the
+-- index of a close combat refused is left here for PLAYER_REGEN_ENABLED: every
+-- frame it touches is protected, so mid-fight there is nothing it may do.
+local ReleaseSecureState
+local secureCloseDirty
+
+function ns.Close()
+ if not liveView then return end
+ local palette = liveView:GetFrame()
+ if not palette:IsShown() then
+ ReleaseEscape()
+ return
+ end
+ palette:SetScript("OnUpdate", nil)
+ palette:Hide()
+ ReleaseSecureState(liveView:GetPaletteIndex())
+ ReleaseEscape()
+ -- Both, always together. fanVisual left behind at the strip's last centre
+ -- while fanTarget went to nil reads as a settle that can never finish, and
+ -- SteerUnchanged takes that to mean the geometry is still moving -- so one
+ -- scroll-fan open would cost every later open of ANY layout its per-frame
+ -- skip for the rest of the session. Nothing needs it to survive the close:
+ -- the strip's own re-seeds it, and both readers default it.
+ liveView.fanTarget = nil
+ liveView.fanVisual = nil
+ liveView:SetSelection(nil)
+end
+
+-------------------------------------------------------------------------------
+-- Secure activation
+-------------------------------------------------------------------------------
+local bindOwner
+
+-- Which steering model the snippet must use, by the same reading of the profile
+-- the live view does:
+--
+-- ANGULAR ARC, whether it spans a full turn or a sector of one -- chosen
+-- by the angle from the centre.
+-- POINTER GRID, and either fan on pointer input -- the entry nearest the
+-- cursor wins. A pointer fan is a grid one entry deep, so it is the
+-- same search and the same pushed cell positions.
+-- SCROLL a scroll-steered fan. Its selection is an accumulator driven by
+-- the mouse wheel rather than anything derivable from the cursor,
+-- so the snippet reads the index the wheel handler left behind.
+local function LayoutModel(paletteIndex)
+ local p = PA(paletteIndex)
+ local layout = (p and p.layout) or "ARC"
+ if layout == "ARC" then return "ANGULAR" end
+ if layout == "GRID" then return "POINTER" end
+ return ((p and p.fanInput) or "SCROLL") == "CURSOR" and "POINTER" or "SCROLL"
+end
+
+-- Everything ARMING a claim consists of, as one fragment of snippet text
+-- interpolated into all three places that can decide it: a parent gate's own
+-- OnEnter (EnterSnippet), the press branch's geometric pre-arm, and
+-- LeaveSnippet's re-arm on the way out of another claim. See the "Arming
+-- gates" section further down for what arming means. Three hand-kept copies
+-- would only have to agree with each other, and the live view reads eapArmed
+-- and nothing else -- so a copy that forgot to hide a neighbour's gate or to
+-- show its own regions would draw one claim while the release fired from
+-- another.
+--
+-- Three locals have to be in scope where this is interpolated: `btn`, the
+-- palette's secure button (which carries a reference to every gate, and holds
+-- eapArmed itself); `k`, the claim to arm; and `tag`, the letter this arm goes
+-- into the eapGTrace transcript under. Reading `k` as a local rather than
+-- baking it in is what lets the two new sites, where the claim index is only
+-- known at run time, share the fragment with EnterSnippet, where it is a
+-- literal.
+local ARM_CLAIM = [==[
+ btn:SetAttribute("eapArmed", k)
+ -- A bounded transcript of every arm and disarm this hold, read by
+ -- "/euiap gates" -- see the matching append in LeaveSnippet. It is the
+ -- only record of what the gates actually did once a symptom cannot be
+ -- reproduced offline, but the append lands on a mouse-motion path, so
+ -- nothing is built unless the command has asked for it: eapGDebug is
+ -- written from Lua, out of combat, by "/euiap gates" itself.
+ if btn:GetAttribute("eapGDebug") then
+ local tr = (btn:GetAttribute("eapGTrace") or "") .. tag .. k .. ";"
+ if #tr > 160 then tr = tr:sub(#tr - 160 + 1) end
+ btn:SetAttribute("eapGTrace", tr)
+ end
+ -- Exclusive ground: every OTHER claim's parent gate goes dark while
+ -- this one is armed, so nothing but leaving this claim's own region
+ -- (see LeaveSnippet) can hand focus to a neighbour. A block layout's
+ -- nest reaches over other claims' own cells, and a gate left alight
+ -- there would take every reach that has to cross one.
+ --
+ -- Not on the ARC, where they stay alight. A claim's children live
+ -- radially outside the entry ring and its ground clears the
+ -- neighbouring entries' centres, so no neighbour's gate stands on
+ -- ground this claim holds. Leaving them up is what lets a glide from
+ -- one entry straight onto another hand over at all: rect gates cannot
+ -- cover a wedge, so a cursor that has left every rect of this claim
+ -- while still on its ground has nothing left to fire the leave test
+ -- again, and the claim would stay armed over the entry the cursor
+ -- came to rest on. (A nest set to Overflowing can be spread far
+ -- enough that the reach for its outermost children crosses a
+ -- neighbouring CLAIM's entry, which hands over mid-reach; Contained,
+ -- the default, stops at the midpoint between the two and cannot.)
+ if btn:GetAttribute("eapMode") ~= "ANGULAR" then
+ local armGm = tonumber(btn:GetAttribute("eapGateMax")) or 0
+ for armI = 1, armGm do
+ if armI ~= k then
+ local other = btn:GetFrameRef("pgate" .. armI)
+ if other then other:Hide() end
+ end
+ end
+ end
+ -- The floor goes under the claim's ground for as long as it is armed. Two
+ -- of the three sites that reach this fragment arm GEOMETRICALLY, with the
+ -- cursor already standing inside the gates rather than having walked into
+ -- them, and a gate shown under a still cursor never runs Blizzard's own
+ -- OnEnter wrap -- which is the only thing that raises "_wrapentered", and
+ -- the only thing that lets a wrapped OnLeave pre-body run at all. The
+ -- sandbox cannot raise that flag for itself: RestrictedFrames' SetAttribute
+ -- refuses every name that begins with an underscore. So the disarm is hung
+ -- off an OnENTER instead -- which runs on motion alone, flag or no flag --
+ -- and the floor is the frame that is always there to be entered. See
+ -- EnsureGates.
+ local fgate = btn:GetFrameRef("fgate")
+ if fgate then fgate:Show() end
+ for armR = 1, __REGION_MAX__ do
+ local region = btn:GetFrameRef("rgate" .. k .. "_" .. armR)
+ -- Only a region this open actually pushed a box for: the press
+ -- branch clears an unboxed rgate's points, but this is the loop
+ -- that decides whether it is shown at all, and showing one anyway
+ -- would put a live gate over whatever rect it was left at by an
+ -- earlier, longer-lived open.
+ if region and btn:GetAttribute("eapROHW" .. k .. "_" .. armR) then
+ region:Show()
+ end
+ end
+]==]
+-- Plain substitution rather than string.format, for the same reason the
+-- snippets that take this do it: their bodies are full of the modulo operator.
+ARM_CLAIM = ARM_CLAIM:gsub("__REGION_MAX__", tostring(REGION_MAX))
+
+-- Which slot a release fires is decided by where the cursor is at that instant,
+-- so the decision cannot be made in Lua. Writing the chosen action onto the
+-- button from an insecure PreClick fails as soon as the player is in combat:
+--
+-- ADDON_ACTION_BLOCKED tried to call the protected function
+-- 'EUIActionPaletteButton1:SetAttribute()'
+--
+-- Confirmed in-game. Note that SimpleFrameAPIDocumentation.lua does NOT flag
+-- SetAttribute with IsProtectedFunction the way it flags ClearAttribute: that
+-- flag marks methods that are protected unconditionally, and says nothing about
+-- the separate rule that bites here -- a protected frame, written by tainted
+-- code, during combat. Do not move this back into Lua on the strength of it.
+--
+-- So the choosing happens inside a secure snippet. Code in the restricted
+-- environment is secure, and its SetAttribute calls are not blocked. Everything
+-- the snippet needs is pushed onto the button as ordinary attributes while out
+-- of combat, including the arc geometry ArcGeom already works out -- the snippet
+-- does no layout maths of its own, which is what stops it drifting away from
+-- HitTest as the layout options change.
+--
+-- A snippet body is compiled against a fixed parameter list, not as a vararg
+-- function: Wrapped_Click builds the pre-body with the signature
+-- "self,button,down" and the post-body with "self,message,button,down"
+-- (SecureHandlers.lua:275,287). So those names are already locals here, and
+-- `local button, down = ...` is a compile error -- "cannot use '...' outside a
+-- vararg function" -- which surfaces only when the snippet first runs in-game.
+--
+-- The sandbox has no GetCursorPosition, so the cursor is read with
+-- GetMousePosition on a frame handle. That measures against UIParent, NOT
+-- against the palette, for two independent reasons:
+--
+-- * GetMousePosition goes through GetHandleFrame, which refuses a handle to an
+-- unprotected frame while in combat (RestrictedFrames.lua:84). The palette is
+-- an ordinary addon frame, so its handle is rejected exactly when we need it.
+-- * It returns nil when the cursor lies outside the frame's rect
+-- (RestrictedFrames.lua:317). Layout sizes the palette to a finite box
+-- around its entries, so measuring against it would put a hard edge on a
+-- gesture that is deliberately unbounded in depth: a long flick would
+-- highlight an entry and then fire nothing. Against a fixed-position
+-- palette the cursor could
+-- start outside that box entirely.
+--
+-- UIParent is protected, covers the screen, and never moves. The palette's centre
+-- is therefore derived rather than measured: in fixed-position mode it is
+-- UIParent's centre plus the configured offset, and in cursor mode it is
+-- wherever the cursor was when the palette opened -- which is the position the
+-- press captured, since PositionPalette ran in our PreClick just before this.
+--
+-- GetMousePosition reports a [0,1] fraction of the frame measured from its
+-- bottom-left, so scaling by UIParent's size gives UIParent units, and dividing
+-- by the palette's own scale converts to the units radius and deadZone use.
+-- sqrt is not on the sandbox whitelist; ^0.5 is the same thing.
+--
+-- All angles in here are DEGREES, and the step and start are handed over
+-- already converted. The sandbox's atan2 is WoW's global one, which answers in
+-- degrees; math.atan2, which HitTest upvalues, answers in radians. Working in
+-- degrees also means the wrap is an exact 360 rather than a written-out 2*pi
+-- (`pi` is not on the whitelist), which removes a real trap: a 2*pi literal
+-- short by 1e-13 disagrees with HitTest's math.pi*2 often enough to land the
+-- other side of the +0.5 rounding on a entry boundary -- 735 disagreements
+-- across a 2.7M-position sweep, all of them exactly on an edge.
+local SNIPPET_PRE = [==[
+ local ui = self:GetFrameRef("ui")
+ local mode = self:GetAttribute("eapMode")
+ local catcher = self:GetFrameRef("catcher")
+ local cancel = self:GetFrameRef("cancel")
+
+ -- One palette at a time owns the screen, and the sandbox has to enforce it
+ -- for itself: the Lua PreClick that refuses the second key its live view
+ -- runs BEFORE this, and cannot stop the snippet behind it. The catcher, the
+ -- cancel button and the ESCAPE binding are shared by every palette, so a
+ -- second key's press would re-seed the strip the first one is still
+ -- steering, and its release would hide the catcher and drop ESCAPE out from
+ -- under a hold that is still going. The stamp lives on the cancel button --
+ -- one frame every palette already holds a reference to -- and is cleared by
+ -- the owner's own release (SNIPPET_POST) or by a close that never saw one
+ -- (ns.Close).
+ if cancel then
+ local owner = cancel:GetAttribute("eapOwner")
+ local me = self:GetAttribute("eapPalette")
+ if down then
+ if owner and owner ~= me then
+ self:SetAttribute("eapWhy", "taken")
+ self:SetAttribute("type", nil)
+ return nil, 1
+ end
+ cancel:SetAttribute("eapOwner", me)
+ elseif owner ~= me then
+ -- Positive ownership, not merely "nobody else": a release whose
+ -- press was refused holds the geometry of some earlier hold, and
+ -- resolving a cell out of that would fire an entry off a palette
+ -- that was never on screen.
+ self:SetAttribute("eapWhy", "taken")
+ self:SetAttribute("type", nil)
+ return nil, 1
+ end
+ end
+
+ if down then
+ self:SetAttribute("eapWhy", "pressed")
+ self:SetAttribute("eapIdx", nil)
+ -- Claim ESCAPE for as long as this palette is up, and clear whatever a
+ -- previous open left on the flag. The binding is owned by the cancel
+ -- button, not by us: every palette binds the same key to the same button,
+ -- and one owner means one binding to drop however the palette closes.
+ -- Every layout gets this -- the flag is read before any of the steering
+ -- below, so escaping out is one rule, not three.
+ if catcher then catcher:SetAttribute("eapCancel", nil) end
+ if cancel then
+ cancel:SetBindingClick(true, "ESCAPE", cancel, "LeftButton")
+ end
+ -- Kept on the button, not in a snippet global: every palette shares one
+ -- header, so a global would let palette 2's press reset palette 1's origin.
+ self:SetAttribute("eapGX", nil)
+ self:SetAttribute("eapGY", nil)
+ if ui then
+ local x, y = ui:GetMousePosition()
+ if x then
+ self:SetAttribute("eapGX", x * ui:GetWidth())
+ self:SetAttribute("eapGY", y * ui:GetHeight())
+ end
+ end
+
+ -- Nothing armed yet -- whatever this press ends up arming, it arms
+ -- from the boxes pushed for THIS open, further down. A fresh press has
+ -- to start from scratch: an armed claim that survived
+ -- from the previous open would let a release fire a nest the palette
+ -- had not even drawn yet.
+ self:SetAttribute("eapArmed", nil)
+ -- One transcript per hold -- see ARM_CLAIM and LeaveSnippet. Reset
+ -- here rather than at the release, so "/euiap gates" after a close
+ -- still shows what THAT hold's gates did rather than an empty string.
+ self:SetAttribute("eapGTrace", nil)
+ -- Place every gate this palette pushed a box for, in the same origin
+ -- the release below measures against -- cursor mode takes the point
+ -- just captured above, fixed mode UIParent's centre plus the offset.
+ -- Parent gates go up shown, every claim's, and region gates go down
+ -- hidden, because nothing is armed at this point. The geometric
+ -- pre-arm at the end of the loop is what may then take one claim's
+ -- side of that straight away, and it undoes exactly the two things
+ -- arming always undoes. ArmedClaim and the gates' own
+ -- OnEnter/OnLeave take it from there for as long as the key is held.
+ if ui then
+ local ox, oy
+ if self:GetAttribute("eapFixed") then
+ ox = ui:GetWidth() * 0.5 + (tonumber(self:GetAttribute("eapPosX")) or 0)
+ oy = ui:GetHeight() * 0.5 + (tonumber(self:GetAttribute("eapPosY")) or 0)
+ else
+ ox = tonumber(self:GetAttribute("eapGX"))
+ oy = tonumber(self:GetAttribute("eapGY"))
+ end
+ if ox then
+ local s = tonumber(self:GetAttribute("eapScale")) or 1
+ if s <= 0 then s = 1 end
+ local gm = tonumber(self:GetAttribute("eapGateMax")) or 0
+ for k = 1, gm do
+ local phw = tonumber(self:GetAttribute("eapPOHW" .. k))
+ local pgate = self:GetFrameRef("pgate" .. k)
+ if phw and pgate then
+ local pox = tonumber(self:GetAttribute("eapPOX" .. k)) or 0
+ local poy = tonumber(self:GetAttribute("eapPOY" .. k)) or 0
+ local phh = tonumber(self:GetAttribute("eapPOHH" .. k)) or 0
+ pgate:ClearAllPoints()
+ pgate:SetPoint("BOTTOMLEFT", ui, "BOTTOMLEFT",
+ ox + (pox - phw) * s, oy + (poy - phh) * s)
+ pgate:SetWidth(phw * 2 * s)
+ pgate:SetHeight(phh * 2 * s)
+ pgate:Show()
+ elseif pgate then
+ -- No claim at this slot this open. Cleared, not just
+ -- hidden: EnterSnippet and LeaveSnippet both Show()
+ -- gates by claim index without re-checking that the
+ -- index still has a box, so a rect left anchored from
+ -- a longer set of nests would go on answering for
+ -- ground this open does not hold at all.
+ pgate:ClearAllPoints()
+ pgate:Hide()
+ end
+
+ for r = 1, __REGION_MAX__ do
+ local rgate = self:GetFrameRef("rgate" .. k .. "_" .. r)
+ local rhw = tonumber(self:GetAttribute("eapROHW" .. k .. "_" .. r))
+ if rgate and rhw then
+ local rox = tonumber(self:GetAttribute("eapROX" .. k .. "_" .. r)) or 0
+ local roy = tonumber(self:GetAttribute("eapROY" .. k .. "_" .. r)) or 0
+ local rhh = tonumber(self:GetAttribute("eapROHH" .. k .. "_" .. r)) or 0
+ rgate:ClearAllPoints()
+ rgate:SetPoint("BOTTOMLEFT", ui, "BOTTOMLEFT",
+ ox + (rox - rhw) * s, oy + (roy - rhh) * s)
+ rgate:SetWidth(rhw * 2 * s)
+ rgate:SetHeight(rhh * 2 * s)
+ rgate:Hide()
+ elseif rgate then
+ -- Same hygiene as the parent gate above: this
+ -- claim has fewer regions this open than it once
+ -- did (or none at all), so nothing may answer for
+ -- the rect this rgate used to cover.
+ rgate:ClearAllPoints()
+ rgate:Hide()
+ end
+ end
+ end
+
+ -- Arming is otherwise purely an OnEnter edge, and a gate
+ -- SHOWN under a cursor that is already inside it raises no
+ -- such edge until the cursor leaves and comes back. In cursor
+ -- mode that happens on every single press: the palette opens
+ -- centred on the pointer, so a middle cell sits right under
+ -- it, and a claim there would have been drawn but dead. So
+ -- the press asks the question geometrically instead, once,
+ -- against the same parent boxes the gates were just placed
+ -- from -- the answer a real OnEnter would have given had the
+ -- cursor arrived from outside.
+ local cgx = tonumber(self:GetAttribute("eapGX"))
+ local cgy = tonumber(self:GetAttribute("eapGY"))
+ if cgx and cgy then
+ local dx, dy = (cgx - ox) / s, (cgy - oy) / s
+ local pre
+ for k = 1, gm do
+ local phw = tonumber(self:GetAttribute("eapPOHW" .. k))
+ if phw then
+ local pox = tonumber(self:GetAttribute("eapPOX" .. k)) or 0
+ local poy = tonumber(self:GetAttribute("eapPOY" .. k)) or 0
+ local phh = tonumber(self:GetAttribute("eapPOHH" .. k)) or 0
+ if abs(dx - pox) <= phw and abs(dy - poy) <= phh then
+ pre = k
+ break
+ end
+ end
+ end
+ if pre then
+ local btn, k, tag = self, pre, "P"
+ __ARM_CLAIM__
+ end
+ end
+ end
+ end
+
+ if mode == "SCROLL" and catcher then
+ -- 1, not nil: the strip opens centred on its first entry and that
+ -- entry is selected from the outset. See the wheel snippet.
+ catcher:SetAttribute("eapFanTarget", 1)
+ catcher:SetAttribute("eapShown", self:GetAttribute("eapShown"))
+ catcher:SetAttribute("eapInvert", self:GetAttribute("eapInvert"))
+ catcher:SetAttribute("eapOpen", 1)
+ catcher:Show()
+ end
+ self:SetAttribute("type", nil)
+ return nil, 1
+ end
+
+ self:SetAttribute("type", nil)
+
+ -- Escaped out while the key was still held. Checked before anything is
+ -- steered, so it beats every layout's own cancel and cannot be undone by
+ -- moving the pointer back onto the palette.
+ if catcher and catcher:GetAttribute("eapCancel") then
+ self:SetAttribute("eapWhy", "escaped") return nil, 1
+ end
+
+ local n = tonumber(self:GetAttribute("eapShown")) or 0
+ if n < 1 then self:SetAttribute("eapWhy", "noslots") return nil, 1 end
+ -- Every cell, the palette's own entries and the nested ones after them.
+ -- Which of the nested ones, if any, may actually be picked below is
+ -- eapArmed's business -- see the ANGULAR and POINTER branches -- rather
+ -- than something decided here.
+ local total = tonumber(self:GetAttribute("eapTotal")) or n
+
+ local idx
+ if mode == "SCROLL" then
+ -- The wheel snippet has been keeping the accumulator; the cursor plays
+ -- no part in this layout, so none of the pointer work below applies.
+ if not catcher then
+ self:SetAttribute("eapWhy", "nocatcher") return nil, 1
+ end
+ local ft = tonumber(catcher:GetAttribute("eapFanTarget"))
+ self:SetAttribute("eapRel", ft)
+ if not ft then
+ -- The press seeds the accumulator, so this can only mean the press
+ -- never reached the catcher. Nothing was steered; cancel.
+ self:SetAttribute("eapWhy", "unscrolled") return nil, 1
+ end
+
+ idx = ((ft - 1) % n) + 1
+
+ -- Offset from where the pointer was when the palette opened, which is
+ -- what this layout measures both its cancel and its nests from.
+ local gx = tonumber(self:GetAttribute("eapGX"))
+ local gy = tonumber(self:GetAttribute("eapGY"))
+ local dx, dy
+ if gx and ui then
+ local x, y = ui:GetMousePosition()
+ if x then
+ local s = tonumber(self:GetAttribute("eapScale")) or 1
+ if s <= 0 then s = 1 end
+ dx = (x * ui:GetWidth() - gx) / s
+ dy = (y * ui:GetHeight() - gy) / s
+ end
+ end
+
+ -- Into the nest the wheel's entry opens, if the pointer has gone there.
+ -- Before the cancel below, and not only for speed: the children sit
+ -- past the ordinary margin, so a release among them reads as thrown
+ -- clear until this has had its say.
+ local base = tonumber(self:GetAttribute("eapNBase" .. idx))
+ local hit
+ if base and dx then
+ local num = tonumber(self:GetAttribute("eapNNum" .. idx)) or 0
+ for j = 1, num do
+ local i2 = base + j
+ local bx = tonumber(self:GetAttribute("eapBX" .. i2))
+ local by = tonumber(self:GetAttribute("eapBY" .. i2))
+ if bx and abs(dx - bx) <= (tonumber(self:GetAttribute("eapHW" .. i2)) or 0)
+ and abs(dy - by) <= (tonumber(self:GetAttribute("eapHH" .. i2)) or 0) then
+ idx = i2
+ hit = true
+ break
+ end
+ end
+ end
+
+ -- Thrown clear of the strip -> cancel. This is the strip's counterpart
+ -- to the grid's out-of-reach: past the strip in ANY direction, measured
+ -- from where the pointer was when the palette opened. The box is as
+ -- long as the strip is drawn and only a margin wide, because that is
+ -- the shape of the thing being left. The live view applies exactly this
+ -- rule, so a strip showing nothing selected fires nothing.
+ --
+ -- No geometry pushed -> no box to test against, so the release stands.
+ -- Firing what the user steered to is the safer of the two failures.
+ local margin = tonumber(self:GetAttribute("eapFanMargin"))
+ local half = tonumber(self:GetAttribute("eapFanHalf"))
+ if not hit and dx and margin and half then
+ local along, across = dx, dy
+ if not self:GetAttribute("eapFanHoriz") then
+ along, across = across, along
+ end
+ -- Reaching toward a nest is not leaving. Only on the side that
+ -- nest is on, and only while its entry is the one the wheel is on.
+ local am = margin
+ local na = tonumber(self:GetAttribute("eapNAcross" .. idx))
+ local ns = tonumber(self:GetAttribute("eapNSide" .. idx))
+ if na and ns and (across > 0) == (ns > 0) and na > am then am = na end
+ if abs(across) > am or abs(along) > half + margin then
+ self:SetAttribute("eapWhy", "thrownclear") return nil, 1
+ end
+ end
+ else
+ if not ui then self:SetAttribute("eapWhy", "nohandle") return nil, 1 end
+ local x, y = ui:GetMousePosition()
+ if not x then self:SetAttribute("eapWhy", "offscreen") return nil, 1 end
+ local w, h = ui:GetWidth(), ui:GetHeight()
+ local cx, cy = x * w, y * h
+
+ local gx = tonumber(self:GetAttribute("eapGX"))
+ local gy = tonumber(self:GetAttribute("eapGY"))
+
+ -- SetPoint offsets are read in the palette's own scaled space, so the
+ -- centre sits exactly posX/posY UIParent units from UIParent's centre;
+ -- the scale only converts the distance from there.
+ local s = tonumber(self:GetAttribute("eapScale")) or 1
+ if s <= 0 then s = 1 end
+
+ -- Opening under the cursor would otherwise arrive with an entry already
+ -- chosen; nothing counts until the pointer has actually moved.
+ --
+ -- Divided by the scale so this is one PALETTE unit, the same unit the live
+ -- views measure their gate in. Comparing raw UIParent units against 1
+ -- agreed with them only at scale 1: at scale 2 a move the palette still
+ -- counted as stationary was already past the snippet's threshold, and
+ -- the release fired an entry the palette was drawing as unselected.
+ --
+ -- This does not latch, where the live views set _steered on the first
+ -- movement and never re-arm. The snippet only ever sees the release, so
+ -- a gesture that wanders off and returns to within a unit of where it
+ -- started cancels here while the palette still shows an entry selected.
+ -- It errs toward cancelling rather than firing something unintended.
+ if gx and abs(cx - gx) / s < 1 and abs(cy - gy) / s < 1 then
+ self:SetAttribute("eapWhy", "unmoved") return nil, 1
+ end
+
+ local ox, oy
+ if self:GetAttribute("eapFixed") then
+ ox = w * 0.5 + (tonumber(self:GetAttribute("eapPosX")) or 0)
+ oy = h * 0.5 + (tonumber(self:GetAttribute("eapPosY")) or 0)
+ elseif gx then
+ ox, oy = gx, gy
+ else
+ self:SetAttribute("eapWhy", "noorigin") return nil, 1
+ end
+ local dx, dy = (cx - ox) / s, (cy - oy) / s
+ self:SetAttribute("eapDX", dx)
+ self:SetAttribute("eapDY", dy)
+
+ if mode == "POINTER" then
+ local pitch = tonumber(self:GetAttribute("eapPitch")) or 1
+ if pitch <= 0 then pitch = 1 end
+
+ -- The armed claim's cells first, and by CONTAINMENT: a half-extent
+ -- is what marks a cell as one. Inside a box, that child regardless
+ -- of what the block holds underneath; outside every box, the block
+ -- answers as though the nest were not there. eapArmed is what a
+ -- gate frame's OnEnter/OnLeave has kept current for as long as the
+ -- key has been held -- see EnsureGates and the press branch above --
+ -- so a claim the cursor never actually entered through its parent
+ -- has no cells tested here at all, however close the pointer now
+ -- sits to where they are drawn.
+ local armed = tonumber(self:GetAttribute("eapArmed"))
+ if armed then
+ local base = tonumber(self:GetAttribute("eapGBase" .. armed)) or 0
+ local num = tonumber(self:GetAttribute("eapGNum" .. armed)) or 0
+ for j = 1, num do
+ local i = base + j
+ local hw = tonumber(self:GetAttribute("eapHW" .. i))
+ if hw then
+ local bx = tonumber(self:GetAttribute("eapBX" .. i)) or 0
+ local by = tonumber(self:GetAttribute("eapBY" .. i)) or 0
+ local hh = tonumber(self:GetAttribute("eapHH" .. i)) or 0
+ if abs(dx - bx) <= hw and abs(dy - by) <= hh then
+ idx = i
+ break
+ end
+ end
+ end
+ end
+
+ -- Nearest of the palette's own, by true 2D distance in cells. A grid
+ -- has no privileged axis, so projecting onto one would let sideways
+ -- movement change the choice. Past eapReach cells from every entry
+ -- nothing is selected -- that is this layout's cancel, and it has no
+ -- dead zone: the centre of a grid can hold an entry, so cancelling
+ -- there would make the middle of an odd-sized grid unfireable.
+ if not idx then
+ local bestK
+ for i = 1, n do
+ local bx = tonumber(self:GetAttribute("eapBX" .. i))
+ local by = tonumber(self:GetAttribute("eapBY" .. i))
+ if bx then
+ local px, py = (dx - bx) / pitch, (dy - by) / pitch
+ local k = (px * px + py * py) ^ 0.5
+ if not bestK or k < bestK then idx, bestK = i, k end
+ end
+ end
+ self:SetAttribute("eapRel", bestK)
+ if bestK and bestK > (tonumber(self:GetAttribute("eapReach")) or 1) then
+ idx = nil
+ end
+ end
+ if not idx then
+ self:SetAttribute("eapIdx", nil)
+ self:SetAttribute("eapWhy", "outofreach") return nil, 1
+ end
+ else
+ local dist = (dx * dx + dy * dy) ^ 0.5
+ local theta = atan2(dx, dy)
+ if theta < 0 then theta = theta + 360 end
+ self:SetAttribute("eapTheta", theta)
+
+ -- The armed claim's rings, and no other's -- see the note above the
+ -- POINTER branch's own use of eapArmed, and ArmedClaim on the live
+ -- side. A child sector reaches past its parent entry's own, which is
+ -- exactly the ground a neighbouring claim used to be able to steal
+ -- before the cursor had ever gone through its own parent to earn it.
+ local armed = tonumber(self:GetAttribute("eapArmed"))
+ if armed then
+ local band = tonumber(self:GetAttribute("eapCBand" .. armed))
+ if band and dist >= band then
+ -- Which ring dist falls in -- Lo/Hi partition the RADIUS,
+ -- not the angle, so a ring that matches the radius but
+ -- misses the angle is the claim missing outright, not a
+ -- reason to try the next ring out.
+ local rows = tonumber(self:GetAttribute("eapCRows" .. armed)) or 0
+ for r = 1, rows do
+ local tag = "eapCR" .. armed .. "_" .. r
+ local lo = tonumber(self:GetAttribute(tag .. "Lo"))
+ local hi = tonumber(self:GetAttribute(tag .. "Hi"))
+ if lo and dist >= lo and (not hi or dist < hi) then
+ local cstep = tonumber(self:GetAttribute(tag .. "StepDeg")) or 0
+ local cn = tonumber(self:GetAttribute(tag .. "N")) or 0
+ if cstep > 0 and cn > 0 then
+ local crel = (theta
+ - (tonumber(self:GetAttribute(tag .. "StartDeg")) or 0)) % 360
+ if crel < cn * cstep then
+ idx = (tonumber(self:GetAttribute(tag .. "Base")) or 0)
+ + floor(crel / cstep) + 1
+ end
+ end
+ break
+ end
+ end
+ end
+ end
+
+ if not idx then
+ local dz = tonumber(self:GetAttribute("eapDeadZone")) or 24
+ if dist < dz then
+ self:SetAttribute("eapWhy", "deadzone") return nil, 1
+ end
+
+ local step = tonumber(self:GetAttribute("eapStepDeg")) or 0
+ if step == 0 then
+ idx = 1
+ else
+ local rel = theta - (tonumber(self:GetAttribute("eapStartDeg")) or 0)
+ self:SetAttribute("eapRel", rel)
+ if self:GetAttribute("eapFull") then
+ idx = (floor(rel / step + 0.5) % n) + 1
+ else
+ rel = rel % 360
+ if rel <= (n - 1) * step + step * 0.5 then
+ idx = floor(rel / step + 0.5) + 1
+ -- Exactly on the arc's outer boundary rounds up
+ -- past its last entry, and the bound below cannot
+ -- catch that once the palette nests anything: with
+ -- children pushed, total is larger than n, so n + 1
+ -- is the FIRST nested cell -- which would fire
+ -- without its claim ever having been armed. HitTest
+ -- guards the same rounding the same way.
+ if idx > n then idx = nil end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ self:SetAttribute("eapIdx", idx)
+ if not idx or idx < 1 or idx > total then
+ self:SetAttribute("eapWhy", "noidx") return nil, 1
+ end
+
+ -- Stopped on a slot that opens a palette rather than going through it.
+ if self:GetAttribute("eapPal" .. idx) then
+ self:SetAttribute("eapWhy", "palette") return nil, 1
+ end
+
+ local t = self:GetAttribute("eapT" .. idx)
+ if not t then self:SetAttribute("eapWhy", "emptyslot") return nil, 1 end
+
+ -- Clear every action key before writing this slot's, so no earlier slot's
+ -- value can outlive it: type="macro" reads "macro" before it falls through
+ -- to "macrotext", and type="spell" would reuse a stale "spell" happily.
+ self:SetAttribute("spell", nil)
+ self:SetAttribute("item", nil)
+ self:SetAttribute("macro", nil)
+ self:SetAttribute("macrotext", nil)
+ self:SetAttribute("toy", nil)
+ -- "action" is the marker sweep's key, and type="raidtarget" falls back to
+ -- "toggle" when it is unset -- so a sweep left behind would turn the next
+ -- raidtarget slot into a clear-all of the whole group.
+ self:SetAttribute("action", nil)
+
+ -- A cycling entry names a different marker on every press, and the position
+ -- it has reached has to advance HERE: an insecure SetAttribute is refused
+ -- in combat, which is the whole of when marking matters. eapCycPos is the
+ -- position last placed, so the step is taken before it is spent, and the
+ -- Lua side reads it back off this button once the release is over.
+ local v = self:GetAttribute("eapV" .. idx)
+ local cn = tonumber(self:GetAttribute("eapCycN" .. idx))
+ if cn and cn > 0 then
+ local pos = (tonumber(self:GetAttribute("eapCycPos" .. idx)) or 0) % cn + 1
+ self:SetAttribute("eapCycPos" .. idx, pos)
+ v = self:GetAttribute("eapCycV" .. idx .. "_" .. pos) or v
+ end
+ self:SetAttribute(self:GetAttribute("eapK" .. idx), v)
+ self:SetAttribute("type", t)
+ self:SetAttribute("eapWhy", "fire")
+ return nil, 1
+]==]
+-- REGION_MAX is baked in by plain substitution rather than string.format:
+-- the body above is full of %, the modulo operator, and format would choke
+-- on every one of them that is not itself a substitution.
+SNIPPET_PRE = SNIPPET_PRE:gsub("__REGION_MAX__", tostring(REGION_MAX))
+-- A FUNCTION replacement rather than a plain string: gsub reads "%" in a
+-- replacement string as a capture reference, and a fragment that ever grows a
+-- modulo would otherwise fail here rather than where it was written.
+SNIPPET_PRE = SNIPPET_PRE:gsub("__ARM_CLAIM__", function() return ARM_CLAIM end)
+
+-- Leaves nothing armed: the next press has to choose again from scratch.
+local SNIPPET_POST = [==[
+ if down then return end
+ self:SetAttribute("type", nil)
+ -- Only the palette that owns the screen may put any of this away: all of it
+ -- is shared, and a second key pressed during another palette's hold was
+ -- refused its press (see SNIPPET_PRE), so its release has nothing of its
+ -- own to tear down and would otherwise tear down the hold still in
+ -- progress.
+ local cancel = self:GetFrameRef("cancel")
+ if cancel and cancel:GetAttribute("eapOwner") ~= self:GetAttribute("eapPalette") then
+ return
+ end
+ if cancel then cancel:SetAttribute("eapOwner", nil) end
+ -- Every close funnels through here, including the cancels that returned
+ -- early above, so the catcher stops eating camera zoom on all of them.
+ local catcher = self:GetFrameRef("catcher")
+ if catcher then
+ catcher:SetAttribute("eapOpen", nil)
+ catcher:Hide()
+ end
+ -- Hand ESCAPE back to the game menu.
+ if cancel then cancel:ClearBindings() end
+
+ -- Every gate this palette owns, hidden and disarmed on every close --
+ -- including the cancels above, for the same reason the catcher is handled
+ -- unconditionally here. Nothing may leak into the next press: a region
+ -- gate left shown from this hold would still be over its old ground the
+ -- next time the palette opens somewhere else entirely, at least until the
+ -- press branch repositions it, and eapArmed itself would let a release on
+ -- the very next open fire a claim the cursor never went near this time.
+ self:SetAttribute("eapArmed", nil)
+ -- The floor first: it is the one gate that covers the whole screen, and a
+ -- hold that ended with a claim armed would otherwise leave it there taking
+ -- the cursor's hover away from everything under it.
+ local fgate = self:GetFrameRef("fgate")
+ if fgate then fgate:Hide() end
+ local gm = tonumber(self:GetAttribute("eapGateMax")) or 0
+ for k = 1, gm do
+ local pgate = self:GetFrameRef("pgate" .. k)
+ if pgate then pgate:Hide() end
+ for r = 1, __REGION_MAX__ do
+ local rgate = self:GetFrameRef("rgate" .. k .. "_" .. r)
+ if rgate then rgate:Hide() end
+ end
+ end
+]==]
+SNIPPET_POST = SNIPPET_POST:gsub("__REGION_MAX__", tostring(REGION_MAX))
+
+-- ESCAPE while a palette is open. It cannot be an insecure key handler: the
+-- release that follows is resolved inside the snippet, and only secure code may
+-- leave it a flag to read once the player is in combat. So the press snippet
+-- binds ESCAPE to this button, this button's snippet raises the flag, and the
+-- release finds it and fires nothing.
+--
+-- The button performs no action of its own -- it never gets a "type" -- so the
+-- click exists purely to run this.
+local SNIPPET_CANCEL = [==[
+ local catcher = self:GetFrameRef("catcher")
+ if catcher then
+ catcher:SetAttribute("eapCancel", 1)
+ -- A scroll fan's catcher is still eating the mouse wheel, and the key
+ -- may be held for a while yet. Give camera zoom back now rather than at
+ -- the release, which is the same thing the release itself would do.
+ catcher:SetAttribute("eapOpen", nil)
+ catcher:Hide()
+ end
+]==]
+
+-- Closing the palette on screen is insecure work, and none of it is protected:
+-- the frame is an ordinary addon frame.
+local function OnCancelClick()
+ ns.Close()
+end
+
+local function EnsureCancelButton()
+ if cancelButton then return cancelButton end
+
+ local btn = CreateFrame("Button", "EUIActionPaletteCancel", UIParent,
+ "SecureActionButtonTemplate")
+ -- Down only: ESCAPE should take effect the instant it is pressed, and a
+ -- second run on the up edge would only re-raise a flag that is already set.
+ btn:RegisterForClicks("AnyDown")
+ -- Parked like the palette buttons: invisible, unclickable by mouse, and shown,
+ -- because an override-binding click has to reach a live button.
+ btn:EnableMouse(false)
+ btn:SetSize(1, 1)
+ btn:SetAlpha(0)
+ btn:SetPoint("TOPLEFT", UIParent, "TOPLEFT", -400, 100)
+ btn:Show()
+ btn:SetScript("PostClick", OnCancelClick)
+
+ SecureHandlerSetFrameRef(btn, "catcher", EnsureScrollCatcher())
+ SecureHandlerWrapScript(btn, "OnClick", EnsureSecureHeader(), SNIPPET_CANCEL)
+
+ cancelButton = btn
+ return btn
+end
+
+-------------------------------------------------------------------------------
+-- Arming gates -- the pass-through rule, and the exclusive-ground rule
+--
+-- A nest's cells only answer a release once the cursor has actually entered
+-- the claim's own parent entry, and stop answering only once it has left
+-- the claim's WHOLE ground -- parent cell, nest, and the corridor between --
+-- not merely one rect of it. That is state that has to survive the entire
+-- hold, which the release snippet cannot do on its own -- it only ever sees
+-- the final position -- so it is kept on the secure button itself, as
+-- eapArmed, and maintained by protected frames per claim reacting to real
+-- mouse movement:
+--
+-- PARENT gate covers the claim's own entry. OnEnter arms the claim,
+-- hides every OTHER claim's parent gate (block layouts
+-- only -- see below), and shows this claim's REGION gates.
+-- REGION gates up to REGION_MAX rects covering the claim's ground -- see
+-- CorridorBox and CellChildGeom/ChildGeom for what
+-- they are. An arc's ground is a wedge rather than a set of
+-- rects, so there its rects are event surfaces only and the
+-- test below is polar. OnLeave of ANY of them does NOT blindly
+-- disarm: moving between two of a claim's own region rects
+-- also fires OnLeave (see below), so the snippet instead
+-- measures the cursor against the claim's FULL region,
+-- geometrically, and disarms only when it is genuinely
+-- outside all of it. Disarming re-shows every OTHER
+-- claim's parent gate and hides this claim's own regions.
+--
+-- Parent gates sit at a HIGHER frame level than region gates, and every
+-- region gate of a claim sits at the SAME level as its siblings. WoW's mouse
+-- focus is exclusive and topmost-wins -- exactly one frame holds it at a
+-- time -- which is what both rules lean on:
+--
+-- Exclusive arming, in the BLOCK layouts. While claim A is armed, every
+-- OTHER claim's parent gate is hidden, so brushing past a neighbour's cell
+-- cannot steal focus from A's own ground no matter how close the two sit --
+-- the in-game complaint this fixes was two adjacent Halo rings swapping
+-- back and forth on the slightest movement. A hidden gate cannot receive
+-- OnEnter, so B stays unarmable until A's OnLeave test actually disarms it
+-- and re-shows B's gate.
+--
+-- The ARC does the opposite and leaves them alight, because there the two
+-- grounds do not interleave: a claim's children sit radially outside the
+-- entry ring, and its ground clears the neighbouring entries' own centres
+-- (see ChildGeom). What it cannot do is cover that ground with rects -- the
+-- ground is a wedge -- so a cursor that has left every rect of A while
+-- still on A's ground has nothing left to fire the leave test again, and
+-- hiding B's gate as well would leave A armed over the entry the cursor
+-- finally stopped on. That is also the one place the two layouts' promises
+-- differ: on the arc a claim can stay armed over a PLAIN entry, which
+-- leaves its nest drawn open a moment too long and costs the release
+-- nothing (the release never consults eapArmed inside the entry ring).
+--
+-- Geometric arming, at the two moments a gate is SHOWN. Show() does not
+-- synthesise a motion event, so a gate that comes up under a cursor
+-- already inside it raises no OnEnter at all until the cursor leaves and
+-- comes back -- and both of the moments gates are shown are moments the
+-- cursor is very likely already on one. In cursor mode the palette opens
+-- centred on the pointer, so a middle cell's gate is under it at every
+-- single press; and a disarm re-shows every parent gate, possibly beneath
+-- a cursor that has already arrived on another claim's entry. Left to the
+-- OnEnter edge alone, the nest was drawn and its children dead until the
+-- user wiggled out and back. So both moments ask the same question
+-- geometrically, in secure code, from the same pushed parent boxes the
+-- gates themselves were placed from: the press branch of SNIPPET_PRE
+-- after it places them, and LeaveSnippet's disarm path after it re-shows
+-- them. Both then arm through ARM_CLAIM, the same fragment a parent
+-- gate's own OnEnter uses, so there is only ever one meaning of "armed".
+--
+-- Same-claim focus hand-off. Moving from one of a claim's own region
+-- rects to a sibling rect of the SAME claim still fires the first one's
+-- OnLeave (focus left THAT frame), which is why the geometric re-test
+-- exists: it finds the cursor inside the sibling rect and answers "still
+-- in", so nothing is disarmed and neither rect is hidden. Nothing needs a
+-- reference to any gate but its own here, because the button (eapArmed)
+-- is the only shared state -- every gate reads and writes through it.
+--
+-- Built and positioned only out of combat, alongside PushPalette's geometry:
+-- SecureHandlerSetFrameRef and SecureHandlerWrapScript are themselves
+-- ordinary insecure calls, and PushPalette already refuses to run in combat
+-- for the same reason. Positioning happens in the press branch of
+-- SNIPPET_PRE instead, because only that branch knows where this particular
+-- press's palette actually opened.
+-------------------------------------------------------------------------------
+
+-- self:GetFrameRef("btn") is the palette's own secure button; every gate
+-- carries that one reference back, however many palettes and claims exist,
+-- because the header they are all wrapped through is shared. k is baked into
+-- the snippet text rather than read off an attribute: each gate only ever
+-- needs to know its OWN claim index, never anyone else's, so there is nothing
+-- for a shared body to look up. It goes into a LOCAL of that name, which is
+-- what ARM_CLAIM reads -- the two sites that arm geometrically only know their
+-- claim at run time, and one fragment serving all three is one definition of
+-- what arming does.
+--
+-- Wrapped in parentheses for the same reason LeaveSnippet's return is; see
+-- the note there.
+local function EnterSnippet(k)
+ return (([==[
+ local btn = self:GetFrameRef("btn")
+ if btn then
+ local k, tag = __CLAIM_K__, "E"
+ __ARM_CLAIM__
+ end
+ ]==]):gsub("__CLAIM_K__", tostring(k))
+ :gsub("__ARM_CLAIM__", function() return ARM_CLAIM end))
+end
+
+-- Runs on the OnLeave of any one of claim k's region rects. Does not trust
+-- "I lost focus" to mean "the claim is left" -- a claim can own several of
+-- these rects, and moving between two of its own fires this too -- so it
+-- re-measures the cursor against the claim's WHOLE region before deciding.
+-- The maths mirrors the release branch of SNIPPET_PRE: same origin, same
+-- scale, same units, because this and that answer the identical question
+-- ("where is the cursor in the palette's own space") from two different
+-- places and must not drift apart.
+-- Built with plain substitution rather than string.format: the body below
+-- has a real modulo operator in it (`% 360`), which format would choke on
+-- as an invalid conversion.
+--
+-- The whole chain is wrapped in its own parentheses, not merely the string
+-- literal at its head: gsub returns the substitution count as a SECOND
+-- value, and an unparenthesised tail call in a return statement hands both
+-- of them back. EnsureGates calls this as the LAST argument to
+-- SecureHandlerWrapScript, so that stray count would have landed in
+-- postBody -- which SecureHandlerWrapScript rejects outright unless it is a
+-- string or nil, aborting the wrap (and, uncaught, the rest of EnsureGates'
+-- loop past it) with "Invalid post-handler body" the moment any claim's
+-- first region gate was ever built.
+--
+-- k is nil for the FLOOR gate, which belongs to no one claim and runs the same
+-- test for whichever claim is armed at the moment it is entered. Only two lines
+-- differ -- the "is this gate still the armed claim's" prologue, and the letter
+-- the transcript records -- and the rest of the body already reads `armed` at
+-- run time rather than through the baked-in literal, so both variants measure
+-- the identical ground the identical way.
+local function LeaveSnippet(k)
+ return (([==[
+ local btn = self:GetFrameRef("btn")
+ local armed = btn and tonumber(btn:GetAttribute("eapArmed"))
+ if __STALE_TEST__ then
+ self:Hide()
+ return
+ end
+ -- Past here armed == this claim's own index, so every attribute
+ -- lookup below reads THROUGH the runtime value rather than through
+ -- another baked-in literal -- one less place for a claim's own
+ -- number to have to agree with itself.
+
+ local inside = false
+ -- The cursor offset, kept out here rather than in the block that
+ -- works it out: the disarm path at the bottom re-uses it to ask
+ -- whether the cursor has landed on ANOTHER claim's entry, and it is
+ -- the same reading either way. nil when there was no reading to take.
+ local cdx, cdy
+ local ui = self:GetFrameRef("ui")
+ if ui then
+ local x, y = ui:GetMousePosition()
+ if x then
+ local w, h = ui:GetWidth(), ui:GetHeight()
+ local cx, cy = x * w, y * h
+ local s = tonumber(btn:GetAttribute("eapScale")) or 1
+ if s <= 0 then s = 1 end
+ local ox, oy
+ if btn:GetAttribute("eapFixed") then
+ ox = w * 0.5 + (tonumber(btn:GetAttribute("eapPosX")) or 0)
+ oy = h * 0.5 + (tonumber(btn:GetAttribute("eapPosY")) or 0)
+ else
+ ox = tonumber(btn:GetAttribute("eapGX"))
+ oy = tonumber(btn:GetAttribute("eapGY"))
+ end
+ if ox then
+ local dx, dy = (cx - ox) / s, (cy - oy) / s
+ cdx, cdy = dx, dy
+
+ -- No inflation HERE, and none needed: the overshoot grace
+ -- a fast reach wants is built into the eapRO* rects
+ -- themselves, by CellChildGeom, so the gate FRAMES the
+ -- press branch sizes from those numbers already carry it
+ -- and this test consumes the identical rects. That is the
+ -- whole reason it belongs there rather than here. A margin
+ -- applied only in this test would instead create a dead
+ -- zone: the cursor crossing the frame's own smaller edge
+ -- fires this, the margin answers "still inside", and no
+ -- gate is left under the cursor to fire a SECOND OnLeave
+ -- once it clears the wider boundary -- so that verdict is
+ -- never revisited and the claim stays armed however far
+ -- the cursor drifts on.
+
+ -- The parent's own cell always counts.
+ local phw = tonumber(btn:GetAttribute("eapPOHW" .. armed))
+ if phw then
+ local pox = tonumber(btn:GetAttribute("eapPOX" .. armed)) or 0
+ local poy = tonumber(btn:GetAttribute("eapPOY" .. armed)) or 0
+ local phh = tonumber(btn:GetAttribute("eapPOHH" .. armed)) or 0
+ if abs(dx - pox) <= phw and abs(dy - poy) <= phh then
+ inside = true
+ end
+ end
+
+ -- An ARC claim's true ground is polar, not the rects the
+ -- gate frames use for event coverage -- those are
+ -- generous on purpose (see CorridorBox). Two pieces, both
+ -- sized by ChildGeom -- a BEAM out of the parent entry,
+ -- and a WEDGE past the entry ring's outer edge -- and
+ -- nothing at all inward of the icon's inner face, where a
+ -- retreat toward the centre has to disarm so the other
+ -- claims get their parent gates back.
+ --
+ -- Neither piece is the release's own ring resolution, and
+ -- both are supersets of it. The beam is what the reach for
+ -- a child actually travels through: a straight line from
+ -- the parent passes BESIDE its icon before it clears the
+ -- entry ring, and while that ground belonged to nothing the
+ -- pgate's own OnLeave -- fired a few units into every reach
+ -- -- disarmed the claim and left its children dead for the
+ -- rest of the hold.
+ if not inside and btn:GetAttribute("eapMode") == "ANGULAR" then
+ local lo = tonumber(btn:GetAttribute("eapCLo" .. armed))
+ -- Along the parent's own axis, and across it. The axis
+ -- is pushed as a vector because the sandbox has no
+ -- sin/cos to rebuild it from the angle.
+ local u = lo and (dx * (tonumber(btn:GetAttribute("eapCAX" .. armed)) or 0)
+ + dy * (tonumber(btn:GetAttribute("eapCAY" .. armed)) or 0))
+ if u and u >= lo then
+ local v = dx * (tonumber(btn:GetAttribute("eapCAY" .. armed)) or 0)
+ - dy * (tonumber(btn:GetAttribute("eapCAX" .. armed)) or 0)
+ if v < 0 then v = -v end
+ if v <= (tonumber(btn:GetAttribute("eapCBeam" .. armed)) or 0)
+ + u * (tonumber(btn:GetAttribute("eapCSlope" .. armed)) or 0) then
+ inside = true
+ elseif (dx * dx + dy * dy) ^ 0.5
+ >= (tonumber(btn:GetAttribute("eapCEdge" .. armed)) or 0) then
+ local ad = (atan2(dx, dy)
+ - (tonumber(btn:GetAttribute("eapCAngle" .. armed)) or 0)) % 360
+ if ad > 180 then ad = 360 - ad end
+ inside = ad <= (tonumber(btn:GetAttribute("eapCWedge" .. armed)) or 0)
+ end
+ end
+ elseif not inside then
+ for r = 1, __REGION_MAX__ do
+ local rhw = tonumber(btn:GetAttribute("eapROHW" .. armed .. "_" .. r))
+ if rhw then
+ local rox = tonumber(btn:GetAttribute("eapROX" .. armed .. "_" .. r)) or 0
+ local roy = tonumber(btn:GetAttribute("eapROY" .. armed .. "_" .. r)) or 0
+ local rhh = tonumber(btn:GetAttribute("eapROHH" .. armed .. "_" .. r)) or 0
+ if abs(dx - rox) <= rhw and abs(dy - roy) <= rhh then
+ inside = true
+ break
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ if btn:GetAttribute("eapGDebug") then
+ -- See the matching append in ARM_CLAIM -- one transcript per
+ -- hold, shared across every claim's gates because eapGTrace lives
+ -- on the button, not on any one gate. Off until "/euiap gates"
+ -- switches it on: this runs on every crossing of every gate, the
+ -- screen-wide floor included.
+ local tr = (btn:GetAttribute("eapGTrace") or "") ..
+ __TRACE_HEAD__ .. (inside and ":in;" or ":out;")
+ if #tr > 160 then tr = tr:sub(#tr - 160 + 1) end
+ btn:SetAttribute("eapGTrace", tr)
+ end
+
+ if not inside then
+ btn:SetAttribute("eapArmed", nil)
+ local gm = tonumber(btn:GetAttribute("eapGateMax")) or 0
+ for i = 1, gm do
+ local other = btn:GetFrameRef("pgate" .. i)
+ -- Only a slot that still has a claim this open: see the
+ -- matching note in EnterSnippet's own Show() loop.
+ if other and btn:GetAttribute("eapPOHW" .. i) then other:Show() end
+ end
+ for r = 1, __REGION_MAX__ do
+ local region = btn:GetFrameRef("rgate" .. armed .. "_" .. r)
+ if region then region:Hide() end
+ end
+
+ -- Those parent gates went back up under wherever the cursor
+ -- happens to be right now, which on a quick move from one claim's
+ -- entry to another's is that other entry itself. Show() raises no
+ -- OnEnter, so the claim the user has already reached would stay
+ -- unarmed until they moved off it and back on. Asked
+ -- geometrically instead, from the boxes the gates were placed
+ -- from -- the same thing the press branch does for the same
+ -- reason. Skipped when there was no cursor reading to take:
+ -- nothing to test against, and the disarm above already stands.
+ if cdx then
+ local reArm
+ local rgm = tonumber(btn:GetAttribute("eapGateMax")) or 0
+ for i = 1, rgm do
+ if i ~= armed then
+ local phw2 = tonumber(btn:GetAttribute("eapPOHW" .. i))
+ if phw2 then
+ local pox2 = tonumber(btn:GetAttribute("eapPOX" .. i)) or 0
+ local poy2 = tonumber(btn:GetAttribute("eapPOY" .. i)) or 0
+ local phh2 = tonumber(btn:GetAttribute("eapPOHH" .. i)) or 0
+ if abs(cdx - pox2) <= phw2 and abs(cdy - poy2) <= phh2 then
+ reArm = i
+ break
+ end
+ end
+ end
+ end
+ if reArm then
+ local k, tag = reArm, "R"
+ __ARM_CLAIM__
+ end
+ end
+ end
+ ]==]):gsub("__STALE_TEST__", k and ("armed ~= " .. k) or "not armed")
+ :gsub("__TRACE_HEAD__", k and ('"L' .. k .. '"') or '"F" .. armed')
+ :gsub("__REGION_MAX__", tostring(REGION_MAX))
+ :gsub("__ARM_CLAIM__", function() return ARM_CLAIM end))
+end
+
+-- One parent gate and up to REGION_MAX region gates per possible claim,
+-- pooled per palette. MAX_SLOTS of each is the most a palette could ever
+-- need -- one claim per slot -- but that is 1 + REGION_MAX frames and twice
+-- as many wrapped scripts for every one of them, paid at login by palettes
+-- that nest nothing at all, which is most of them. So the pool grows to
+-- whatever PushPalette asks for and never shrinks: the snippets clear and
+-- re-show gates by index up to eapGateMax, which is the same high-water mark
+-- (see PushPalette), so a claim that stops nesting keeps a real gate to be
+-- cleared through for the rest of the session. Growth only ever appends --
+-- PushPalette repositions and re-shows or hides what is already there.
+local gatePools = {}
+
+local function EnsureGates(index, btn, need)
+ local pool = gatePools[index]
+ if not pool then
+ pool = { pgate = {}, rgate = {}, built = 0 }
+ gatePools[index] = pool
+
+ -- The FLOOR. One per palette, under every other gate and over
+ -- everything else, shown only while some claim is armed -- see
+ -- ARM_CLAIM, which shows it, and SNIPPET_POST, which puts it away with
+ -- the rest of them.
+ --
+ -- It exists because arming and disarming do not run off the same kind
+ -- of edge. A claim can be armed with the cursor standing still -- the
+ -- press branch's pre-arm, and LeaveSnippet's re-arm, both of which
+ -- measure the cursor against the pushed boxes rather than waiting for
+ -- an OnEnter that a gate shown under a still cursor never gets -- and
+ -- Blizzard's own wrapper raises "_wrapentered" only from inside a
+ -- MOTION OnEnter (SecureHandlers.lua, Wrapped_OnEnter), while its
+ -- OnLeave refuses to run a pre-body without that flag. So a
+ -- geometrically armed claim's parent gate has a dead OnLeave: the
+ -- cursor steps off the cell through ground no region rect covers, and
+ -- nothing disarms for the rest of the hold -- nest stuck open, block
+ -- stuck dim, every other claim's parent gate stuck hidden. The sandbox
+ -- cannot raise the flag itself either: RestrictedFrames' SetAttribute
+ -- rejects every name beginning with an underscore.
+ --
+ -- An OnENTER pre-body has no such precondition -- motion is all it
+ -- asks -- so the disarm is hung off entering the floor rather than
+ -- leaving the cell. Screen-wide, because the one thing it must never
+ -- do is leave a way off the ground that misses it; below the region
+ -- gates (level 10) and the parent gates (20), so every rect of a
+ -- claim's real ground still wins the cursor and the floor is only ever
+ -- reached where the ground is not. Motion only, never
+ -- SetMouseClickEnabled: clicks -- the secure activation path itself --
+ -- pass straight through it, exactly as they do through the gates it
+ -- sits under.
+ local fgate = CreateFrame("Frame", "EUIActionPaletteButton" .. index .. "FGate",
+ UIParent, "SecureHandlerEnterLeaveTemplate")
+ fgate:SetAllPoints(UIParent)
+ fgate:SetFrameStrata(LIVE_STRATA)
+ fgate:SetFrameLevel(5)
+ fgate:SetMouseClickEnabled(false)
+ fgate:SetMouseMotionEnabled(true)
+ fgate:Hide()
+
+ SecureHandlerSetFrameRef(fgate, "btn", btn)
+ SecureHandlerSetFrameRef(fgate, "ui", UIParent)
+ -- Both edges, for the same reason a region gate wraps both: the OnEnter
+ -- is the test that matters, and the OnLeave is what raising the flag on
+ -- that entry buys -- a screen-wide gate is only ever left for another
+ -- gate, and re-testing there costs one geometric measurement.
+ SecureHandlerWrapScript(fgate, "OnEnter", EnsureSecureHeader(), LeaveSnippet(nil))
+ SecureHandlerWrapScript(fgate, "OnLeave", EnsureSecureHeader(), LeaveSnippet(nil))
+ SecureHandlerSetFrameRef(btn, "fgate", fgate)
+ pool.fgate = fgate
+ end
+ if pool.built >= need then return pool end
+
+ for k = pool.built + 1, need do
+ local pgate = CreateFrame("Frame", "EUIActionPaletteButton" .. index .. "PGate" .. k,
+ UIParent, "SecureHandlerEnterLeaveTemplate")
+ pgate:SetFrameStrata(LIVE_STRATA)
+ pgate:SetFrameLevel(20)
+ pgate:SetMouseClickEnabled(false)
+ pgate:SetMouseMotionEnabled(true)
+ pgate:Hide()
+
+ SecureHandlerSetFrameRef(pgate, "btn", btn)
+ SecureHandlerSetFrameRef(pgate, "ui", UIParent)
+ SecureHandlerWrapScript(pgate, "OnEnter", EnsureSecureHeader(), EnterSnippet(k))
+ -- The parent gate's own rect is exactly the claim's own cell, and it
+ -- outranks every region gate of the same claim (level 20 against 10),
+ -- so wherever a region reaches over that cell -- all of it, as an
+ -- uncarved one does, or whatever part of it a carve left -- this gate
+ -- is the one with focus there, and a region gate is only ever the
+ -- topmost alongside or beyond the cell. Wherever a region does not
+ -- extend past the parent cell at all -- HALO skipping a ring
+ -- position a plain neighbour already occupies is the everyday case
+ -- of this -- leaving the parent cell in exactly that direction
+ -- leaves NO gate underneath at all, and the topmost-wins focus
+ -- model this depends on hands focus straight to nothing without
+ -- ever touching a region gate's own OnLeave. Wrapping this gate's
+ -- OnLeave with the identical true-ground re-test closes that gap:
+ -- every way OUT of the claim now runs the same check, whether the
+ -- last gate under the cursor was the parent's or one of its
+ -- regions'.
+ SecureHandlerWrapScript(pgate, "OnLeave", EnsureSecureHeader(), LeaveSnippet(k))
+
+ -- The button carries its own reference to every gate too, so the press
+ -- branch of SNIPPET_PRE -- which only knows claim indices and boxes,
+ -- never the frames themselves until it asks -- can place and size them.
+ SecureHandlerSetFrameRef(btn, "pgate" .. k, pgate)
+
+ pool.pgate[k] = pgate
+ pool.rgate[k] = {}
+ for r = 1, REGION_MAX do
+ local rgate = CreateFrame("Frame", "EUIActionPaletteButton" .. index .. "RGate" .. k .. "_" .. r,
+ UIParent, "SecureHandlerEnterLeaveTemplate")
+ rgate:SetFrameStrata(LIVE_STRATA)
+ rgate:SetFrameLevel(10)
+ rgate:SetMouseClickEnabled(false)
+ rgate:SetMouseMotionEnabled(true)
+ rgate:Hide()
+
+ SecureHandlerSetFrameRef(rgate, "btn", btn)
+ SecureHandlerSetFrameRef(rgate, "ui", UIParent)
+ -- OnEnter carries no test of its own -- LeaveSnippet is the whole
+ -- story for a region gate -- but it still has to be wrapped here,
+ -- empty body and all. SecureHandlers.lua's own OnEnter/OnLeave
+ -- wrapper only ever raises "_wrapentered" from INSIDE the OnEnter
+ -- wrap (Wrapped_OnEnter), and Wrapped_OnLeave refuses to run
+ -- LeaveSnippet at all unless that flag is already up. Leaving
+ -- this gate's OnEnter unwrapped left the flag permanently down,
+ -- so the disarm test below never ran even once -- a claim that
+ -- ever armed stayed armed for the rest of the hold, which is the
+ -- stuck-dim and stuck-nest both come from.
+ SecureHandlerWrapScript(rgate, "OnEnter", EnsureSecureHeader(), "")
+ SecureHandlerWrapScript(rgate, "OnLeave", EnsureSecureHeader(), LeaveSnippet(k))
+
+ SecureHandlerSetFrameRef(btn, "rgate" .. k .. "_" .. r, rgate)
+ pool.rgate[k][r] = rgate
+ end
+ pool.built = k
+ end
+ return pool
+end
+
+-- Declared up beside ns.Close, which is the only caller: the closes that never
+-- see a key-up -- the open timeout, a zone change -- have to do here everything
+-- SNIPPET_POST would have done at a release, or the hold's state outlives the
+-- palette. The gates are the part that shows: a parent gate left up is a
+-- mouse-motion rect parked over whatever was under the palette, taking hover
+-- and tooltips from it until that key is next pressed. (The floor gate heals
+-- itself once eapArmed is clear, since its own leave test then hides it, but
+-- only if the cursor happens to cross it.)
+--
+-- Everything here is a protected frame, so a close mid-fight may touch none of
+-- it. The palette is remembered instead and PLAYER_REGEN_ENABLED comes back
+-- for it. Clearing the accumulator matters as much as hiding the catcher: the
+-- strip opens with entry 1 seeded, so a key-up arriving after an unattended
+-- close would otherwise fire that entry with nothing on screen.
+function ReleaseSecureState(index)
+ if InCombatLockdown() then
+ -- 0 for a close with no palette of its own to put away -- the catcher
+ -- and the stamp still have to be retried.
+ secureCloseDirty = index or 0
+ return
+ end
+ secureCloseDirty = nil
+ if scrollCatcher then
+ scrollCatcher:SetAttribute("eapFanTarget", nil)
+ scrollCatcher:SetAttribute("eapOpen", nil)
+ scrollCatcher:Hide()
+ end
+ -- The screen is free again, so the next press of any key may take it. See
+ -- the ownership test in SNIPPET_PRE.
+ if cancelButton then cancelButton:SetAttribute("eapOwner", nil) end
+
+ local btn = index and secureButtons[index]
+ if btn then btn:SetAttribute("eapArmed", nil) end
+ local pool = index and gatePools[index]
+ if not pool then return end
+ if pool.fgate then pool.fgate:Hide() end
+ for k = 1, pool.built do
+ local pgate = pool.pgate[k]
+ if pgate then pgate:Hide() end
+ local rgates = pool.rgate[k]
+ for r = 1, REGION_MAX do
+ local rgate = rgates and rgates[r]
+ if rgate then rgate:Hide() end
+ end
+ end
+end
+
+-- Defined with the push coalescer it belongs to, further down; declared here
+-- because the press below has to be able to land a pending push before it
+-- opens anything.
+local FlushPendingPush
+
+-- Is the live view drawing the palette this button pushed? There is one view
+-- for every bound key, and only the button whose palette it is currently laid
+-- out on may read a cell out of it or close it.
+local function OwnsLiveView(self)
+ return liveView and liveView:GetPaletteIndex() == self._palette
+end
+
+local function OnPreClick(self, _, down)
+ if down then
+ -- Between an edit and the coalescer's timer the palette DRAWS the new
+ -- contents while the button would still fire the old ones. A press is
+ -- the moment that stops being tolerable, so it lands the push itself.
+ -- One boolean when nothing is pending, which is every press but the
+ -- one that follows an edit.
+ FlushPendingPush()
+ -- A second palette key pressed while the first is still HELD leaves the
+ -- screen to the one already on it. The two keys' secure buttons each
+ -- resolve their own release from their own pushed geometry, and there
+ -- is only one live view to draw either of them with: re-laying it onto
+ -- this palette would leave the held key steering a palette that is no
+ -- longer drawn, and its release reading its chosen cell out of this
+ -- one -- a different slot list, so a different pet, mount or spec than
+ -- the one under the cursor.
+ --
+ -- Both halves of the button refuse it, and both have to: this one runs
+ -- before the snippets and cannot stop them. Here the press that finds
+ -- the screen taken opens nothing, and its own release then fires
+ -- nothing insecure and closes nothing (see OnPostClick). In the
+ -- sandbox the press stamps eapOwner on the shared cancel button, and
+ -- SNIPPET_PRE turns away the press and the release of any button whose
+ -- eapPalette is not that owner -- eapWhy "taken", and no type written,
+ -- so nothing fires -- while SNIPPET_POST leaves on the same test
+ -- rather than hiding the catcher and dropping ESCAPE out from under
+ -- the hold still going. The held key keeps the palette it opened until
+ -- it is let go.
+ if liveView and liveView:GetFrame():IsShown() and not OwnsLiveView(self) then
+ return
+ end
+ ns.Open(self._palette)
+ return
+ end
+ -- Nothing to commit here any more: all three steering models are resolved
+ -- by the snippet, which is the only place allowed to write these attributes
+ -- once the player is in combat.
+end
+
+local function OnPostClick(self, _, down)
+ if down then return end
+ -- Some kinds have no secure action type at all and fire from here. WHICH
+ -- cell fires is the SNIPPET's answer, read back off the button, not the
+ -- selection the live view happens to be drawing: the two part company on
+ -- every cancel the snippet makes for itself. Escaping out of an open
+ -- palette leaves an entry selected on screen and fires nothing, and a pet
+ -- summoned out of a cancelled palette is the bug that reading the
+ -- selection here produced.
+ --
+ -- Two of the eapWhy steps mean "this cell was chosen": "fire", and
+ -- "emptyslot" -- which is exactly what a kind with no secure action type
+ -- looks like from inside the sandbox, ResolveAction having answered it
+ -- nothing. Every other value is a cancel. Reading an attribute is
+ -- unrestricted, so this works in combat as well as out.
+ --
+ -- The view has to be drawing THIS button's palette for either half of that
+ -- to mean anything. CellSlot maps the snippet's index through whichever
+ -- palette the view is laid out on, and a second key pressed during this
+ -- hold is refused the screen rather than allowed to move it (see
+ -- OnPreClick) -- so a release that does not own the view is the second
+ -- key's, and it neither fires a cell of somebody else's palette nor tears
+ -- down a palette its owner is still holding.
+ local why = self:GetAttribute("eapWhy")
+ if not OwnsLiveView(self) then return end
+ if why == "fire" or why == "emptyslot" then
+ local idx = tonumber(self:GetAttribute("eapIdx"))
+ local slot = liveView:CellSlot(idx)
+ FireInsecure(slot)
+ -- Only "fire" moved a cycle on: "emptyslot" is a kind the sandbox has
+ -- no action type for, and it never reached the snippet's step.
+ if why == "fire" then CyclePosBack(self, idx, slot) end
+ end
+ ns.Close()
+end
+
+local function GetSecureButton(index)
+ local btn = secureButtons[index]
+ if btn then return btn end
+
+ btn = CreateFrame("Button", "EUIActionPaletteButton" .. index, UIParent,
+ "SecureActionButtonTemplate")
+ btn._palette = index
+ -- The same number the sandbox can read: the ownership test in SNIPPET_PRE
+ -- and SNIPPET_POST needs to know which palette it is running for, and a
+ -- plain field is invisible from inside the restricted environment. Written
+ -- here, where the button is built, which is out of combat by construction
+ -- -- UpdateBindings, the only caller, defers the whole rebind to
+ -- PLAYER_REGEN_ENABLED while the player is fighting.
+ btn:SetAttribute("eapPalette", index)
+ btn:RegisterForClicks("AnyDown", "AnyUp")
+
+ -- SecureActionButton_OnClick performs the action on exactly one edge
+ -- (SecureTemplates.lua:786-793):
+ --
+ -- clickAction = (down and useOnKeyDown) or (not down and not useOnKeyDown)
+ --
+ -- Left unset, useOnKeyDown follows the ActionButtonUseKeyDown CVar, which
+ -- is on by default -- so the DOWN edge would be the acting one. DOWN is
+ -- where we open the palette and clear "type", so it fires nothing, and UP is
+ -- then skipped entirely: PreClick and PostClick still run, so the palette
+ -- opens and closes normally while no action is ever performed. Pinning the
+ -- attribute keeps the acting edge on UP whatever the CVar says.
+ btn:SetAttribute("useOnKeyDown", false)
+ -- Parked off-screen and invisible, but shown: an override-binding click
+ -- has to reach a live button, and the suite's click-cast proxies use the
+ -- same shape (EUI_RaidFrames_ClickCast.lua).
+ btn:EnableMouse(false)
+ btn:SetSize(1, 1)
+ btn:SetAlpha(0)
+ btn:SetPoint("TOPLEFT", UIParent, "TOPLEFT", -300 - index * 4, 100)
+ btn:Show()
+ btn:SetScript("PreClick", OnPreClick)
+ btn:SetScript("PostClick", OnPostClick)
+
+ -- The snippet measures the cursor against the palette, so it needs a handle to
+ -- it. Wrapped around OnClick rather than PreClick: PreClick is ours, and the
+ -- wrap has to run inside the very click that goes on to fire the action.
+ SecureHandlerSetFrameRef(btn, "ui", UIParent)
+ SecureHandlerSetFrameRef(btn, "catcher", EnsureScrollCatcher())
+ SecureHandlerSetFrameRef(btn, "cancel", EnsureCancelButton())
+ SecureHandlerWrapScript(btn, "OnClick", EnsureSecureHeader(),
+ SNIPPET_PRE, SNIPPET_POST)
+
+ secureButtons[index] = btn
+ return btn
+end
+
+-- Hand the sandbox everything it needs to choose a entry. Out of combat only:
+-- these are ordinary insecure writes to a protected frame, which is precisely
+-- what combat forbids. A palette edited mid-fight keeps firing its previous
+-- contents until the fight ends -- the same bargain the override bindings make.
+-- How many cells each button was last given, so a palette that loses a nest
+-- clears the entries that nest used to occupy.
+local pushedCells = {}
+
+-- The most claims this button has ever been pushed, per palette. Never
+-- shrinks: see the eapGateMax write below.
+local pushedClaims = {}
+
+-- One cell's action. Both the palette's own entries and the cells its nests
+-- contribute are pushed through here, under the cell index the snippet will
+-- resolve a release to -- which is what lets the snippet fire either without
+-- knowing which of the two it landed on.
+local function PushCell(btn, i, slot)
+ local aType, aKey, aVal = ResolveAction(slot)
+ btn:SetAttribute("eapT" .. i, aType)
+ btn:SetAttribute("eapK" .. i, aKey)
+ btn:SetAttribute("eapV" .. i, aVal)
+ -- A palette resolves to no action, same as an empty slot. Marked so the
+ -- trace can tell "you stopped on the door" from "that slot is empty".
+ btn:SetAttribute("eapPal" .. i, ChildIndex(slot) and true or nil)
+
+ -- A cycling entry's whole run, one step per attribute, plus the position it
+ -- is up to. Written out rather than parsed in the snippet: the marker order
+ -- and the engine's numbering already live up in the slot model, and the
+ -- restricted environment is the last place to restate either of them.
+ --
+ -- eapCycN is what marks the cell as cycling, so a cell that has stopped
+ -- being one has to lose it -- and the steps under it, which are read by
+ -- position and would otherwise outlive a shorter run.
+ local steps = CycleSteps(slot and slot.kind)
+ local had = tonumber(btn:GetAttribute("eapCycN" .. i)) or 0
+ for s = 1, max(had, steps and #steps or 0) do
+ btn:SetAttribute("eapCycV" .. i .. "_" .. s, steps and steps[s] or nil)
+ end
+ btn:SetAttribute("eapCycN" .. i, steps and #steps or nil)
+ btn:SetAttribute("eapCycPos" .. i, steps and tonumber(slot.cyclePos) or nil)
+end
+
+local function PushPalette(index)
+ if InCombatLockdown() then return end
+ local p = PA(index)
+ local btn = secureButtons[index]
+ local palette = EnsurePalette(index)
+ if not p or not btn or not palette then return end
+
+ -- Every measurement below is the live view's, and a keybound palette is
+ -- pushed long before it is ever opened, so the view has to exist by here
+ -- rather than by the first Open. Past the button guard above, so a module
+ -- switched off -- which registers no bindings and therefore builds no
+ -- buttons -- still builds no frames at all.
+ CreateLiveView()
+
+ -- The view is laid out for whatever was last DRAWN, which on a push over
+ -- every bound palette in turn is almost never this one -- and appearance
+ -- is per palette, so every measurement it makes below would otherwise be
+ -- taken against some other palette's layout. appIndex points its own
+ -- profile accessor at this palette for the length of the push. There is no
+ -- early return past here; the clear at the bottom is unconditional.
+ liveView.appIndex = index
+
+ for i = 1, MAX_SLOTS do
+ PushCell(btn, i, palette.slots[i])
+ end
+
+ -- The live palette draws exactly what the palette holds -- the trailing "+"
+ -- entry is the editor's -- so #slots is the count the snippet divides by, and
+ -- ArcGeom is asked for the geometry rather than the snippet re-deriving it.
+ local n = #palette.slots
+ local step, arcStart, full = liveView:ArcGeom(n)
+ local _, _, deadZone = liveView:Geom()
+ -- Where the palette's centre will be, so the snippet can work in UIParent
+ -- units without a handle to the palette itself. Cursor mode has no fixed
+ -- centre, so the snippet takes the opening cursor position instead.
+ btn:SetAttribute("eapFixed", p.centerMode == "SCREEN")
+ btn:SetAttribute("eapPosX", p.posX or 0)
+ btn:SetAttribute("eapPosY", p.posY or 0)
+ btn:SetAttribute("eapScale", p.scale or 1)
+
+ local model = LayoutModel(index)
+ btn:SetAttribute("eapMode", model)
+ btn:SetAttribute("eapShown", n)
+ btn:SetAttribute("eapInvert", p.fanInvert == true)
+
+ -- Pointer layouts: the cell centres, worked out here rather than in the
+ -- snippet. GridDims and GridBase already encode the auto-column rule and the
+ -- short-final-row centring, and re-deriving either in the sandbox would give
+ -- the palette a second, drifting copy of the layout -- the same mistake the
+ -- angular path avoids by pushing ArcGeom's answer.
+ if model == "POINTER" then
+ local _, iconSize = liveView:Geom()
+ local pitch = iconSize + (p.fanGap or 10)
+ local cols, rows = liveView:GridDims(n)
+ for i = 1, MAX_SLOTS do
+ if i <= n then
+ local bx, by = liveView:GridBase(i, cols, rows, pitch, n)
+ btn:SetAttribute("eapBX" .. i, bx)
+ btn:SetAttribute("eapBY" .. i, by)
+ else
+ btn:SetAttribute("eapBX" .. i, nil)
+ btn:SetAttribute("eapBY" .. i, nil)
+ end
+ -- A half-extent is what marks a cell as a nest, and the nests are
+ -- written after this. Cleared over the palette's OWN range too: a
+ -- longer set of nests last time would otherwise leave half-extents
+ -- on indices that are now ordinary entries, and those entries would
+ -- answer to containment instead of taking their turn at nearness.
+ btn:SetAttribute("eapHW" .. i, nil)
+ btn:SetAttribute("eapHH" .. i, nil)
+ end
+ btn:SetAttribute("eapPitch", pitch)
+ btn:SetAttribute("eapReach", GRID_REACH)
+ end
+
+ -- The scroll fan's cancel box: a margin across, the drawn strip plus that
+ -- same margin along, and the axis it runs on. Three numbers rather than the
+ -- pointer layouts' table of cells, the strip having only one axis to steer.
+ if model == "SCROLL" then
+ local _, iconSize = liveView:Geom()
+ btn:SetAttribute("eapFanMargin",
+ FAN_CANCEL_REACH * (iconSize + (p.fanGap or 10)))
+ btn:SetAttribute("eapFanHalf", liveView:FanHalfLength())
+ btn:SetAttribute("eapFanHoriz", liveView:FanHoriz())
+ end
+ btn:SetAttribute("eapDeadZone", deadZone)
+ -- Degrees, not the radians ArcGeom deals in. The sandbox whitelists WoW's
+ -- GLOBAL atan2 (RestrictedEnvironment.lua:60), which answers in DEGREES --
+ -- where HitTest upvalues math.atan2, which answers in radians. Treating the
+ -- sandbox's as radians silently rotated every selection: a release aimed at
+ -- one entry fired its neighbour, and a release near the arc's edge missed
+ -- entirely. Converting here keeps the one conversion in Lua, where the unit
+ -- is named, and lets the snippet wrap on an exact 360.
+ btn:SetAttribute("eapStepDeg", step * 180 / pi)
+ btn:SetAttribute("eapStartDeg", arcStart * 180 / pi)
+ btn:SetAttribute("eapFull", full)
+
+ -- Nested entries. They are appended to the SAME action table the palette's
+ -- own entries use, starting past the last of them, so the firing end of the
+ -- snippet needs no idea that nesting exists: a child is a cell with a higher
+ -- index. Only the claim geometry that maps an angle onto one of those
+ -- indices is new.
+ --
+ -- The loop above has already cleared indices n+1 .. MAX_SLOTS, which is
+ -- where these land, so the writes must come after it.
+ local claims = liveView:ChildGeom(n, palette)
+
+ -- How far every claim-indexed loop below, and every snippet loop that
+ -- reads eapGateMax, runs. MAX_SLOTS is what a palette could hold; this is
+ -- what one has actually held at some point this session, and a palette
+ -- that nests nothing keeps it at zero -- which is the common case and the
+ -- difference between a few hundred attribute writes per push and none.
+ --
+ -- MONOTONIC, and that is the whole of its correctness. Every snippet that
+ -- clears, hides or re-shows gates walks 1..eapGateMax, so an index that
+ -- was ever pushed a box or a gate for has to stay inside the bound for the
+ -- rest of the session; the loops below then nil that index's attributes
+ -- and the press branch clears its gate's points, exactly as they did when
+ -- the bound was MAX_SLOTS. Lowering it to today's claim count instead
+ -- would strand a live gate at yesterday's rect with nothing left to clear
+ -- it.
+ local gateMax = max(pushedClaims[index] or 0, claims and #claims or 0)
+ pushedClaims[index] = gateMax
+ -- The arming gates. Built out of combat like everything else here, grown
+ -- to the same mark, and reused and merely repositioned afterwards. See the
+ -- "Arming gates" section above GetSecureButton for what they are for.
+ EnsureGates(index, btn, gateMax)
+ btn:SetAttribute("eapGateMax", gateMax)
+
+ local total = n
+ for k = 1, (claims and #claims or 0) do
+ local c = claims[k]
+ c.base = total
+ for j = 1, c.n do
+ total = total + 1
+ PushCell(btn, total, c.slots[j])
+ -- A block layout's nests carry a BOX. Half-extents are what tells
+ -- the snippet these cells are tested by containment rather than by
+ -- nearness -- the palette's own entries have no half-extents, and
+ -- fall to the nearest-cell search below.
+ if c.cells then
+ local b = c.cells[j]
+ btn:SetAttribute("eapBX" .. total, b.x)
+ btn:SetAttribute("eapBY" .. total, b.y)
+ btn:SetAttribute("eapHW" .. total, b.hw)
+ btn:SetAttribute("eapHH" .. total, b.hh)
+ end
+ end
+ end
+ -- Whatever a longer set of nests left behind last time. Bounded by what was
+ -- actually written rather than by the theoretical maximum, so an ordinary
+ -- palette does not pay a hundred attribute writes on every options tick.
+ for i = max(total, MAX_SLOTS) + 1, (pushedCells[index] or 0) do
+ -- nil for the slot, which is also how PushCell clears a cycle's steps.
+ PushCell(btn, i, nil)
+ btn:SetAttribute("eapBX" .. i, nil)
+ btn:SetAttribute("eapBY" .. i, nil)
+ btn:SetAttribute("eapHW" .. i, nil)
+ btn:SetAttribute("eapHH" .. i, nil)
+ end
+ pushedCells[index] = total
+ btn:SetAttribute("eapTotal", total)
+
+ -- One claim-index -> cell-range mapping, the parent's own arming box, and
+ -- up to REGION_MAX region boxes, for every possible claim slot -- cleared
+ -- past #claims the same way the gates themselves get cleared, so a claim
+ -- that stopped nesting cannot leave its gate armable over ground that no
+ -- longer holds anything. Keyed by CLAIM INDEX rather than by parent slot,
+ -- the same index eapCBand and friends already use below, so eapArmed
+ -- means one thing everywhere it is read regardless of layout.
+ for k = 1, gateMax do
+ local c = claims and claims[k]
+ btn:SetAttribute("eapGBase" .. k, c and c.base)
+ btn:SetAttribute("eapGNum" .. k, c and c.n)
+ local pb = c and c.parentBox
+ btn:SetAttribute("eapPOX" .. k, pb and pb.x)
+ btn:SetAttribute("eapPOY" .. k, pb and pb.y)
+ btn:SetAttribute("eapPOHW" .. k, pb and pb.hw)
+ btn:SetAttribute("eapPOHH" .. k, pb and pb.hh)
+ for r = 1, REGION_MAX do
+ local rb = c and c.regions and c.regions[r]
+ btn:SetAttribute("eapROX" .. k .. "_" .. r, rb and rb.x)
+ btn:SetAttribute("eapROY" .. k .. "_" .. r, rb and rb.y)
+ btn:SetAttribute("eapROHW" .. k .. "_" .. r, rb and rb.hw)
+ btn:SetAttribute("eapROHH" .. k .. "_" .. r, rb and rb.hh)
+ end
+ end
+
+ -- A scroll-steered strip reaches its nests through the entry the WHEEL is
+ -- on, not through the cursor: the wheel says which nest, and the cursor only
+ -- says which of its children. One lookup per entry that nests, so the
+ -- snippet goes straight from the wheel's answer to that nest's boxes.
+ if model == "SCROLL" then
+ for i = 1, MAX_SLOTS do
+ btn:SetAttribute("eapNBase" .. i, nil)
+ btn:SetAttribute("eapNNum" .. i, nil)
+ btn:SetAttribute("eapNAcross" .. i, nil)
+ btn:SetAttribute("eapNSide" .. i, nil)
+ end
+ for k = 1, (claims and #claims or 0) do
+ local c = claims[k]
+ btn:SetAttribute("eapNBase" .. c.parent, c.base)
+ btn:SetAttribute("eapNNum" .. c.parent, c.n)
+ btn:SetAttribute("eapNAcross" .. c.parent, c.across)
+ btn:SetAttribute("eapNSide" .. c.parent, c.sign)
+ end
+ end
+
+ -- One ANGULAR claim per slot that opens a palette, and one RING per claim
+ -- past MAX_CHILD_ROWS never happens (ChildGeom caps there too), so every
+ -- claim's rings fit in this fixed span of attributes. Angles in degrees,
+ -- and a ring's start is the EDGE of its first child's sector rather than
+ -- its centre, so the snippet's test is a plain division with no half-step
+ -- to remember.
+ --
+ -- A block layout's nests need none of this: they were pushed as ordinary
+ -- cells above, and the nearest-cell search finds them without being told
+ -- that they are nests at all.
+ local angular = (model == "ANGULAR") and claims or nil
+ for k = 1, gateMax do
+ local c = angular and angular[k]
+ btn:SetAttribute("eapCBand" .. k, c and c.band)
+ btn:SetAttribute("eapCRows" .. k, c and #c.rows)
+ -- The claim's own ground -- the beam and the wedge ChildGeom sized --
+ -- for the DISARM test only. LeaveSnippet asks "is the cursor still on
+ -- this claim" and never walks the rings the release below does: the
+ -- ground has to include the ring's own radius and the sides of the
+ -- parent's icon, which answer to no ring at all, and while they
+ -- belonged to nothing every reach for a child disarmed the claim a few
+ -- units into itself.
+ local g = c and c.ground
+ btn:SetAttribute("eapCAngle" .. k, g and ((c.angle * 180 / pi) % 360))
+ btn:SetAttribute("eapCAX" .. k, g and g.ax)
+ btn:SetAttribute("eapCAY" .. k, g and g.ay)
+ btn:SetAttribute("eapCLo" .. k, g and g.lo)
+ btn:SetAttribute("eapCEdge" .. k, g and g.edge)
+ btn:SetAttribute("eapCBeam" .. k, g and g.beam)
+ btn:SetAttribute("eapCSlope" .. k, g and g.slope)
+ btn:SetAttribute("eapCWedge" .. k, g and (g.half * 180 / pi))
+ for r = 1, MAX_CHILD_ROWS do
+ local row = c and c.rows[r]
+ local tag = "eapCR" .. k .. "_" .. r
+ btn:SetAttribute(tag .. "Lo", row and row.lo)
+ btn:SetAttribute(tag .. "Hi", row and row.hi)
+ btn:SetAttribute(tag .. "N", row and row.n)
+ -- Absolute: the cell index this ring's first child lands on, so the
+ -- snippet adds nothing but the local offset it works out itself.
+ btn:SetAttribute(tag .. "Base", row and (c.base + row.base))
+ btn:SetAttribute(tag .. "StepDeg", row and (row.step * 180 / pi))
+ btn:SetAttribute(tag .. "StartDeg",
+ row and ((((row.start - row.step * 0.5) * 180 / pi) % 360)))
+ end
+ end
+ btn:SetAttribute("eapClaims", angular and #angular or 0)
+
+ -- Back to whatever the view is actually drawing.
+ liveView.appIndex = nil
+end
+
+-- Raised whenever a push was wanted and combat refused it, cleared by the push
+-- that finally lands. PLAYER_REGEN_ENABLED reads it rather than pushing
+-- unconditionally, the same bargain bindingsDirty makes below.
+local pushDirty = false
+
+-- Bound palettes only: nested ones have no button of their own, and their
+-- entries are pushed as part of whichever palette nests them.
+local function PushAllPalettes()
+ if InCombatLockdown() then
+ pushDirty = true
+ return
+ end
+ pushDirty = false
+ for i = 1, PaletteCount() do PushPalette(i) end
+end
+
+-- A push is on the order of a thousand attribute writes per bound palette, and
+-- the options panel reaches Refresh on every slider tick -- so a drag would pay
+-- for one per frame while the sandbox only ever reads the last of them. One
+-- deferred push serves the whole drag: the first request in a quiet window
+-- schedules the run, and every request inside that window folds into it.
+--
+-- Only the pushed geometry is deferred. UpdateBindings and the redraw of any
+-- view on screen stay where Refresh calls them, so the preview still tracks the
+-- slider live.
+local PUSH_DELAY = 0.15
+local pushQueued = false
+
+local function RequestPush()
+ -- Nothing to schedule: the writes are refused for as long as the fight
+ -- lasts, and PLAYER_REGEN_ENABLED is what picks this up.
+ if InCombatLockdown() then
+ pushDirty = true
+ return
+ end
+ if pushQueued then return end
+ pushQueued = true
+ C_Timer.After(PUSH_DELAY, function()
+ -- A press already landed this one. The timer is left to run into the
+ -- lowered flag rather than cancelled: that costs one comparison and
+ -- keeps no timer handle anywhere for a later request to have to
+ -- reason about.
+ if not pushQueued then return end
+ pushQueued = false
+ PushAllPalettes()
+ end)
+end
+
+-- Land a pending push NOW rather than at the end of its window. Called by the
+-- press, so a key can never fire geometry the palette has stopped drawing --
+-- and so an edit followed straight into a fight cannot strand the old actions
+-- for the whole of it, which waiting out the window could.
+--
+-- In combat PushAllPalettes refuses as it always has and raises pushDirty
+-- instead, so the pending state is handed to PLAYER_REGEN_ENABLED rather than
+-- dropped.
+FlushPendingPush = function()
+ if not pushQueued then return end
+ pushQueued = false
+ PushAllPalettes()
+end
+
+local bindingsDirty = false
+local bindingSig = nil
+
+-- ClearOverrideBindings / SetOverrideBindingClick are protected, so a combat
+-- refresh is deferred to PLAYER_REGEN_ENABLED. Nothing is lost by waiting:
+-- the bindings already in place keep working until then.
+--
+-- The signature guard is required for correctness, not an optimisation.
+-- Registering an override binding itself fires UPDATE_BINDINGS, and
+-- UPDATE_BINDINGS is what brings us here -- so an unconditional rewrite feeds
+-- itself forever. Action Bars hit exactly this and solved it the same way (see
+-- the note at EllesmereUIActionBars.lua:10486). It also makes the call free for
+-- the options panel, which reaches Refresh on every slider tick.
+function ns.UpdateBindings()
+ local p = P()
+ if not p then return end
+
+ local sig = p.enabled and "on" or "off"
+ local count = PaletteCount()
+ for i = 1, count do
+ local k1, k2 = GetBindingKey(BINDING_PREFIX .. i)
+ sig = sig .. "|" .. (k1 or "") .. "/" .. (k2 or "")
+ end
+ if sig == bindingSig then return end
+
+ if InCombatLockdown() then
+ bindingsDirty = true
+ return
+ end
+ bindingsDirty = false
+ bindingSig = sig
+
+ -- No owner means nothing has ever been bound through one, so there is
+ -- nothing to clear -- and building one anyway is the single frame that
+ -- would keep a never-enabled session from costing nothing at all.
+ if bindOwner then ClearOverrideBindings(bindOwner) end
+ if not p.enabled then return end
+ if not bindOwner then bindOwner = CreateFrame("Frame") end
+
+ -- A button is built only for a palette that has a key, so a profile that
+ -- binds two of its sixteen pays for two. PushPalette skips an index with no
+ -- button, so the ones left unbound cost no attribute writes either -- their
+ -- entries still reach the sandbox through whichever palette nests them.
+ local built = false
+ for i = 1, count do
+ local k1, k2 = GetBindingKey(BINDING_PREFIX .. i)
+ if k1 or k2 then
+ built = built or not secureButtons[i]
+ local name = GetSecureButton(i):GetName()
+ if k1 then SetOverrideBindingClick(bindOwner, false, k1, name) end
+ if k2 then SetOverrideBindingClick(bindOwner, false, k2, name) end
+ end
+ end
+
+ -- A button built just now holds none of its palette's geometry yet, and the
+ -- binding change that built it is not itself a reason for anything else to
+ -- ask for a push. Without this, the first hold on a freshly bound key would
+ -- open an empty palette.
+ if built then RequestPush() end
+end
+
+-------------------------------------------------------------------------------
+-- Events
+--
+-- Registered only while the module is switched ON. A session that never
+-- enables it dispatches nothing: UPDATE_BINDINGS alone fires on every keybind
+-- save and on every override binding registered anywhere in the UI.
+--
+-- The switch is followed rather than read once at load, so switching the
+-- module on mid-session brings the three handlers up with it -- ns.Refresh is
+-- what the enable checkbox, a profile switch and a spec switch all reach, and
+-- it is where the transition is noticed.
+-------------------------------------------------------------------------------
+local eventsOn = false
+-- Declared ahead of the handlers, which reach both of them once the work a
+-- fight deferred has been paid off.
+local EventsWanted, SetEventsEnabled
+
+local function OnUpdateBindings()
+ ns.UpdateBindings()
+end
+
+local function OnRegenEnabled()
+ if bindingsDirty then ns.UpdateBindings() end
+ -- Only when the fight actually refused one: a palette edited during it
+ -- was skipped by PushPalette and the sandbox is still holding the old
+ -- contents, but a fight nobody edited anything through needs nothing.
+ if pushDirty then PushAllPalettes() end
+ -- A palette that closed unattended mid-fight could not give ESCAPE
+ -- back at the time. Now it can. No live view means none was ever
+ -- opened, which is still a reason to try: the binding belongs to the
+ -- cancel button rather than to the view.
+ local idle = not liveView or not liveView:GetFrame():IsShown()
+ if idle then ReleaseEscape() end
+ -- Same story for the catcher, the ownership stamp and the arming
+ -- gates: a close the fight refused left them exactly as the hold had
+ -- them, and a shown catcher goes on eating camera zoom until this runs.
+ -- Only with nothing on screen: a key held as the fight ends owns all
+ -- of that state, and tearing it down under the hold would kill its
+ -- steering. The flag stands until then, and the next unattended close
+ -- out of combat does the work anyway.
+ if secureCloseDirty and idle then
+ local index = secureCloseDirty
+ secureCloseDirty = nil
+ ReleaseSecureState(index ~= 0 and index or nil)
+ end
+ -- A disable that landed mid-fight left this handler standing precisely so
+ -- the work above could happen; now that it has, the handlers may go.
+ SetEventsEnabled(EventsWanted())
+end
+
+-- A zone change while the key is held (portals, taxi) can swallow the
+-- key-up; drop the palette rather than leave it stuck.
+local function OnEnteringWorld()
+ ns.Close()
+end
+
+-- Switched off, the module wants none of these -- except while something is
+-- still owed to PLAYER_REGEN_ENABLED. Every one of those debts is work a fight
+-- refused: override bindings that could not be cleared, a push that could not
+-- land, a palette the fight left on screen. Dropping the handler that pays them
+-- would strand the bindings live for the rest of the session.
+function EventsWanted()
+ local p = P()
+ if p and p.enabled then return true end
+ return bindingsDirty or pushDirty or secureCloseDirty ~= nil
+end
+
+function SetEventsEnabled(on)
+ on = on and true or false
+ if on == eventsOn then return end
+ eventsOn = on
+ if on then
+ EAP:RegisterEvent("UPDATE_BINDINGS", OnUpdateBindings)
+ EAP:RegisterEvent("PLAYER_REGEN_ENABLED", OnRegenEnabled)
+ EAP:RegisterEvent("PLAYER_ENTERING_WORLD", OnEnteringWorld)
+ else
+ EAP:UnregisterEvent("UPDATE_BINDINGS")
+ EAP:UnregisterEvent("PLAYER_REGEN_ENABLED")
+ EAP:UnregisterEvent("PLAYER_ENTERING_WORLD")
+ end
+end
+
+-- Re-read everything from the DB. Safe to call at any time; only redraws views
+-- that are actually on screen.
+function ns.Refresh()
+ -- Ahead of everything that reads the profile: a profile imported from a
+ -- pre-rename build carries its palettes under the dead key until this
+ -- runs, and applying such a profile is exactly what reaches here.
+ MigrateActiveProfile()
+
+ ns.UpdateBindings()
+ SetEventsEnabled(EventsWanted())
+
+ -- After UpdateBindings, which is what a DISABLE has to reach to take the
+ -- override bindings back down, and before anything that costs something:
+ -- switched off, this module draws nothing, pushes nothing and schedules
+ -- nothing at all.
+ local p = P()
+ if not p or not p.enabled then return end
+
+ RefreshFonts()
+ RequestPush()
+
+ if liveView and liveView:GetFrame():IsShown() then
+ -- Read the selection before Layout, which clears it.
+ local keep = liveView:GetSelection()
+ liveView:Layout(liveView:GetPaletteIndex())
+ local n = liveView:SlotCount()
+ liveView:SetSelection(keep and n > 0 and min(keep, n) or nil)
+ end
+
+ -- Non-live views (the options preview) follow the same data, so a slider
+ -- tick or a slot mutation has to repaint them too. IsVisible, not IsShown:
+ -- the options page's wrapper is torn down and re-parented around them.
+ for i = 1, #views do
+ local v = views[i]
+ if v ~= liveView and v:GetFrame():IsVisible() then
+ v:Layout(v:GetPaletteIndex())
+ end
+ end
+end
+
+-- Options-panel entry point, matching the suite's _G.__ convention.
+_G._EAP_Apply = ns.Refresh
+
+-------------------------------------------------------------------------------
+-- Lifecycle
+-------------------------------------------------------------------------------
+-- This module was called Radial Wheel. Its data has always lived in the
+-- suite's central store, under the addons key NewDB derives from the saved
+-- variable name -- so the rename moved the module's home from
+-- addons.EllesmereUIRadialWheel to addons.EllesmereUIActionPalette and left
+-- every configured palette behind under the old key, invisible to the module
+-- and to profile export alike. Move each profile's blob to the new key HERE,
+-- before NewDB runs. A moved blob still carries pre-rename field names
+-- (ringCount, rings); P() converts those on the profile's first touch, so
+-- the raw move is enough. Current data wins: a profile that already holds
+-- palette settings under the new key keeps them. One blind spot: logout
+-- StripDefaults leaves an all-defaults profile as an EMPTY table, which reads
+-- as "no data" here, so such a profile takes the legacy blob -- once, since
+-- the old key is removed either way. That cleanup is what makes this run
+-- once, and what stops the orphan riding along in every profile forever.
+local function MigrateLegacySV()
+ -- The old global is vestigial, like every child SV: wiped in place so
+ -- WoW's serializer, which holds the table reference from load time,
+ -- writes it out empty. NewDB does the same for the new name.
+ if type(_G.EllesmereUIRadialWheelDB) == "table" then
+ wipe(_G.EllesmereUIRadialWheelDB)
+ end
+
+ local profiles = EllesmereUIDB and EllesmereUIDB.profiles
+ if type(profiles) ~= "table" then return end
+ -- No live table to pour into at this point: NewDB has not run, so nothing
+ -- holds a reference to any of these yet and the key may be repointed.
+ for _, prof in pairs(profiles) do MigrateLegacyProfile(prof) end
+end
+
+function EAP:OnInitialize()
+ MigrateLegacySV()
+ db = EllesmereUI.Lite.NewDB("EllesmereUIActionPaletteDB", DB_DEFAULTS)
+ -- The profile itself is converted by P(), on first touch, so that switching
+ -- profile mid-session converts the incoming one too. See MigrateNames.
+ _G._EAP_AceDB = db
+ ns.db = db
+
+ _G.BINDING_HEADER_EUI_RADIAL = "EllesmereUI Action Palette"
+ for i = 1, MAX_PALETTES do
+ _G["BINDING_NAME_" .. BINDING_PREFIX .. i] = "Open Action Palette " .. i
+ end
+end
+
+function EAP:OnEnable()
+ local p = P()
+ if not p then return end
+
+ -- Switched off, this is the whole of what the module does for the session.
+ -- The three handlers, the secure buttons, the scroll catcher and the arming
+ -- gates are all brought up by ns.Refresh instead, which is what the enable
+ -- checkbox, a profile switch and a spec switch all reach -- so switching the
+ -- module on mid-session still gets combat-deferred rebinding and
+ -- stuck-palette cleanup, without a session that never enables it paying for
+ -- any of them. All it holds is the one empty frame the main chunk makes.
+ if not p.enabled then return end
+
+ for i = 1, PaletteCount() do EnsurePalette(i) end
+ ns.UpdateBindings()
+ PushAllPalettes()
+ SetEventsEnabled(true)
+end
+
+-------------------------------------------------------------------------------
+-- Slash command
+-------------------------------------------------------------------------------
+-- Palettes are built in the options page's own preview now, so there is nothing
+-- left for the command to toggle -- it just points the way.
+-- The two arc-era commands stay registered as aliases: they are muscle
+-- memory by now, and a slash command that silently stops existing after a
+-- rename reads as the module having been removed.
+_G.SLASH_EUIACTIONPALETTE1 = "/euiap"
+_G.SLASH_EUIACTIONPALETTE2 = "/euipalette"
+_G.SLASH_EUIACTIONPALETTE3 = "/euirw"
+_G.SLASH_EUIACTIONPALETTE4 = "/euiradial"
+-- "/euiap trace" reports what the snippet decided on the last release. The
+-- snippet cannot print -- there is no output in the restricted environment --
+-- so it leaves its reasoning in attributes, which Lua may read at any time,
+-- combat included. eapWhy is the step it stopped at:
+--
+-- pressed the release never ran at all
+-- taken the ownership stamp on the cancel button was not this key's:
+-- either another palette was still held, or ESCAPE closed this
+-- one out of combat, since that close clears the stamp while
+-- the key is still down
+-- escaped ESCAPE was pressed while the palette was open AND the close
+-- it asked for was deferred to the end of a fight, so the stamp
+-- was still standing when the key came up
+-- unscrolled a scroll fan whose accumulator was never seeded by the press
+-- thrownclear a scroll fan whose pointer was carried clear of the strip
+-- nocatcher a scroll fan with no scroll catcher reachable
+-- noslots the palette was pushed as empty
+-- nohandle no UIParent handle
+-- offscreen GetMousePosition returned nil
+-- unmoved the cursor never left the opening point
+-- noorigin cursor mode with no captured origin
+-- deadzone inside the dead zone
+-- noidx an angle outside the arc
+-- outofreach pointer layouts: further than eapReach cells from every entry
+-- palette stopped on an entry that OPENS a palette rather than going
+-- through it into one of the entries beyond
+-- emptyslot that entry has no action pushed
+-- fire attributes were written; anything wrong past here is Blizzard's
+-- side of the click
+SlashCmdList.EUIACTIONPALETTE = function(msg)
+ -- "/euiap gates" reports the arming gates' own transcript -- eapArmed as
+ -- it stands right now, and eapGTrace, the record ARM_CLAIM and
+ -- LeaveSnippet append to on every arm and every real gate crossing this
+ -- hold. One entry per way the answer can change:
+ --
+ -- E claim k armed by its parent gate's own OnEnter
+ -- P claim k armed geometrically at the press, the cursor
+ -- already standing on its entry when the palette opened
+ -- R claim k armed geometrically on the way out of another
+ -- claim, the cursor having landed on k's entry
+ -- L:in claim k's leave test ran and found the cursor still on
+ -- its ground, so nothing changed
+ -- L:out claim k's leave test disarmed it
+ --
+ -- Bounded to the last 160
+ -- characters on the button itself, so this is reading exactly what the
+ -- gates did rather than a guess reconstructed after the fact -- the
+ -- thing to run after a nest misbehaves in a way the offline harness
+ -- cannot reproduce.
+ --
+ -- The transcript is not kept unless it has been asked for: the appends sit
+ -- on a mouse-motion path, so this command is also the switch that turns
+ -- them on ("/euiap gates off" turns them back off), and the first run
+ -- reports the hold BEFORE it -- which recorded nothing. Setting the flag is
+ -- an insecure write to a protected button, so it waits for the fight to
+ -- end like every other push does.
+ if type(msg) == "string" and msg:lower():find("gates") then
+ local want = not msg:lower():find("off")
+ if InCombatLockdown() then
+ EllesmereUI.Print("|cff0cd29fAction Palette:|r gate tracing cannot be "
+ .. "switched in combat -- run this again once the fight ends.")
+ else
+ for i = 1, PaletteCount() do
+ local btn = secureButtons[i]
+ if btn then btn:SetAttribute("eapGDebug", want or nil) end
+ end
+ EllesmereUI.Print(("|cff0cd29fAction Palette:|r gate tracing %s."):format(
+ want and "on -- hold a palette, then run this again" or "off"))
+ end
+ for i = 1, PaletteCount() do
+ local btn = secureButtons[i]
+ if btn then
+ EllesmereUI.Print(("|cff0cd29fPalette %d|r armed=%s"):format(
+ i, tostring(btn:GetAttribute("eapArmed"))))
+ EllesmereUI.Print(" " .. (btn:GetAttribute("eapGTrace") or "(no gate crossings this hold)"))
+ else
+ EllesmereUI.Print(("|cff0cd29fPalette %d|r unbound, so no secure button"):format(i))
+ end
+ end
+ return
+ end
+ if type(msg) == "string" and msg:lower():find("trace") then
+ for i = 1, PaletteCount() do
+ local btn = secureButtons[i]
+ if btn then
+ -- Degrees, because the arc is configured in degrees: whether a
+ -- miss was legitimate is only obvious next to the arc's own
+ -- extent, and radians make that a mental conversion.
+ local function deg(key)
+ local v = tonumber(btn:GetAttribute(key))
+ return v and string.format("%.1f", v) or "nil"
+ end
+ local n = tonumber(btn:GetAttribute("eapShown")) or 0
+ local step = tonumber(btn:GetAttribute("eapStepDeg")) or 0
+ local full = btn:GetAttribute("eapFull")
+ -- The far edge the arc branch tests against.
+ local bound = full and "n/a"
+ or string.format("%.1f", (n - 1) * step + step * 0.5)
+
+ EllesmereUI.Print(("|cff0cd29fPalette %d|r why=%s idx=%s shown=%s mode=%s"):format(
+ i, tostring(btn:GetAttribute("eapWhy")),
+ tostring(btn:GetAttribute("eapIdx")), n,
+ tostring(btn:GetAttribute("eapMode"))))
+ EllesmereUI.Print((" full=%s step=%s start=%s theta=%s rel=%s bound=%s"):format(
+ tostring(full), deg("eapStepDeg"), deg("eapStartDeg"),
+ deg("eapTheta"), deg("eapRel"), bound))
+ EllesmereUI.Print((" dx=%s dy=%s fixed=%s scale=%s type1=%s val1=%s"):format(
+ tostring(btn:GetAttribute("eapDX")),
+ tostring(btn:GetAttribute("eapDY")),
+ tostring(btn:GetAttribute("eapFixed")),
+ tostring(btn:GetAttribute("eapScale")),
+ tostring(btn:GetAttribute("eapT1")),
+ tostring(btn:GetAttribute("eapV1"))))
+ else
+ EllesmereUI.Print(("|cff0cd29fPalette %d|r unbound, so no secure button"):format(i))
+ end
+ end
+ return
+ end
+ EllesmereUI.Print("|cff0cd29fAction Palette:|r configure palettes on the "
+ .. "|cffffd100Action Palette|r options page -- pick the palette, then drag "
+ .. "actions onto the preview.")
+end
diff --git a/EllesmereUIActionPalette/EllesmereUIActionPalette.toc b/EllesmereUIActionPalette/EllesmereUIActionPalette.toc
new file mode 100644
index 000000000..bfc5a2d55
--- /dev/null
+++ b/EllesmereUIActionPalette/EllesmereUIActionPalette.toc
@@ -0,0 +1,27 @@
+## Interface: 120000, 120001, 120005, 120007, 120100
+## Title: |cff0cd29fEllesmereUI|r Action Palette
+## Category: |cff0cd29fEllesmere|rUI
+## Group: EllesmereUI
+## Notes: Hold a keybind to open a palette of actions; point or scroll to choose, release to fire.
+## Author: Ellesmere
+## Version: 8.7.6
+## Dependencies: EllesmereUI
+# The RadialWheel name is this module's own, from before it grew layouts that
+# are not wheels. The global holds no data -- ring settings always lived in the
+# suite's central store, and MigrateLegacySV moves them to the module's new key
+# there. It stays declared only so WoW loads the vestigial table and writes it
+# back out empty; an undeclared SavedVariable would sit in the file untouched
+# forever. Drop this second name a release after the migration has shipped.
+## SavedVariables: EllesmereUIActionPaletteDB, EllesmereUIRadialWheelDB
+## IconTexture: Interface\AddOns\EllesmereUI\media\eg-logo.tga
+
+# Note: Bindings.xml (one binding per ring, routed to the secure buttons via
+# SetOverrideBindingClick) is intentionally NOT listed here -- WoW auto-loads a
+# file named Bindings.xml from the addon root, and listing it as well registers
+# every twice. Same reasoning as EllesmereUIActionBars.toc.
+
+# Main Lua
+EllesmereUIActionPalette.lua
+
+# Options
+EUI_ActionPalette_Options.lua
diff --git a/EllesmereUI_FirstInstall.lua b/EllesmereUI_FirstInstall.lua
index 0bc08669a..dfc254c51 100644
--- a/EllesmereUI_FirstInstall.lua
+++ b/EllesmereUI_FirstInstall.lua
@@ -59,6 +59,7 @@ local GROUPS = {
{ label = "Quality of Life", addon = "EllesmereUIQoL" },
{ label = "AuraBuff Reminders", addon = "EllesmereUIAuraBuffReminders" },
{ label = "DataBars", addon = "EllesmereUIDataBars" },
+ { label = "Action Palette", addon = "EllesmereUIActionPalette" },
-- Cursor Circle is a feature inside the QoL addon (cursor.enabled).
-- The checkbox here is a front-end shortcut that writes directly to
-- the QoL profile so users get a sensible default on first install.
diff --git a/EllesmereUI_GlobalSearch.lua b/EllesmereUI_GlobalSearch.lua
index d538afb4c..0bb1d6106 100644
--- a/EllesmereUI_GlobalSearch.lua
+++ b/EllesmereUI_GlobalSearch.lua
@@ -185,6 +185,7 @@ local function BuildModuleAliases()
local EXTRA_ALIASES = {
EllesmereUIMythicTimer = { "m+ timer", "m+" },
EllesmereUICooldownManager = { "cdm" },
+ EllesmereUIActionPalette = { "radial", "wheel", "ring menu", "palette", "grid", "arc", "fan", "action wheel" },
}
for folder, list in pairs(EXTRA_ALIASES) do
if EllesmereUI._modules and EllesmereUI._modules[folder] then
diff --git a/EllesmereUI_Profiles.lua b/EllesmereUI_Profiles.lua
index 3a772ebba..d2c031729 100644
--- a/EllesmereUI_Profiles.lua
+++ b/EllesmereUI_Profiles.lua
@@ -80,6 +80,7 @@ local ADDON_DB_MAP = {
{ folder = "EllesmereUIDamageMeters", display = "Damage Meters", svName = "EllesmereUIDamageMetersDB", suffix = "DamageMeters" },
{ folder = "EllesmereUIChat", display = "Chat", svName = "EllesmereUIChatDB", suffix = "Chat" },
{ folder = "EllesmereUIDataBars", display = "DataBars", svName = "EllesmereUIDataBarsDB", suffix = "DataBars" },
+ { folder = "EllesmereUIActionPalette", display = "Action Palette", svName = "EllesmereUIActionPaletteDB", suffix = "ActionPalette" },
}
EllesmereUI._ADDON_DB_MAP = ADDON_DB_MAP
@@ -1428,6 +1429,8 @@ function EllesmereUI.RefreshAllAddons()
if _G._EDM_Apply then _G._EDM_Apply() end
-- DataBars (bar set + blocks + layout + positions are all per-profile)
if _G._EDB_Apply then _G._EDB_Apply() end
+ -- Action Palette (enable state + palette count drive the override bindings)
+ if _G._EAP_Apply then _G._EAP_Apply() end
-- Dragon Riding HUD
if _G._EDR_Rebuild then _G._EDR_Rebuild() end
-- Minimap (flyout button state)
diff --git a/EllesmereUI_SpecOverrides.lua b/EllesmereUI_SpecOverrides.lua
index d6dc5fb40..2247b0236 100644
--- a/EllesmereUI_SpecOverrides.lua
+++ b/EllesmereUI_SpecOverrides.lua
@@ -115,6 +115,7 @@ local REFRESH_FNS = {
EllesmereUIMythicTimer = { "_EMT_Apply" },
EllesmereUIDamageMeters = { "_EDM_Apply" },
EllesmereUIDataBars = { "_EDB_Apply" },
+ EllesmereUIActionPalette = { "_EAP_Apply" },
EllesmereUIAuraBuffReminders = { "_EABR_RequestRefresh", "_EABR_ApplyUnlockPos" },
-- Folder is capture/apply-blacklisted (see FOLDER_BLACKLIST); this entry
-- is insurance so a key that slips through any future path can never hit
diff --git a/EllesmereUI_Widgets.lua b/EllesmereUI_Widgets.lua
index bf89c34d2..dce52dc1f 100644
--- a/EllesmereUI_Widgets.lua
+++ b/EllesmereUI_Widgets.lua
@@ -4770,10 +4770,14 @@ local function BuildCogPopup(opts)
-- Spec Overrides capture: a cog's settings belong to the slot hosting the
-- cog -- one setting, captured whole. When the call site passes
-- captureRegion (the DualRow half-region the cog sits in), every row with
- -- get/set joins that slot's capture group.
+ -- get/set joins that slot's capture group. row.noCapture opts a single
+ -- row out, the same flag DualRow honors per widget: a popup can then mix
+ -- profile-wide rows that capture with rows that must not (settings stored
+ -- per palette rather than under a flat key).
if opts.captureRegion and EllesmereUI.AddCaptureAccessor and opts.rows then
for _, row in ipairs(opts.rows) do
- if row.get and row.set and row.type ~= "button" and row.type ~= "reorder" then
+ if row.get and row.set and not row.noCapture
+ and row.type ~= "button" and row.type ~= "reorder" then
EllesmereUI.AddCaptureAccessor(opts.captureRegion, {
type = row.type, text = row.label, getValue = row.get, setValue = row.set,
min = row.min, max = row.max, step = row.step,