From a25eccba4e54d1435e1c8ce69ed31768d0460bad Mon Sep 17 00:00:00 2001 From: Derek Mikesell Date: Thu, 6 Aug 2026 20:37:51 -0700 Subject: [PATCH 1/3] feat(actionpalette): add the Action Palette module Hold a keybind to open a palette of up to 12 actions; point or scroll to choose, release to fire. Palettes come in grid, arc and fan layouts, can nest one level through a dedicated entry, and stay fully usable in combat: selection and firing run inside secure snippets, so no insecure code touches protected state during lockdown. The module costs nothing while disabled: the master switch defaults off, no events are registered, and no override bindings or secure buttons exist until the module is enabled and a palette key is bound. Slots hold spells, items, toys, mounts, pets, macros, world markers and equipment sets. World markers place in the engine order, mounts can roll the journal's own random favorite, and entries show stack counts, charges, cooldowns and usability tints. The module folder keeps a vestigial RadialWheel SavedVariables name from its development history; migration moves that data to the new key and the second name can be dropped one release later. --- .pkgmeta | 1 + EUI__General_Options.lua | 2 + EllesmereUI.lua | 38 +- EllesmereUIActionPalette/Bindings.xml | 18 + .../EllesmereUIActionPalette.lua | 6965 +++++++++++++++++ .../EllesmereUIActionPalette.toc | 27 + EllesmereUI_FirstInstall.lua | 1 + EllesmereUI_GlobalSearch.lua | 1 + EllesmereUI_Profiles.lua | 3 + EllesmereUI_SpecOverrides.lua | 1 + 10 files changed, 7040 insertions(+), 17 deletions(-) create mode 100644 EllesmereUIActionPalette/Bindings.xml create mode 100644 EllesmereUIActionPalette/EllesmereUIActionPalette.lua create mode 100644 EllesmereUIActionPalette/EllesmereUIActionPalette.toc 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/EUI__General_Options.lua b/EUI__General_Options.lua index fa523bc75..521a46b3e 100644 --- a/EUI__General_Options.lua +++ b/EUI__General_Options.lua @@ -4113,6 +4113,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 @@ -6546,6 +6547,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 056bf0be7..cc90c54d7 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/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 From 9d72b2afd01746cbf746c70374929979a467eef2 Mon Sep 17 00:00:00 2001 From: Derek Mikesell Date: Thu, 6 Aug 2026 20:37:51 -0700 Subject: [PATCH 2/3] feat(actionpalette): add the options page A settings page with a live palette preview: drag to reorder slots, a picker with category folders for every slot kind, per-palette shape, position and appearance, preset palettes for a quick start, and cog popups for the power knobs. Palettes each get a keybinding row, and markers get their own picker category. BuildCogPopup gains a per-row noCapture flag so a popup can mix profile-wide rows that join the capture group with per-palette rows that must not. --- .../EUI_ActionPalette_Options.lua | 2793 +++++++++++++++++ EllesmereUI_Widgets.lua | 8 +- 2 files changed, 2799 insertions(+), 2 deletions(-) create mode 100644 EllesmereUIActionPalette/EUI_ActionPalette_Options.lua 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/EllesmereUI_Widgets.lua b/EllesmereUI_Widgets.lua index eb8b4b218..dd2308351 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, From c39df7e05f0aed0179b03759553cef404c3cd6a1 Mon Sep 17 00:00:00 2001 From: Derek Mikesell Date: Thu, 6 Aug 2026 20:37:51 -0700 Subject: [PATCH 3/3] chore(actionpalette): add the nest agreement harness An offline harness that executes the module's real WrapScript and snippet plumbing outside the game and checks that the secure and insecure sides agree about arming, handoff, ownership and teardown across the nest paths. Run from EllesmereUIActionPalette/: lua ../.tools/palette-nest/nest_agree.lua . 3 The .pkgmeta ignore list already excludes .tools from packaging. --- .tools/palette-nest/README | 1 + .tools/palette-nest/nest_agree.lua | 2906 ++++++++++++++++++++++++++++ 2 files changed, 2907 insertions(+) create mode 100644 .tools/palette-nest/README create mode 100644 .tools/palette-nest/nest_agree.lua 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)