diff --git a/CHANGELOG.md b/CHANGELOG.md index a275570c..0a9339fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Improvements +* (Pinned Frames) Pinned frames now **lock and unlock together with your main frames** — there's no separate per-set Lock Position toggle any more. Unlock your party or raid frames and the pinned sets for that mode show their drag handles too; lock again and they all settle. Each set's handle also opens the same **fine position panel** as the party/raid frames (precise X/Y nudge, centre, reset), so you can place a pinned set to the pixel. (by Krathe) * (Pinned Frames) Pinned frame settings are now **global per party/raid mode** and no longer saved into auto layouts — a raid auto layout only controls whether each pinned set is **shown** for that layout. This removes the stale/blank pinned data and editor mismatches that came from pinned settings being stored per-layout, and pinned edits now take effect live. (by Krathe) ### Bug Fixes diff --git a/Config.lua b/Config.lua index daf8d3cb..3dd10d42 100644 --- a/Config.lua +++ b/Config.lua @@ -1434,6 +1434,8 @@ DF.PartyDefaults = { permanentMoverWidth = 15, pixelPerfect = true, snapToGrid = true, + pinnedSnapToGrid = false, + pinnedHideMover = false, hideDragOverlay = false, -- Group Labels @@ -2315,7 +2317,6 @@ DF.PartyDefaults = { horizontalSpacing = 2, verticalSpacing = 2, position = { point = "CENTER", x = 0, y = 200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -2336,7 +2337,6 @@ DF.PartyDefaults = { horizontalSpacing = 2, verticalSpacing = 2, position = { point = "CENTER", x = 0, y = -200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -2362,7 +2362,6 @@ DF.PartyDefaults = { verticalSpacing = 2, scale = 1.0, position = { point = "CENTER", x = 0, y = 200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -2382,7 +2381,6 @@ DF.PartyDefaults = { verticalSpacing = 2, scale = 1.0, position = { point = "CENTER", x = 0, y = -200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -3034,6 +3032,8 @@ DF.RaidDefaults = { permanentMoverWidth = 15, pixelPerfect = true, snapToGrid = true, + pinnedSnapToGrid = false, + pinnedHideMover = false, hideDragOverlay = false, -- Group Labels @@ -3820,7 +3820,6 @@ DF.RaidDefaults = { horizontalSpacing = 2, verticalSpacing = 2, position = { point = "CENTER", x = 0, y = 200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -3841,7 +3840,6 @@ DF.RaidDefaults = { horizontalSpacing = 2, verticalSpacing = 2, position = { point = "CENTER", x = 0, y = -200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -3867,7 +3865,6 @@ DF.RaidDefaults = { verticalSpacing = 2, scale = 1.0, position = { point = "CENTER", x = 0, y = 200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -3887,7 +3884,6 @@ DF.RaidDefaults = { verticalSpacing = 2, scale = 1.0, position = { point = "CENTER", x = 0, y = -200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", diff --git a/Core.lua b/Core.lua index a4b49abc..2132705c 100644 --- a/Core.lua +++ b/Core.lua @@ -5987,7 +5987,7 @@ function DF:FullProfileRefresh() -- Pinned frames if DF.PinnedFrames and DF.PinnedFrames.initialized and DF.PinnedFrames.headers then - for setIndex = 1, 2 do + for setIndex = 1, (DF.PinnedFrames.MAX_SETS or 4) do local header = DF.PinnedFrames.headers[setIndex] if header then for i = 1, 40 do @@ -6048,7 +6048,7 @@ function DF:FullProfileRefresh() if DF.PinnedFrames.RefreshEnabledState then DF.PinnedFrames:RefreshEnabledState() end - for setIndex = 1, 2 do + for setIndex = 1, (DF.PinnedFrames.MAX_SETS or 4) do if DF.PinnedFrames.headers[setIndex] then DF.PinnedFrames:ApplyLayoutSettings(setIndex) DF.PinnedFrames:ResizeContainer(setIndex) diff --git a/DesignerPresets.lua b/DesignerPresets.lua index c096931e..ebda902e 100644 --- a/DesignerPresets.lua +++ b/DesignerPresets.lua @@ -455,8 +455,10 @@ function DF:ImportDesignerPresets(importData, categories) wanted = {} for _, cat in ipairs(categories) do wanted[cat] = true end end - local importAura = (not wanted) or wanted.auraDesigner or wanted.autoLayout - local importText = (not wanted) or wanted.text or wanted.autoLayout + -- Pinned sets reference designer presets too, so a pinnedFrames-only import/ + -- export must carry the libraries or a set ends up pointing at a missing preset. + local importAura = (not wanted) or wanted.auraDesigner or wanted.autoLayout or wanted.pinnedFrames + local importText = (not wanted) or wanted.text or wanted.autoLayout or wanted.pinnedFrames local function mergeLib(libKey, src) if type(src) ~= "table" then return end @@ -559,7 +561,7 @@ end local function RestampPinnedPresets() local pf = DF.PinnedFrames if pf and pf.initialized then - for i = 1, 2 do + for i = 1, (pf.MAX_SETS or 4) do if pf.ApplyLayoutSettings then pf:ApplyLayoutSettings(i) end end end diff --git a/Features/PinnedFrames.lua b/Features/PinnedFrames.lua index 76aa4d5a..0d88eb55 100644 --- a/Features/PinnedFrames.lua +++ b/Features/PinnedFrames.lua @@ -20,6 +20,10 @@ PinnedFrames.testFrames = {} -- [setIndex] = { [1..N] = fake non-secure test PinnedFrames.testContainers = {} -- [setIndex] = non-secure container at the test-mode profile's position for this set PinnedFrames.initialized = false PinnedFrames.currentMode = nil -- Track what mode we initialized for +-- Global unlock state: pinned movers/chrome are shown only while the MAIN frames +-- are unlocked (driven from DF:UnlockFrames / DF:UnlockRaidFrames). Replaces the +-- retired per-set `set.locked`. Default false = locked (no drag handles). +PinnedFrames.moversShown = false -- Color palette per mode (raid = orange, party = purple-blue) -- Matches C_RAID / C_ACCENT used across the GUI @@ -42,6 +46,77 @@ local function GetModeColors(isRaid) } end +-- Make a pinned drag handle read as clickable and show which set the position +-- panel is currently driving. All text stays white (addon convention); the +-- selected handle stands out by being solid + bright while the others dim. +-- States (only while the panel is in pinned mode does dimming apply): +-- active — SOLID full accent fill + white edge (the panel targets this set) +-- hovered — white edge + lighter fill (the "pointed at" cue) +-- resting — accent edge + dark fill; DIMMED when another handle is active +-- Colours come from mover.dfColors so the pooled test handle can be re-themed on +-- a party<->raid flip by updating that table and calling restyle. +local function StylePinnedHandle(mover, borderTex, innerTex, textFS, colors) + mover.dfColors = colors + mover.dfActive = false + mover.dfHovered = false + + local DIM = 0.40 -- alpha for non-selected handles while one is selected + + local function restyle() + local c = mover.dfColors or {} + local accent = c.moverBorder or { 1, 1, 1, 1 } + local fill = c.moverBg or { 0, 0, 0, 1 } + if mover.dfActive then + -- Selected: solid, full-brightness accent fill, white edge + text. + if innerTex then innerTex:SetColorTexture(accent[1], accent[2], accent[3], 1) end + if borderTex then borderTex:SetColorTexture(1, 1, 1, 1) end + if textFS then textFS:SetTextColor(1, 1, 1, 1) end + elseif mover.dfHovered then + if innerTex then innerTex:SetColorTexture( + math.min(fill[1] + 0.20, 1), math.min(fill[2] + 0.20, 1), + math.min(fill[3] + 0.20, 1), fill[4] or 1) end + if borderTex then borderTex:SetColorTexture(1, 1, 1, 1) end + if textFS then textFS:SetTextColor(1, 1, 1, 1) end + else + -- Resting. Dim only when the panel is targeting some OTHER pinned set, + -- so a fresh unlock (no pinned target) leaves every handle full. + local d = (DF.positionPanelMode == "pinned") and DIM or 1 + if innerTex then innerTex:SetColorTexture(fill[1], fill[2], fill[3], (fill[4] or 1) * d) end + if borderTex then borderTex:SetColorTexture(accent[1], accent[2], accent[3], (accent[4] or 1) * d) end + if textFS then textFS:SetTextColor(1, 1, 1, d) end + end + end + mover.dfRestyle = restyle + mover.SetActive = function(_, on) mover.dfActive = on and true or false; restyle() end + + -- Auto-size the handle to its label, with generous padding so it reads as a + -- solid, obvious handle rather than a thin bar. + mover.dfFitWidth = function() + local w = ((textFS and textFS:GetStringWidth()) or 0) + 28 + if w < 48 then w = 48 end + mover:SetWidth(w) + mover:SetHeight(20) + end + + mover:HookScript("OnEnter", function(self) + self.dfHovered = true + restyle() + GameTooltip:SetOwner(self, "ANCHOR_TOP") + GameTooltip:AddLine(textFS and textFS:GetText() or "Pinned") + GameTooltip:AddLine("Drag to move", 0.8, 0.8, 0.8) + GameTooltip:AddLine("Click to open the position panel", 0.8, 0.8, 0.8) + GameTooltip:Show() + end) + mover:HookScript("OnLeave", function(self) + self.dfHovered = false + restyle() + GameTooltip:Hide() + end) + + restyle() + mover.dfFitWidth() +end + -- ============================================================ -- UTILITY FUNCTIONS -- ============================================================ @@ -93,6 +168,14 @@ local function PinnedSoloAllowed(set) return set and set.showInSoloMode == true -- opt-in to show when solo end +-- Display label for a set's drag handle + position panel: the set's name (or +-- "Pinned N" when unnamed) tagged with the mode it belongs to, e.g. "NPC (Raid)". +local function PinnedSetLabel(set, setIndex, isRaidMode) + local name = set and set.name + if not name or name == "" then name = "Pinned " .. (setIndex or 1) end + return name .. " (" .. (isRaidMode and "Raid" or "Party") .. ")" +end + -- True when instanced PvP has pinned processing disabled (the live default — -- see ProcessAllSets for the full rationale). Shared by ProcessAllSets -- (auto-population dormancy) and ComputeHiddenNames: a dormant feature must @@ -721,6 +804,39 @@ local function GetContainerAnchorPoint(set) return anchor end +-- A WoW anchor name as fractional offsets from a frame's centre, in frame-size +-- units (LEFT=-0.5, RIGHT=+0.5, TOP=+0.5, BOTTOM=-0.5; centre axes = 0). +local function AnchorFractions(point) + local fx = (point:find("LEFT") and -0.5) or (point:find("RIGHT") and 0.5) or 0 + local fy = (point:find("TOP") and 0.5) or (point:find("BOTTOM") and -0.5) or 0 + return fx, fy +end + +-- Position a pinned container so its FIRST FRAME lands at a screen spot that is +-- INDEPENDENT of the container's size (frame count). Frames grow from the +-- container's GROWTH corner (GetContainerAnchorPoint), so we anchor THAT corner +-- to the screen — not the container's saved-point corner. The saved `point` +-- stays the screen reference (so coords keep their meaning; nothing jumps), and a +-- half-frame offset reproduces where a point-anchored single frame sits — so a +-- default set doesn't move, yet test (sized to testCount) and live (sized to the +-- visible count) now place the first frame identically. Dragged sets, whose point +-- already equals the growth corner, get a zero offset and render unchanged. +-- frameW/frameH are the set's per-frame size in container-local units. +local function PositionPinnedContainer(container, set, pos, frameW, frameH) + if not container then return end + local growth = GetContainerAnchorPoint(set) + local ref = (pos and pos.point) or growth + local gfx, gfy = AnchorFractions(growth) + local rfx, rfy = AnchorFractions(ref) + local s = container:GetScale() or 1 + -- pos.x/y are screen-space (÷scale → container units); the frame offset is + -- already in container-local units, so it is NOT divided by scale. + local x = ((pos and pos.x) or 0) / s + (gfx - rfx) * (frameW or 0) + local y = ((pos and pos.y) or 0) / s + (gfy - rfy) * (frameH or 0) + container:ClearAllPoints() + container:SetPoint(growth, UIParent, ref, x, y) +end + -- ============================================================ -- FRAME CREATION -- ============================================================ @@ -1151,7 +1267,7 @@ function PinnedFrames:CreateSetFrames(setIndex) local set = GetSetDB(setIndex) if not set then return end - + local modeSuffix = IsInRaid() and "Raid" or "Party" -- Create container (movable anchor frame) @@ -1160,15 +1276,14 @@ function PinnedFrames:CreateSetFrames(setIndex) container:SetFrameStrata("MEDIUM") container:SetClampedToScreen(true) - -- Position from saved settings — use growth-direction anchor + -- Position from saved settings, pinning the growth corner so size never shifts + -- the first frame (see PositionPinnedContainer). local containerAnchor = GetContainerAnchorPoint(set) local pos = set.position or { point = containerAnchor, x = 0, y = 200 * (setIndex == 1 and 1 or -1) } - -- If saved anchor doesn't match current growth anchor, convert on first layout pass - local useAnchor = pos.point or containerAnchor local initScale = GetSetScale(set) container:SetScale(initScale) - container:ClearAllPoints() - container:SetPoint(useAnchor, UIParent, useAnchor, (pos.x or 0) / initScale, (pos.y or 0) / initScale) + local initW, initH = GetSetFrameSize(set, GetPinnedModeDB()) + PositionPinnedContainer(container, set, pos, initW, initH) -- Make draggable when unlocked container:SetMovable(true) @@ -1181,7 +1296,7 @@ function PinnedFrames:CreateSetFrames(setIndex) container.bg = container:CreateTexture(nil, "BACKGROUND") container.bg:SetAllPoints() container.bg:SetColorTexture(unpack(colors.containerBg)) - container.bg:SetShown(not set.locked) + container.bg:SetShown(self.moversShown) -- Border when unlocked container.border = CreateFrame("Frame", nil, container, "BackdropTemplate") @@ -1191,11 +1306,11 @@ function PinnedFrames:CreateSetFrames(setIndex) edgeSize = 1, }) container.border:SetBackdropBorderColor(unpack(colors.containerBorder)) - container.border:SetShown(not set.locked) + container.border:SetShown(self.moversShown) -- Mover frame (parented to UIParent for scale independence) local mover = CreateFrame("Frame", "DandersPinned" .. setIndex .. "Mover", UIParent) - mover:SetSize(80, 16) + mover:SetSize(140, 16) mover:SetFrameStrata("HIGH") mover:SetPoint("BOTTOM", container, "TOP", 0, 2) @@ -1216,26 +1331,49 @@ function PinnedFrames:CreateSetFrames(setIndex) -- Mover text mover.text = mover:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") mover.text:SetPoint("CENTER") - mover.text:SetText("Drag to Move") + mover.text:SetText(PinnedSetLabel(set, setIndex, IsInRaid())) mover.text:SetTextColor(unpack(colors.moverText)) - + + -- Hover highlight + tooltip + active-state styling (reads as clickable). + StylePinnedHandle(mover, mover.border, moverInner, mover.text, colors) + -- Mover is the drag handle mover:EnableMouse(true) mover:RegisterForDrag("LeftButton") - - -- Track starting mouse and container position - local startMouseX, startMouseY, startPosX, startPosY - + + -- Clicking (or finishing a drag on) this set's mover points the shared + -- position panel at this set, so the X/Y nudge controls drive it. + mover:HookScript("OnMouseUp", function(_, button) + if button == "LeftButton" and DF.SetPositionPanelMode then + DF.positionPanelPinnedSet = setIndex + DF:SetPositionPanelMode("pinned") + end + end) + + -- Track starting mouse and container position (+ the drag's anchor reference + -- and frame size, captured once so OnUpdate/OnDragStop stay consistent). + local startMouseX, startMouseY, startPosX, startPosY, dragRef, dragW, dragH + mover:SetScript("OnDragStart", function(self) -- Re-resolve the set EVERY drag: the closure's `set` upvalue is bound at -- CreateSetFrames time, but a profile switch swaps the underlying table -- without recreating the frames — stale lock state would gate the drag -- and (worse) OnDragStop would save the position into the DEAD profile. local liveSet = GetSetDB(setIndex) - if not liveSet or liveSet.locked then return end + -- Drag is only valid while globally unlocked and out of combat (the + -- container can be a secure header parent — repositioning it taints). + if not liveSet or not PinnedFrames.moversShown or InCombatLockdown() then return end - -- Get the current anchor for this set - local anchor = GetContainerAnchorPoint(liveSet) + -- Point the position panel at this set so it tracks the drag live. + if DF.SetPositionPanelMode then + DF.positionPanelPinnedSet = setIndex + DF:SetPositionPanelMode("pinned") + end + + -- Keep the set's existing anchor reference (pos.point) so coords stay in + -- the same space; PositionPinnedContainer pins the growth corner from it. + dragRef = (liveSet.position and liveSet.position.point) or GetContainerAnchorPoint(liveSet) + dragW, dragH = GetSetFrameSize(liveSet, GetPinnedModeDB()) -- Get starting mouse position in screen coordinates local uiScale = UIParent:GetEffectiveScale() @@ -1260,10 +1398,16 @@ function PinnedFrames:CreateSetFrames(setIndex) local newX = startPosX + deltaX local newY = startPosY + deltaY - -- Divide by scale for SetPoint — WoW multiplies offsets by frame scale internally - local s = container:GetScale() or 1 - container:ClearAllPoints() - container:SetPoint(anchor, UIParent, anchor, newX / s, newY / s) + -- Snap to grid when pinned snap is enabled (its own flag, default off). + local sdb = DF.GetDB and DF:GetDB() + if sdb and sdb.pinnedSnapToGrid and DF.SnapToGrid then + newX, newY = DF:SnapToGrid(newX, newY) + end + + -- Track the live drag in the DB + panel so the X/Y readouts update. + liveSet.position = { point = dragRef, x = newX, y = newY } + PositionPinnedContainer(container, liveSet, liveSet.position, dragW, dragH) + if DF.UpdatePositionPanel then DF:UpdatePositionPanel() end end) end) @@ -1275,8 +1419,8 @@ function PinnedFrames:CreateSetFrames(setIndex) local liveSet = GetSetDB(setIndex) if not liveSet then return end - -- Get the current anchor for this set - local anchor = GetContainerAnchorPoint(liveSet) + -- Keep the captured anchor reference (pos.point) from drag start. + local anchor = dragRef or GetContainerAnchorPoint(liveSet) -- Get final position from mouse delta local uiScale = UIParent:GetEffectiveScale() @@ -1289,25 +1433,33 @@ function PinnedFrames:CreateSetFrames(setIndex) local finalX = startPosX + deltaX local finalY = startPosY + deltaY + -- Snap to grid when pinned snap is enabled (its own flag, default off). + local sdb = DF.GetDB and DF:GetDB() + if sdb and sdb.pinnedSnapToGrid and DF.SnapToGrid then + finalX, finalY = DF:SnapToGrid(finalX, finalY) + end + -- Save logical position (unscaled) liveSet.position = { point = anchor, x = finalX, y = finalY } - -- When an auto layout is active, GetSetDB() returns a table from the overlay's - -- deep copy of _realRaidDB.pinnedFrames, so the write above goes to that copy - -- rather than the real DB. Position is intentionally not auto-layout-overridable, - -- so always write it through to _realRaidDB so it survives overlay rebuilds. - local realSet = DF._realRaidDB - and DF._realRaidDB.pinnedFrames - and DF._realRaidDB.pinnedFrames.sets - and DF._realRaidDB.pinnedFrames.sets[setIndex] - if realSet then - realSet.position = { point = anchor, x = finalX, y = finalY } + -- RAID ONLY: when an auto layout is active, GetSetDB() returns a deep copy + -- of _realRaidDB.pinnedFrames, so the write above goes to that throwaway copy + -- — mirror through to the real raid DB so it survives overlay rebuilds. Party + -- sets ARE the real table (liveSet), and _realRaidDB.sets[setIndex] is the + -- RAID set, so mirroring in party mode would corrupt the raid set's position. + if IsInRaid() then + local realSet = DF._realRaidDB + and DF._realRaidDB.pinnedFrames + and DF._realRaidDB.pinnedFrames.sets + and DF._realRaidDB.pinnedFrames.sets[setIndex] + if realSet then + realSet.position = { point = anchor, x = finalX, y = finalY } + end end - -- Divide by scale for SetPoint - local s = container:GetScale() or 1 - container:ClearAllPoints() - container:SetPoint(anchor, UIParent, anchor, finalX / s, finalY / s) + PositionPinnedContainer(container, liveSet, liveSet.position, dragW, dragH) + + if DF.UpdatePositionPanel then DF:UpdatePositionPanel() end -- If Test Mode is active, re-sync test container(s) to the new position. -- The drag updated the current mode's set.position; the test container @@ -1319,8 +1471,8 @@ function PinnedFrames:CreateSetFrames(setIndex) end end) - -- Mover shows when unlocked AND enabled - mover:SetShown(set.enabled and not set.locked) + -- Mover shows when globally unlocked AND the set is enabled + mover:SetShown(set.enabled and self.moversShown) container.mover = mover -- Label (parented to UIParent for scale independence) @@ -1347,7 +1499,7 @@ function PinnedFrames:CreateSetFrames(setIndex) if set.enabled then container:Show() if label then label:SetShown(set.showLabel) end - if container.mover then container.mover:SetShown(not set.locked) end + if container.mover then container.mover:SetShown(self.moversShown) end else container:Hide() if label then label:Hide() end @@ -1407,7 +1559,7 @@ function PinnedFrames:CreateSetFrames(setIndex) label:SetShown(set.showLabel) end if container.mover then - container.mover:SetShown(not set.locked) + container.mover:SetShown(self.moversShown) end else container:Hide() @@ -1712,23 +1864,13 @@ function PinnedFrames:ApplyLayoutSettings(setIndex) header:ClearAllPoints() header:SetPoint(containerAnchorPoint, container, containerAnchorPoint, 0, 0) - -- Restore saved position. Pin the container by the anchor the user - -- actually dragged it to (pos.point), NOT the current growth anchor: - -- pos.x/pos.y were saved relative to pos.point, so applying them at a - -- different corner makes the frame jump on re-enable / layout refresh - -- (the old GetLeft()-gated conversion silently no-ops on a just-shown - -- frame whose layout hasn't flushed yet — the reported "disable/enable - -- moves it" bug). The header still anchors to the container at - -- containerAnchorPoint for internal grid growth; the container box is - -- sized to the grid, so pinning the saved corner keeps the visual - -- position even if the growth direction later changes. Mirrors - -- EnsureTestContainer so live and test frames restore identically. + -- Restore saved position via the shared helper: it pins the GROWTH corner + -- (so the grid size never shifts the first frame — live and test agree) + -- while keeping pos.point as the screen reference (so coords keep their + -- meaning and a default set doesn't jump). Mirrors EnsureTestContainer. local pos = set.position if pos then - local anchor = pos.point or containerAnchorPoint - container:ClearAllPoints() - local s = container:GetScale() or 1 - container:SetPoint(anchor, UIParent, anchor, (pos.x or 0) / s, (pos.y or 0) / s) + PositionPinnedContainer(container, set, pos, frameWidth, frameHeight) end end @@ -1776,18 +1918,15 @@ function PinnedFrames:ApplyBossLayout(setIndex) if not set or not container then return end if InCombatLockdown() then return end - -- Container anchor + scale + saved position handling. - local anchor = GetContainerAnchorPoint(set) + -- Container scale + saved position handling. container:SetScale(GetSetScale(set)) - -- Pin by the saved drag anchor (pos.point), not the current growth anchor — - -- same rationale as ApplyLayoutSettings (avoids the disable/enable jump). + -- Pin the growth corner (size-invariant) with pos.point as the screen + -- reference — same as ApplyLayoutSettings (see PositionPinnedContainer). local pos = set.position if pos then - local posAnchor = pos.point or anchor - container:ClearAllPoints() - local s = container:GetScale() or 1 - container:SetPoint(posAnchor, UIParent, posAnchor, (pos.x or 0) / s, (pos.y or 0) / s) + local bw, bh = GetSetFrameSize(set, GetPinnedModeDB()) + PositionPinnedContainer(container, set, pos, bw, bh) end -- Push slot coords + sizes to the secure handler. The allocator snippet @@ -1995,8 +2134,15 @@ function PinnedFrames:SetEnabled(setIndex, enabled) local label = self.labels[setIndex] if visible then - container:Show() - if header then header:Show() end + -- During test mode the live frames are deliberately hidden (the preview + -- owns the screen). Update layout data but DON'T show live chrome, or the + -- real frame leaks under the preview; ExitTestMode shows it on the way out. + local inTest = self.testModeActive + + if not inTest then + container:Show() + if header then header:Show() end + end if isBoss then self:ApplyBossLayout(setIndex) @@ -2007,9 +2153,17 @@ function PinnedFrames:SetEnabled(setIndex, enabled) end self:UpdateLabel(setIndex) - if label then label:SetShown(set.showLabel) end - if container.mover and not set.locked then - container.mover:SetShown(true) + if not inTest then + if label then label:SetShown(set.showLabel) end + -- A set that becomes visible while globally unlocked gets its drag chrome. + if self.moversShown then + if container.mover then container.mover:SetShown(true) end + if container.bg then container.bg:SetShown(true) end + if container.border then container.border:SetShown(true) end + end + -- Re-assert Hide-Mover alpha + active highlight on the freshly-shown handle. + self:ApplyMoverOverlayAlpha() + self:RefreshMoverActiveStates() end self:RefreshChildFrames(setIndex) @@ -2018,6 +2172,8 @@ function PinnedFrames:SetEnabled(setIndex, enabled) if header then header:Hide() end if label then label:Hide() end if container.mover then container.mover:Hide() end + if container.bg then container.bg:Hide() end + if container.border then container.border:Hide() end -- #78: a set that was hiding members from the main frames must RELEASE -- them when it turns off — ComputeHiddenNames now skips this set, but @@ -2044,92 +2200,181 @@ function PinnedFrames:RefreshEnabledState() end end --- Toggle locked state for a set -function PinnedFrames:SetLocked(setIndex, locked) - local set = GetSetDB(setIndex) - local container = self.containers[setIndex] - - if not set or not container then return end - - -- Unlocking requires frame manipulation that can taint in combat - if not locked and InCombatLockdown() then - self.pendingUnlock = self.pendingUnlock or {} - self.pendingUnlock[setIndex] = true - DF:Debug("PINNED", "Set %d unlock queued until after combat", setIndex) +-- Show or hide the pinned drag chrome (mover + bg + border) for every set in the +-- current mode. Driven by the MAIN frames lock: DF:UnlockFrames / UnlockRaidFrames +-- → true; LockFrames / LockRaidFrames → false. Replaces the retired per-set lock, +-- so the pinned frames now lock/unlock together with everything else. +function PinnedFrames:SetMoversShown(shown) + -- Showing handles only makes sense out of combat (the drag reposition path is + -- combat-guarded). The main Unlock paths already block in combat, so this is + -- just defence in depth — defer an unlock that somehow arrives mid-combat. + if shown and InCombatLockdown() then + self.pendingMoversShown = true return end - - set.locked = locked - -- Effective visibility includes the solo gate — the mover is parented to - -- UIParent, so showing it for a solo-hidden set floats a "Drag to Move" - -- handle over an invisible container. - local visible = set.enabled and PinnedSoloAllowed(set) + -- An explicit lock cancels any combat-deferred/remembered unlock intent, so a + -- post-combat restore can't re-show handles the user just locked. (LockAllForCombat + -- re-sets moversShownBeforeCombat AFTER calling this, so its own hide is unaffected.) + if not shown then + self.pendingMoversShown = nil + self.moversShownBeforeCombat = nil + end + + self.moversShown = shown and true or false - -- Container background/border visibility - container.bg:SetShown(not locked and visible) - container.border:SetShown(not locked and visible) + if not self.initialized then return end - -- Mover shows when unlocked (independent of label) - if container.mover then - container.mover:SetShown(not locked and visible) + -- During test mode the live containers are hidden and the TEST containers + -- (each with its own testMover) are the preview — drive those handles instead. + if self.testModeActive then + for i = 1, PinnedFrames.MAX_SETS do + local tc = self.testContainers[i] + if tc and tc.testMover then tc.testMover:SetShown(self.moversShown) end + end + self:RefreshMoverActiveStates() + self:ApplyMoverOverlayAlpha() + return end -end --- Auto-lock all unlocked sets (called on combat start) -function PinnedFrames:LockAllForCombat() - if not self.initialized then return end - - local hlDB = GetPinnedDB() - if not hlDB or not hlDB.sets then return end - for i = 1, PinnedFrames.MAX_SETS do - local set = hlDB.sets[i] + local set = GetSetDB(i) local container = self.containers[i] - if set and container and not set.locked then - -- Remember which sets were unlocked so we can restore after combat - self.unlockedBeforeCombat = self.unlockedBeforeCombat or {} - self.unlockedBeforeCombat[i] = true - - -- Lock visually (hide mover/bg/border) but don't save to DB - container.bg:Hide() - container.border:Hide() - if container.mover then - container.mover:Hide() - end - - DF:Debug("PINNED", "Set %d auto-locked for combat", i) + if set and container then + -- Effective visibility includes the party solo gate — chrome is + -- parented to UIParent, so showing it for a solo-hidden set would + -- float a "Drag to Move" handle over an invisible container. + local visible = self.moversShown and set.enabled and PinnedSoloAllowed(set) + if container.bg then container.bg:SetShown(visible) end + if container.border then container.border:SetShown(visible) end + if container.mover then container.mover:SetShown(visible) end end end + self:RefreshMoverActiveStates() + self:ApplyMoverOverlayAlpha() end --- Restore unlock state after combat +-- Hide pinned chrome on combat start, remembering the unlock intent so it can be +-- restored afterwards. Only non-secure overlay frames are touched (combat-safe). +function PinnedFrames:LockAllForCombat() + if not self.initialized then return end + + local was = self.moversShown + if was then + -- SetMoversShown(false) hides every set's chrome (safe in combat) and clears + -- the restore intent; we set it AFTER so the post-combat restore can fire. + self:SetMoversShown(false) + DF:Debug("PINNED", "Pinned movers hidden for combat") + end + self.moversShownBeforeCombat = was +end + +-- Restore the pre-combat unlock state (or apply an unlock requested during combat). function PinnedFrames:RestoreUnlockedAfterCombat() - -- Restore sets that were unlocked before combat - if self.unlockedBeforeCombat then - for setIndex in pairs(self.unlockedBeforeCombat) do - local set = GetSetDB(setIndex) - local container = self.containers[setIndex] - if set and container and not set.locked then - -- Solo gate: don't restore UIParent-anchored chrome (mover) for - -- a set whose container is hidden when solo. - local visible = set.enabled and PinnedSoloAllowed(set) - container.bg:SetShown(visible) - container.border:SetShown(visible) - if container.mover then - container.mover:SetShown(visible) - end - end - end - self.unlockedBeforeCombat = nil + if self.moversShownBeforeCombat or self.pendingMoversShown then + self.moversShownBeforeCombat = nil + self.pendingMoversShown = nil + self:SetMoversShown(true) end - - -- Process any unlock requests that came in during combat - if self.pendingUnlock then - for setIndex in pairs(self.pendingUnlock) do - self:SetLocked(setIndex, false) +end + +-- Forward declaration: GetSetDBForMode is defined further down (near the test-mode +-- helpers) but is needed here by GetSetForPosition. Declaring the local up front +-- lets the later `function GetSetDBForMode` assign to this same upvalue. +local GetSetDBForMode + +-- True when the position panel should target the RAID set: either raid test mode +-- is previewing, or (not in test) the player is actually in a raid. Party sets +-- live in the party db with no auto-layout overlays; raid sets need the mirror. +local function PositionTargetIsRaid(self) + if self.testModeActive then return DF.raidTestMode and true or false end + return IsInRaid() +end + +-- Resolve the set table the shared position panel targets. In test mode this is +-- the set for the mode being PREVIEWED (raid test while solo edits the raid set), +-- matching the test mover + the frames actually on screen; otherwise the live +-- current-mode set. Overlay-aware (same table the runtime + drag handler use). +function PinnedFrames:GetSetForPosition(setIndex) + if self.testModeActive then + return GetSetDBForMode(setIndex, PositionTargetIsRaid(self)) + end + return GetSetDB(setIndex) +end + +-- True when the position panel currently targets a RAID set (raid accent + label). +function PinnedFrames:IsPositionTargetRaid() + return PositionTargetIsRaid(self) +end + +-- "NPC (Raid)" style label for the position panel, matching the drag handle. +function PinnedFrames:GetPositionPanelLabel(setIndex) + return PinnedSetLabel(self:GetSetForPosition(setIndex), setIndex, PositionTargetIsRaid(self)) +end + +-- Apply the pinned "Hide Mover" preference to every pinned handle — both live and +-- test. Its own flag (db.pinnedHideMover), independent of the main-frame drag +-- overlay. alpha 0 keeps handles draggable but invisible. Called from the panel +-- toggle and whenever handles are (re)shown so the pref sticks. +function PinnedFrames:ApplyMoverOverlayAlpha() + local db = DF.GetDB and DF:GetDB() + local a = (db and db.pinnedHideMover) and 0 or 1 + for i = 1, PinnedFrames.MAX_SETS do + local lc = self.containers[i] + if lc and lc.mover then lc.mover:SetAlpha(a) end + local tc = self.testContainers[i] + if tc and tc.testMover then tc.testMover:SetAlpha(a) end + end +end + +-- Highlight the drag handle of the set the position panel is currently driving +-- (and clear the others). Called whenever the panel target changes so the +-- handle<->panel link is visible. Covers both live and test handles. +function PinnedFrames:RefreshMoverActiveStates() + local activeIndex = (DF.positionPanelMode == "pinned") and (DF.positionPanelPinnedSet or 1) or nil + for i = 1, PinnedFrames.MAX_SETS do + local lc = self.containers[i] + if lc and lc.mover and lc.mover.SetActive then lc.mover:SetActive(i == activeIndex) end + local tc = self.testContainers[i] + if tc and tc.testMover and tc.testMover.SetActive then tc.testMover:SetActive(i == activeIndex) end + end +end + +-- Reposition a pinned set's container from its saved set.position (the nudge +-- position panel's apply). Mirrors the drag handler: pin by the saved anchor +-- (pos.point) and write the position through to the persistent DB so it survives +-- auto-layout overlay rebuilds (position is never auto-layout-overridable). +function PinnedFrames:ApplySetPosition(setIndex) + if InCombatLockdown() then return end -- moving a secure-header parent taints + local set = self:GetSetForPosition(setIndex) + if not set then return end + + local pos = set.position + if not pos then return end + + local raid = PositionTargetIsRaid(self) + local db = raid and DF:GetRaidDB() or DF:GetDB() + local w, h = GetSetFrameSize(set, db) + + -- Move the container the user currently sees: the TEST container in test mode + -- (live containers are hidden then), otherwise the LIVE container. The mover + + -- label are anchored to the container, so they follow. PositionPinnedContainer + -- pins the growth corner (size-invariant) using pos.point as the screen ref. + local container = self.testModeActive and self.testContainers[setIndex] + or self.containers[setIndex] + PositionPinnedContainer(container, set, pos, w, h) + + -- Mirror RAID-set positions to _realRaidDB so they survive auto-layout overlay + -- rebuilds. Party sets have no overlays (GetSetDB returns the real table). + if raid then + local realSet = DF._realRaidDB and DF._realRaidDB.pinnedFrames + and DF._realRaidDB.pinnedFrames.sets and DF._realRaidDB.pinnedFrames.sets[setIndex] + if realSet then + realSet.position = realSet.position or {} + realSet.position.point = pos.point or GetContainerAnchorPoint(set) + realSet.position.x = pos.x + realSet.position.y = pos.y end - self.pendingUnlock = nil end end @@ -2322,7 +2567,6 @@ local function MakeDefaultSet(index) horizontalSpacing = 2, verticalSpacing = 2, position = { point = "CENTER", x = 0, y = 250 - (index - 1) * 130 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -2380,6 +2624,18 @@ function PinnedFrames:RemoveSet(setIndex, mode) table.remove(pf.sets, setIndex) + -- The raid position mirror writes to DF._realRaidDB.pinnedFrames.sets[i] BY + -- INDEX; with an active auto-layout overlay, pf.sets is a separate deep copy, + -- so compact the real raid array too or the later sets' mirrored positions + -- desync after a remove. (When no overlay is active rsets == pf.sets and it's + -- already compacted — the identity guard avoids a double-remove.) + if mode == "raid" then + local rsets = DF._realRaidDB and DF._realRaidDB.pinnedFrames and DF._realRaidDB.pinnedFrames.sets + if rsets and rsets ~= pf.sets and rsets[setIndex] then + table.remove(rsets, setIndex) + end + end + if mode ~= GetActualMode() then return true end -- inactive mode: DB only if InCombatLockdown() then local c = self.containers[setIndex] @@ -2639,7 +2895,6 @@ function PinnedFrames:DebugPrint() print(" === Set " .. i .. " ===") if set then print(" Enabled:", tostring(set.enabled)) - print(" Locked:", tostring(set.locked)) print(" ShowLabel:", tostring(set.showLabel)) print(" Name:", set.name or "(nil)") print(" Players in set:", #set.players) @@ -2763,7 +3018,8 @@ local function GetPinnedDBForMode(isRaidMode) end -- Returns a set's config from the specified mode's profile. -local function GetSetDBForMode(setIndex, isRaidMode) +-- (Local forward-declared above so GetSetForPosition can call it.) +function GetSetDBForMode(setIndex, isRaidMode) local hlDB = GetPinnedDBForMode(isRaidMode) return hlDB and hlDB.sets and hlDB.sets[setIndex] end @@ -2826,21 +3082,27 @@ end -- frames live during test mode by dragging this handle — updates the -- TEST MODE'S profile set.position (raid profile when raid test is on). -- Themed with GetModeColors so raid test uses orange, party test uses blue. -local function AttachTestMover(container, set, isRaidMode) - -- Mover is hidden when the set is locked (matches real pinned mover behavior) - local shouldShow = not set.locked +local function AttachTestMover(container, set, isRaidMode, setIndex) + -- The test drag handle obeys the global unlock, exactly like the live pinned + -- movers and the main frames: test mode shows the preview frames, but you must + -- UNLOCK to drag them. SetMoversShown re-syncs these when the lock toggles. + local shouldShow = PinnedFrames.moversShown == true if container.testMover then -- Refresh refs + theme colors in case mode flipped - container.testMover.dfSet = set - container.testMover.dfIsRaidMode = isRaidMode + local tm = container.testMover + tm.dfSet = set + tm.dfIsRaidMode = isRaidMode + tm.dfSetIndex = setIndex local colors = GetModeColors(isRaidMode) - container.testMover.bg:SetColorTexture(unpack(colors.moverBg)) - container.testMover.borderTex:SetColorTexture(unpack(colors.moverBorder)) - container.testMover.inner:SetColorTexture(unpack(colors.moverBg)) - container.testMover.text:SetTextColor(unpack(colors.moverText)) - container.testMover.text:SetText((isRaidMode and "Raid" or "Party") .. " Test — Drag") - container.testMover:SetShown(shouldShow) + tm.bg:SetColorTexture(unpack(colors.moverBg)) + tm.text:SetText(PinnedSetLabel(set, setIndex, isRaidMode)) + -- Re-theme through the styler so hover/active states pick up the new + -- mode's accent; restyle re-applies border/inner/text for the current state. + tm.dfColors = colors + if tm.dfRestyle then tm.dfRestyle() end + if tm.dfFitWidth then tm.dfFitWidth() end -- label width may have changed + tm:SetShown(shouldShow) return end @@ -2866,18 +3128,40 @@ local function AttachTestMover(container, set, isRaidMode) mover.text = mover:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") mover.text:SetPoint("CENTER") - mover.text:SetText((isRaidMode and "Raid" or "Party") .. " Test — Drag") + mover.text:SetText(PinnedSetLabel(set, setIndex, isRaidMode)) mover.text:SetTextColor(unpack(colors.moverText)) + -- Hover highlight + tooltip + active-state styling (reads as clickable). + StylePinnedHandle(mover, mover.borderTex, mover.inner, mover.text, colors) + mover:EnableMouse(true) mover:RegisterForDrag("LeftButton") + mover.dfSetIndex = setIndex + + -- Clicking (or finishing a drag on) the test handle points the shared position + -- panel at this set, matching the live mover. + mover:HookScript("OnMouseUp", function(self, button) + if button == "LeftButton" and DF.SetPositionPanelMode and self.dfSetIndex then + DF.positionPanelPinnedSet = self.dfSetIndex + DF:SetPositionPanelMode("pinned") + end + end) - local startMouseX, startMouseY, startPosX, startPosY + local startMouseX, startMouseY, startPosX, startPosY, dragRef, dragW, dragH mover:SetScript("OnDragStart", function(self) local currentSet = self.dfSet if not currentSet then return end - local dragAnchor = GetContainerAnchorPoint(currentSet) + -- Point the position panel at this set so it tracks the drag live. + if DF.SetPositionPanelMode and self.dfSetIndex then + DF.positionPanelPinnedSet = self.dfSetIndex + DF:SetPositionPanelMode("pinned") + end + -- Keep the set's existing anchor reference + capture frame size, so the + -- helper pins the growth corner consistently (matches the live mover). + dragRef = (currentSet.position and currentSet.position.point) or GetContainerAnchorPoint(currentSet) + local ddb = self.dfIsRaidMode and DF:GetRaidDB() or DF:GetDB() + dragW, dragH = GetSetFrameSize(currentSet, ddb) local uiScale = UIParent:GetEffectiveScale() startMouseX, startMouseY = GetCursorPosition() startMouseX = startMouseX / uiScale @@ -2892,9 +3176,15 @@ local function AttachTestMover(container, set, isRaidMode) my = my / ps local newX = startPosX + (mx - startMouseX) local newY = startPosY + (my - startMouseY) - local s = container:GetScale() or 1 - container:ClearAllPoints() - container:SetPoint(dragAnchor, UIParent, dragAnchor, newX / s, newY / s) + -- Snap to grid when pinned snap is enabled (its own flag, default off). + local sdb = DF.GetDB and DF:GetDB() + if sdb and sdb.pinnedSnapToGrid and DF.SnapToGrid then + newX, newY = DF:SnapToGrid(newX, newY) + end + -- Track the live drag in the DB + panel so the X/Y readouts update. + currentSet.position = { point = dragRef, x = newX, y = newY } + PositionPinnedContainer(container, currentSet, currentSet.position, dragW, dragH) + if DF.UpdatePositionPanel then DF:UpdatePositionPanel() end end) end) @@ -2903,17 +3193,32 @@ local function AttachTestMover(container, set, isRaidMode) if not startMouseX then return end local currentSet = self.dfSet if not currentSet then return end - local dragAnchor = GetContainerAnchorPoint(currentSet) + local anchor = dragRef or GetContainerAnchorPoint(currentSet) local uiScale = UIParent:GetEffectiveScale() local mx, my = GetCursorPosition() mx = mx / uiScale my = my / uiScale local finalX = startPosX + (mx - startMouseX) local finalY = startPosY + (my - startMouseY) - currentSet.position = { point = dragAnchor, x = finalX, y = finalY } - local s = container:GetScale() or 1 - container:ClearAllPoints() - container:SetPoint(dragAnchor, UIParent, dragAnchor, finalX / s, finalY / s) + -- Snap to grid when pinned snap is enabled (its own flag, default off). + local sdb = DF.GetDB and DF:GetDB() + if sdb and sdb.pinnedSnapToGrid and DF.SnapToGrid then + finalX, finalY = DF:SnapToGrid(finalX, finalY) + end + currentSet.position = { point = anchor, x = finalX, y = finalY } + PositionPinnedContainer(container, currentSet, currentSet.position, dragW, dragH) + + -- Persist raid-set drags through to _realRaidDB (survives overlay rebuilds; + -- position is never overlay-overridable). Party sets need no mirror. + if self.dfIsRaidMode then + local realSet = DF._realRaidDB and DF._realRaidDB.pinnedFrames + and DF._realRaidDB.pinnedFrames.sets and DF._realRaidDB.pinnedFrames.sets[self.dfSetIndex] + if realSet then + realSet.position = { point = anchor, x = finalX, y = finalY } + end + end + + if DF.UpdatePositionPanel then DF:UpdatePositionPanel() end end) mover:SetShown(shouldShow) @@ -2941,23 +3246,16 @@ function PinnedFrames:EnsureTestContainer(setIndex, set, isRaidMode) local frameWidth, frameHeight = GetSetFrameSize(set, db) container:SetSize(frameWidth, frameHeight) - -- Use the SAVED anchor point (pos.point) first — that's what the user - -- dragged the set to. Only fall back to GetContainerAnchorPoint (derived - -- from grow-direction settings) if the set has never been positioned. - -- Mismatching these puts the container off-screen: e.g. anchoring at - -- TOPLEFT but using (x=0, y=200) that was saved for CENTER. + -- Position identically to the live path (PositionPinnedContainer): pin the + -- growth corner so the testCount-sized preview lands where the real (visible- + -- count-sized) frames will, with pos.point as the screen reference. local pos = set.position or {} - local anchor = pos.point or GetContainerAnchorPoint(set) local scale = GetSetScale(set, db) -- mode-explicit: cross-mode test previews container:SetScale(scale) - container:ClearAllPoints() - container:SetPoint( - anchor, UIParent, anchor, - (pos.x or 0) / scale, (pos.y or 0) / scale - ) + PositionPinnedContainer(container, set, pos, frameWidth, frameHeight) container:Show() - AttachTestMover(container, set, isRaidMode) + AttachTestMover(container, set, isRaidMode, setIndex) -- Dedicated test label (parented to UIParent for scale independence). -- Anchored to the test container so it follows the test mover when @@ -3257,14 +3555,22 @@ function PinnedFrames:ExitTestMode() if set then local realContainer = self.containers[setIndex] if realContainer then + -- Re-apply the saved position: it may have changed during test mode + -- (e.g. the user dragged the test frame). The live container was last + -- placed at create/layout time, so without this it keeps the stale + -- spot until a /reload re-creates it. + if set.position then + local cw, ch = GetSetFrameSize(set, GetPinnedModeDB()) + PositionPinnedContainer(realContainer, set, set.position, cw, ch) + end if realContainer.mover then - realContainer.mover:SetShown(visible and not set.locked) + realContainer.mover:SetShown(visible and self.moversShown) end if realContainer.bg then - realContainer.bg:SetShown(visible and not set.locked) + realContainer.bg:SetShown(visible and self.moversShown) end if realContainer.border then - realContainer.border:SetShown(visible and not set.locked) + realContainer.border:SetShown(visible and self.moversShown) end end local realLabel = self.labels[setIndex] diff --git a/Frames/Init.lua b/Frames/Init.lua index d2b5a794..f069b610 100644 --- a/Frames/Init.lua +++ b/Frames/Init.lua @@ -951,7 +951,14 @@ function DF:UnlockRaidFrames() -- Enable raid test mode using the proper function DF:ShowRaidTestFrames() - + + -- Pinned frames ride the global lock: show their drag chrome too. No mode + -- gate — SetMoversShown shows the right handles (live movers for the current + -- mode, or the test movers when test mode is previewing either mode). + if DF.PinnedFrames and DF.PinnedFrames.SetMoversShown then + DF.PinnedFrames:SetMoversShown(true) + end + -- Show position panel (shared with party) and update its values from db if DF.positionPanel then DF:UpdatePositionPanel() @@ -1008,7 +1015,12 @@ function DF:LockRaidFrames() if DF.positionPanel then DF.positionPanel:Hide() end - + + -- Hide pinned drag chrome (lock always hides, regardless of mode). + if DF.PinnedFrames and DF.PinnedFrames.SetMoversShown then + DF.PinnedFrames:SetMoversShown(false) + end + -- Only disable raid test mode if it was not already active before the last unlock. -- Preserves the user's test mode state across the lock/unlock cycle. if not DF.raidTestModeBeforeUnlock then diff --git a/Frames/Position.lua b/Frames/Position.lua index 28fdd761..73361254 100644 --- a/Frames/Position.lua +++ b/Frames/Position.lua @@ -50,6 +50,13 @@ end -- any mover switches the mode; the panel then re-reads from the -- descriptor's db fields and nudge operations write back to them. +-- Resolve the pinned set the panel is currently targeting (current live mode, +-- auto-layout-overlay aware — same table the runtime and drag handler use). +local function ResolvePinnedSet() + if not (DF.PinnedFrames and DF.PinnedFrames.GetSetForPosition) then return nil end + return DF.PinnedFrames:GetSetForPosition(DF.positionPanelPinnedSet or 1) +end + local POSITION_MODES = { party = { title = "Party Position", @@ -106,6 +113,52 @@ local POSITION_MODES = { end, useAccentColor = "accent", }, + pinned = { + -- Title + accent track the targeted set's name and mode, e.g. + -- "NPC (Raid) - Position" in raid orange. + title = function() + local i = DF.positionPanelPinnedSet or 1 + local pf = DF.PinnedFrames + local label = pf and pf.GetPositionPanelLabel and pf:GetPositionPanelLabel(i) + return (label or ("Pinned " .. i)) .. " - Position" + end, + -- Pinned X/Y live NESTED in set.position = {point, x, y}, unlike the flat + -- db fields the other modes use. Return a thin proxy so the shared panel + -- code (which does db[xField] / db[yField]) keeps working unchanged: x/y + -- map to the set's position, and every other key (snapToGrid, gridSize, + -- hideDragOverlay…) falls through to the party db where those live. + getDB = function() + local set = ResolvePinnedSet() + local partyDB = DF:GetDB() + if not set then return partyDB end + set.position = set.position or { point = "CENTER", x = 0, y = 0 } + local pos = set.position + return setmetatable({}, { + __index = function(_, k) + if k == "x" then return pos.x + elseif k == "y" then return pos.y + else return partyDB[k] end + end, + __newindex = function(_, k, v) + if k == "x" then pos.x = v + elseif k == "y" then pos.y = v + else partyDB[k] = v end + end, + }) + end, + xField = "x", + yField = "y", + apply = function() + if DF.PinnedFrames and DF.PinnedFrames.ApplySetPosition then + DF.PinnedFrames:ApplySetPosition(DF.positionPanelPinnedSet or 1) + end + end, + -- Raid orange when the targeted set is a raid set, party accent otherwise. + useAccentColor = function() + return (DF.PinnedFrames and DF.PinnedFrames.IsPositionTargetRaid + and DF.PinnedFrames:IsPositionTargetRaid()) and "raid" or "accent" + end, + }, } -- Accessor helper. Defaults to party mode if something's off. @@ -113,6 +166,30 @@ local function GetPositionMode() return POSITION_MODES[DF.positionPanelMode] or POSITION_MODES.party end +-- Whether snap-to-grid is on for the CURRENTLY targeted panel mode. Mirrors the +-- panel checkbox: pinned uses its own flag, every other mode the shared one. +local function SnapEnabledForPanel() + local db = DF:GetDB() + if not db then return false end + if DF.positionPanelMode == "pinned" then return db.pinnedSnapToGrid and true or false end + return db.snapToGrid and true or false +end + +-- Show or hide the grid overlay to match the active mode's snap state. Called on +-- panel mode switches so the grid follows pinned-vs-main snap. Only acts while the +-- panel is open (unlocked); locking hides the grid via Lock*Frames. +local function SyncGridToPanelMode() + if not DF.gridFrame then return end + if not (DF.positionPanel and DF.positionPanel:IsShown()) then return end + if SnapEnabledForPanel() then + DF.gridFrame:Show() + if DF.gridFrame.RefreshLines then DF.gridFrame.RefreshLines() end + else + DF.gridFrame:Hide() + DF:HideSnapPreview() + end +end + -- Exposed so the movers (personal targeted spells, targeted list) -- can switch the panel target when clicked. function DF:SetPositionPanelMode(mode) @@ -122,6 +199,11 @@ function DF:SetPositionPanelMode(mode) if DF.positionPanel.UpdateTheme then DF.positionPanel:UpdateTheme() end DF:UpdatePositionPanel() end + SyncGridToPanelMode() + -- Highlight whichever pinned handle the panel now drives (or clear them). + if DF.PinnedFrames and DF.PinnedFrames.RefreshMoverActiveStates then + DF.PinnedFrames:RefreshMoverActiveStates() + end end function DF:CreateMoverFrame() @@ -1285,6 +1367,16 @@ end function DF:SnapToGrid(x, y) -- Grid settings are party-wide; edge-snap container depends on mode. local db = DF:GetDB() + + -- Pinned uses a POINT anchor (x/y is the first frame's position, not a centred + -- box), so the centre+edge-snapping model below would mis-place it. Snap the + -- coordinate itself to the nearest grid line. + if DF.positionPanelMode == "pinned" then + local gridSize = db.gridSize or 20 + return math.floor((x / gridSize) + 0.5) * gridSize, + math.floor((y / gridSize) + 0.5) * gridSize + end + local container if DF.positionPanelMode == "raid" then container = DF.raidContainer @@ -1369,7 +1461,9 @@ function DF:CreatePositionPanel() -- else shares the party accent. local function GetAccentColor() local mode = GetPositionMode() - if mode.useAccentColor == "raid" then return C_RAID end + local accent = mode.useAccentColor + if type(accent) == "function" then accent = accent() end + if accent == "raid" then return C_RAID end return C_ACCENT end @@ -1385,6 +1479,24 @@ function DF:CreatePositionPanel() local function LockCurrentFrames() if DF.positionPanelMode == "raid" then DF:LockRaidFrames() + elseif DF.positionPanelMode == "pinned" then + -- Pinned rides the global lock, so its Lock button locks EVERYTHING + -- that's currently unlocked — not just the live mode. (You can unlock + -- raid frames while solo to preview a raid set; locking from the pinned + -- panel must then close the raid unlock too, or it's left orphaned.) + local lockedAny = false + local rdb = DF.GetRaidDB and DF:GetRaidDB() + if rdb and not rdb.raidLocked then DF:LockRaidFrames(); lockedAny = true end + local pdb = DF:GetDB() + if pdb and not pdb.locked then DF:LockFrames(); lockedAny = true end + if not lockedAny then + -- Nothing flagged unlocked (e.g. test-mode-only) — fall back to live mode. + if DF.PinnedFrames and DF.PinnedFrames.currentMode == "raid" then + DF:LockRaidFrames() + else + DF:LockFrames() + end + end else DF:LockFrames() end @@ -1436,9 +1548,12 @@ function DF:CreatePositionPanel() elem:UpdateThemeColor(c) end end - -- Update title text based on mode (read from descriptor) + -- Update title text based on mode (read from descriptor; a descriptor may + -- supply a function for a dynamic title, e.g. the pinned set number). if panel.title then - panel.title:SetText(GetPositionMode().title or "Position") + local t = GetPositionMode().title + if type(t) == "function" then t = t() end + panel.title:SetText(t or "Position") end end panel.UpdateTheme = UpdateTheme @@ -1734,9 +1849,16 @@ function DF:CreatePositionPanel() snapLabel:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) snapCheck:SetScript("OnClick", function(self) - local db = GetPositionDB() - db.snapToGrid = self:GetChecked() - if db.snapToGrid then + local checked = self:GetChecked() + -- Pinned snap is its own party-db flag (independent of the main-frame snap, + -- default off); other modes use the shared snapToGrid. + if DF.positionPanelMode == "pinned" then + local pdb = DF:GetDB() + if pdb then pdb.pinnedSnapToGrid = checked end + else + GetPositionDB().snapToGrid = checked + end + if checked then if DF.gridFrame.RefreshLines then DF.gridFrame:Show() DF.gridFrame.RefreshLines() @@ -1775,10 +1897,21 @@ function DF:CreatePositionPanel() hideOverlayLabel:SetPoint("LEFT", hideOverlayCheck, "RIGHT", 8, 0) hideOverlayLabel:SetText("Hide Drag Overlay") hideOverlayLabel:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + panel.hideOverlayLabel = hideOverlayLabel hideOverlayCheck:SetScript("OnClick", function(self) - DF.hideDragOverlay = self:GetChecked() - -- Store on the party db (grid / overlay settings are party-wide) + local checked = self:GetChecked() + -- Pinned uses its own flag (independent of the main-frame drag overlay); + -- every other mode shares the party-wide hideDragOverlay. + if DF.positionPanelMode == "pinned" then + local pdb = DF:GetDB() + if pdb then pdb.pinnedHideMover = checked end + if DF.PinnedFrames and DF.PinnedFrames.ApplyMoverOverlayAlpha then + DF.PinnedFrames:ApplyMoverOverlayAlpha() + end + return + end + DF.hideDragOverlay = checked local partyDB = DF:GetDB() if partyDB then partyDB.hideDragOverlay = DF.hideDragOverlay end -- Apply alpha to whichever mover matches the current mode. @@ -1951,11 +2084,26 @@ function DF:UpdatePositionPanel() -- read from the party db regardless of panel mode. Personal and -- Targeted List share the party profile's grid config. local partyDB = DF:GetDB() - DF.positionPanel.snapCheck:SetChecked(partyDB.snapToGrid) + -- Pinned mode shows its own snap flag (default off); other modes the shared one. + if DF.positionPanelMode == "pinned" then + DF.positionPanel.snapCheck:SetChecked(partyDB.pinnedSnapToGrid or false) + else + DF.positionPanel.snapCheck:SetChecked(partyDB.snapToGrid) + end DF.positionPanel.gridSlider:SetValue(partyDB.gridSize or 20) DF.positionPanel.gridInput:SetText(tostring(partyDB.gridSize or 20)) if DF.positionPanel.hideOverlayCheck then - DF.positionPanel.hideOverlayCheck:SetChecked(partyDB.hideDragOverlay or false) + if DF.positionPanelMode == "pinned" then + DF.positionPanel.hideOverlayCheck:SetChecked(partyDB.pinnedHideMover or false) + else + DF.positionPanel.hideOverlayCheck:SetChecked(partyDB.hideDragOverlay or false) + end + end + -- Pinned sets have a small handle rather than a full drag overlay box, so the + -- toggle reads "Hide Mover" there. + if DF.positionPanel.hideOverlayLabel then + DF.positionPanel.hideOverlayLabel:SetText( + DF.positionPanelMode == "pinned" and "Hide Mover" or "Hide Drag Overlay") end -- Update position override indicator if editing profile @@ -2031,9 +2179,10 @@ function DF:NudgePosition(dx, dy) end function DF:CenterFrames() - -- Personal / targetedList: simple reset to 0,0 since these don't - -- have growth geometry to account for. - if DF.positionPanelMode == "personal" or DF.positionPanelMode == "targetedList" then + -- Personal / targetedList / pinned: simple reset to 0,0 since these don't + -- have main-frame growth geometry to account for. + if DF.positionPanelMode == "personal" or DF.positionPanelMode == "targetedList" + or DF.positionPanelMode == "pinned" then local mode = GetPositionMode() local db = mode.getDB() if db then @@ -2293,7 +2442,14 @@ function DF:UnlockFrames() if db.targetedListEnabled and DF.ShowTargetedListMover then DF:ShowTargetedListMover() end - + + -- Pinned frames ride the global lock: show their drag chrome too. No mode + -- gate — SetMoversShown shows the right handles (live movers for the current + -- mode, or the test movers when test mode is previewing either mode). + if DF.PinnedFrames and DF.PinnedFrames.SetMoversShown then + DF.PinnedFrames:SetMoversShown(true) + end + -- Always refresh grid state from db when unlocking if DF.gridFrame then if db.snapToGrid then @@ -2361,7 +2517,12 @@ function DF:LockFrames() if DF.HideTargetedListMover then DF:HideTargetedListMover() end - + + -- Hide pinned drag chrome (lock always hides, regardless of mode). + if DF.PinnedFrames and DF.PinnedFrames.SetMoversShown then + DF.PinnedFrames:SetMoversShown(false) + end + -- Stop any OnUpdate for snap preview DF.moverFrame:SetScript("OnUpdate", nil) diff --git a/Options/AutoProfiles.lua b/Options/AutoProfiles.lua index 6a09cdd3..33fc0943 100644 --- a/Options/AutoProfiles.lua +++ b/Options/AutoProfiles.lua @@ -2798,13 +2798,12 @@ function AutoProfilesUI:EnterEditing(contentType, profileIndex) -- IMPORTANT: Iterate PINNED_OVERRIDABLE keys rather than set keys to ensure -- we capture ALL overridable settings, even ones that are nil due to missing migration. -- If a key is nil, backfill a default so both the snapshot and set are consistent. + -- Only `enabled` is overridable (PINNED_OVERRIDABLE = {enabled}), and the loop + -- below reads PINNED_DEFAULTS only for the keys it iterates — so this table needs + -- just `enabled`. (It previously listed ~13 dead keys, incl. a keepOfflinePlayers + -- default that contradicted the real Config/Options default.) local PINNED_DEFAULTS = { - enabled = false, locked = false, showLabel = false, - growDirection = "HORIZONTAL", unitsPerRow = 5, scale = 1.0, - horizontalSpacing = 2, verticalSpacing = 2, - frameAnchor = "START", columnAnchor = "START", - autoAddTanks = false, autoAddHealers = false, autoAddDPS = false, - keepOfflinePlayers = true, players = {}, + enabled = false, } local pinnedFrames = DF._realRaidDB and DF._realRaidDB.pinnedFrames if pinnedFrames and pinnedFrames.sets then diff --git a/Options/Options.lua b/Options/Options.lua index af85f6cb..e871bffc 100644 --- a/Options/Options.lua +++ b/Options/Options.lua @@ -2107,7 +2107,7 @@ function DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) growDirection = "HORIZONTAL", unitsPerRow = 5, horizontalSpacing = 2, verticalSpacing = 2, scale = 1.0, position = { point = "CENTER", x = 0, y = 200 }, - locked = false, showLabel = false, + showLabel = false, autoAddTanks = false, autoAddHealers = false, autoAddDPS = false, keepOfflinePlayers = false, }, @@ -2116,7 +2116,7 @@ function DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) growDirection = "HORIZONTAL", unitsPerRow = 5, horizontalSpacing = 2, verticalSpacing = 2, scale = 1.0, position = { point = "CENTER", x = 0, y = -200 }, - locked = false, showLabel = false, + showLabel = false, autoAddTanks = false, autoAddHealers = false, autoAddDPS = false, keepOfflinePlayers = false, }, @@ -2141,7 +2141,12 @@ function DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) if set.keepOfflinePlayers == nil then set.keepOfflinePlayers = false end if set.columnAnchor == nil then set.columnAnchor = "START" end if set.frameAnchor == nil then set.frameAnchor = "START" end - if set.locked == nil then set.locked = false end + -- CENTER anchor was dropped (never truly centred the frames; it + -- rendered as START). Normalise so the dropdown has a valid value. + if set.columnAnchor == "CENTER" then set.columnAnchor = "START" end + if set.frameAnchor == "CENTER" then set.frameAnchor = "START" end + -- set.locked retired (global lock only); strip the dead field. + set.locked = nil if set.showLabel == nil then set.showLabel = false end if set.players == nil then set.players = {} end if set.manualPlayers == nil then set.manualPlayers = {} end @@ -2737,8 +2742,8 @@ function DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) container.Refresh = function() cb:SetChecked(GetCurrentSet()[dbKey]) -- Optional disabled state: when container.enabledWhen() is false the - -- checkbox is greyed and can't be toggled (e.g. Show Label is moot - -- until Lock Position is on, since the drag label shows instead). + -- checkbox is greyed and can't be toggled (used where one toggle is + -- only meaningful while another option is in a particular state). if container.enabledWhen then if container.enabledWhen() then cb:Enable() @@ -3309,7 +3314,7 @@ function DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) end settingsGroup:AddWidget(pinnedLayoutNote, pinnedLayoutNote.layoutHeight or 44) - -- SetEnabled / SetLocked / SetShowLabel internally use GetSetDB → IsInRaid(), + -- SetEnabled / SetShowLabel internally use GetSetDB → IsInRaid(), -- so calling them while editing the inactive mode would mutate the active -- mode's state. Only call them when the selected mode matches the live mode; -- otherwise the DB write from the checkbox itself is enough and the preview @@ -3337,19 +3342,10 @@ function DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) RefreshTestModeIfActive() RefreshTabs() -- update the on/off pip on this set's tab end), 28) - -- Forward ref so Lock Position can re-grey Show Label when toggled. - local showLabelCheck - settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Lock Position"], "locked", function() - if not DF.PinnedFrames then return end - if IsEditingActiveMode() then - DF.PinnedFrames:SetLocked(activeHighlightTab, GetCurrentSet().locked) - end - DF.PinnedFrames:UpdatePreviewSet(activeHighlightTab) - RefreshTestModeIfActive() - -- Show Label only matters while locked (otherwise the drag label shows). - if showLabelCheck and showLabelCheck.Refresh then showLabelCheck.Refresh() end - end), 28) - showLabelCheck = settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Show Label"], "showLabel", function() + -- Pinned frames now lock/unlock together with the main frames (global + -- lock), so there is no per-set Lock Position toggle. Show Label is always + -- editable; the "Drag to Move" handle only appears while globally unlocked. + settingsGroup:AddWidget(CreateRefreshableCheckbox(self.child, L["Show Label"], "showLabel", function() if not DF.PinnedFrames then return end if IsEditingActiveMode() then DF.PinnedFrames:SetShowLabel(activeHighlightTab, GetCurrentSet().showLabel) @@ -3357,10 +3353,6 @@ function DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) DF.PinnedFrames:UpdatePreviewSet(activeHighlightTab) RefreshTestModeIfActive() end), 28) - -- Grey out Show Label while unlocked — the "Drag to Move" label shows then, - -- not the set's label, so the toggle has no visible effect. - showLabelCheck.enabledWhen = function() return GetCurrentSet().locked == true end - if showLabelCheck.Refresh then showLabelCheck.Refresh() end -- Party-only: show this pinned set while solo (off by default — pinned -- frames highlight other group members). Raid implies a group, so hide it @@ -3748,10 +3740,13 @@ function DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) local directionOptions = { HORIZONTAL= L["Horizontal"], VERTICAL= L["Vertical"] } arrangeGroup:AddWidget(CreateRefreshableDropdown(self.child, L["Direction"], directionOptions, "growDirection", UpdateHighlightLayout), 55) - local frameAnchorOptions = { START= L["Start (Left/Top)"], CENTER= L["Center"], END= L["End (Right/Bottom)"] } + -- CENTER intentionally omitted: it isn't truly implemented for pinned + -- frames (frames grow START-style; only the anchor/label shift). START/END + -- only for now; a real centred layout can be added later. + local frameAnchorOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } arrangeGroup:AddWidget(CreateRefreshableDropdown(self.child, L["Frame Growth"], frameAnchorOptions, "frameAnchor", UpdateHighlightLayout), 55) - local columnAnchorOptions = { START= L["Start (Left/Top)"], CENTER= L["Center"], END= L["End (Right/Bottom)"] } + local columnAnchorOptions = { START= L["Start (Left/Top)"], END= L["End (Right/Bottom)"] } arrangeGroup:AddWidget(CreateRefreshableDropdown(self.child, L["Column Growth"], columnAnchorOptions, "columnAnchor", UpdateHighlightLayout), 55) arrangeGroup:AddWidget(CreateRefreshableSlider(self.child, L["Units Per Row"], 1, 10, 1, "unitsPerRow", UpdateHighlightLayout), 55) diff --git a/Profile.lua b/Profile.lua index 634532d2..f4a999dc 100644 --- a/Profile.lua +++ b/Profile.lua @@ -547,12 +547,12 @@ function DF:ExportProfile(categories, frameTypes, profileName) exportData.auraBlacklist = DF:DeepCopy(DF.db.auraBlacklist) end -- Designer preset libraries: travel with their own categories (the - -- mode tables only carry preset NAME refs). autoLayout also pulls both - -- in — layout overrides may reference presets by name. - if (categorySet.auraDesigner or categorySet.autoLayout) and DF.db.auraDesignerPresets then + -- mode tables only carry preset NAME refs). autoLayout AND pinnedFrames + -- also pull both in — their overrides/sets reference presets by name. + if (categorySet.auraDesigner or categorySet.autoLayout or categorySet.pinnedFrames) and DF.db.auraDesignerPresets then exportData.auraDesignerPresets = StripInternalKeys(DF:DeepCopy(DF.db.auraDesignerPresets)) end - if (categorySet.text or categorySet.autoLayout) and DF.db.textDesignerPresets then + if (categorySet.text or categorySet.autoLayout or categorySet.pinnedFrames) and DF.db.textDesignerPresets then exportData.textDesignerPresets = StripInternalKeys(DF:DeepCopy(DF.db.textDesignerPresets)) end end