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,