From 4dd2f71104bf8c6d9a2b62a1c358cdf6a7912013 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 16 Jun 2026 13:24:14 +0100 Subject: [PATCH 1/8] Pinned frames: ride the global lock + per-set position panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinned frames no longer have their own Lock Position toggle — they now lock/unlock together with the main party/raid frames (global lock only). Unlocking shows each set's drag handle; locking hides them all. The per-set lock field is retired and migrated away. Each set's handle also opens the same nudge position panel as party/raid (precise X/Y, Center, Reset), titled with the set name and mode and themed raid-orange for raid sets. Pinned gets its own Snap to Grid and Hide Mover, independent of the main frames and off by default. Handles read as interactive: hover highlight + tooltip, the targeted set's handle is solid while the others dim, and handles auto-size to their label. --- Changelog.lua | 1 + Config.lua | 8 - Features/PinnedFrames.lua | 457 +++++++++++++++++++++++++++++--------- Frames/Init.lua | 16 +- Frames/Position.lua | 188 ++++++++++++++-- Options/AutoProfiles.lua | 2 +- Options/Options.lua | 30 +-- 7 files changed, 553 insertions(+), 149 deletions(-) diff --git a/Changelog.lua b/Changelog.lua index acf1f271..11495bd9 100644 --- a/Changelog.lua +++ b/Changelog.lua @@ -19,6 +19,7 @@ DF.CHANGELOG_TEXT = [===[ ### 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..151608bc 100644 --- a/Config.lua +++ b/Config.lua @@ -2315,7 +2315,6 @@ DF.PartyDefaults = { horizontalSpacing = 2, verticalSpacing = 2, position = { point = "CENTER", x = 0, y = 200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -2336,7 +2335,6 @@ DF.PartyDefaults = { horizontalSpacing = 2, verticalSpacing = 2, position = { point = "CENTER", x = 0, y = -200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -2362,7 +2360,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 +2379,6 @@ DF.PartyDefaults = { verticalSpacing = 2, scale = 1.0, position = { point = "CENTER", x = 0, y = -200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -3820,7 +3816,6 @@ DF.RaidDefaults = { horizontalSpacing = 2, verticalSpacing = 2, position = { point = "CENTER", x = 0, y = 200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -3841,7 +3836,6 @@ DF.RaidDefaults = { horizontalSpacing = 2, verticalSpacing = 2, position = { point = "CENTER", x = 0, y = -200 }, - locked = false, showLabel = false, columnAnchor = "START", frameAnchor = "START", @@ -3867,7 +3861,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 +3880,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/Features/PinnedFrames.lua b/Features/PinnedFrames.lua index 76aa4d5a..c7208a65 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 @@ -1181,7 +1264,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 +1274,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,13 +1299,25 @@ 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") - + + -- 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 local startMouseX, startMouseY, startPosX, startPosY @@ -1232,7 +1327,15 @@ function PinnedFrames:CreateSetFrames(setIndex) -- 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 + + -- Point the position panel at this set so it tracks the drag live. + if DF.SetPositionPanelMode then + DF.positionPanelPinnedSet = setIndex + DF:SetPositionPanelMode("pinned") + end -- Get the current anchor for this set local anchor = GetContainerAnchorPoint(liveSet) @@ -1260,10 +1363,19 @@ function PinnedFrames:CreateSetFrames(setIndex) local newX = startPosX + deltaX local newY = 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 + newX, newY = DF:SnapToGrid(newX, newY) + end + -- 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) + -- Track the live drag in the DB + panel so the X/Y readouts update. + liveSet.position = { point = anchor, x = newX, y = newY } + if DF.UpdatePositionPanel then DF:UpdatePositionPanel() end end) end) @@ -1289,6 +1401,12 @@ 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 } @@ -1309,6 +1427,8 @@ function PinnedFrames:CreateSetFrames(setIndex) container:ClearAllPoints() container:SetPoint(anchor, UIParent, anchor, finalX / s, finalY / s) + 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 -- may or may not be using this mode's config, but refreshing is cheap @@ -1319,8 +1439,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 +1467,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 +1527,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() @@ -2008,8 +2128,11 @@ function PinnedFrames:SetEnabled(setIndex, enabled) self:UpdateLabel(setIndex) if label then label:SetShown(set.showLabel) end - if container.mover and not set.locked then - container.mover:SetShown(true) + -- 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 self:RefreshChildFrames(setIndex) @@ -2018,6 +2141,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 +2169,172 @@ 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) + 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 + +-- 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 + + self.moversShownBeforeCombat = self.moversShown + if self.moversShown then + -- SetMoversShown(false) hides every set's chrome; safe in combat. + self:SetMoversShown(false) + DF:Debug("PINNED", "Pinned movers hidden for combat") + end end --- Restore unlock state after combat +-- 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 anchor = pos.point or GetContainerAnchorPoint(set) + + -- 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. + local container = self.testModeActive and self.testContainers[setIndex] + or self.containers[setIndex] + if container then + local s = container:GetScale() or 1 + container:ClearAllPoints() + container:SetPoint(anchor, UIParent, anchor, (pos.x or 0) / s, (pos.y or 0) / s) + end + + -- Mirror RAID-set positions to _realRaidDB so they survive auto-layout overlay + -- rebuilds. Party sets have no overlays (GetSetDB returns the real table). + if PositionTargetIsRaid(self) 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 = anchor + realSet.position.x = pos.x + realSet.position.y = pos.y end - self.pendingUnlock = nil end end @@ -2322,7 +2527,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", @@ -2639,7 +2843,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 +2966,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 +3030,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,17 +3076,35 @@ 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 mover:SetScript("OnDragStart", function(self) local currentSet = self.dfSet if not currentSet then return end + -- 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 local dragAnchor = GetContainerAnchorPoint(currentSet) local uiScale = UIParent:GetEffectiveScale() startMouseX, startMouseY = GetCursorPosition() @@ -2892,9 +3120,17 @@ local function AttachTestMover(container, set, isRaidMode) my = my / ps local newX = startPosX + (mx - startMouseX) local newY = startPosY + (my - startMouseY) + -- 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 local s = container:GetScale() or 1 container:ClearAllPoints() container:SetPoint(dragAnchor, UIParent, dragAnchor, newX / s, newY / s) + -- Track the live drag in the DB + panel so the X/Y readouts update. + currentSet.position = { point = dragAnchor, x = newX, y = newY } + if DF.UpdatePositionPanel then DF:UpdatePositionPanel() end end) end) @@ -2910,10 +3146,27 @@ local function AttachTestMover(container, set, isRaidMode) my = my / uiScale local finalX = startPosX + (mx - startMouseX) local finalY = startPosY + (my - startMouseY) + -- 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 = dragAnchor, x = finalX, y = finalY } local s = container:GetScale() or 1 container:ClearAllPoints() container:SetPoint(dragAnchor, UIParent, dragAnchor, finalX / s, finalY / s) + + -- 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 = dragAnchor, x = finalX, y = finalY } + end + end + + if DF.UpdatePositionPanel then DF:UpdatePositionPanel() end end) mover:SetShown(shouldShow) @@ -2957,7 +3210,7 @@ function PinnedFrames:EnsureTestContainer(setIndex, set, isRaidMode) ) 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 @@ -3258,13 +3511,13 @@ function PinnedFrames:ExitTestMode() local realContainer = self.containers[setIndex] if realContainer then 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..a1b6a990 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() @@ -1292,6 +1374,13 @@ function DF:SnapToGrid(x, y) container = DF.personalTargetedSpellsMover or DF.container elseif DF.positionPanelMode == "targetedList" then container = DF.targetedListMoverFrame or DF.container + elseif DF.positionPanelMode == "pinned" then + local pf = DF.PinnedFrames + local i = DF.positionPanelPinnedSet or 1 + container = pf and ( + (pf.testModeActive and pf.testContainers and pf.testContainers[i]) + or (pf.containers and pf.containers[i]) + ) or DF.container else container = DF.container end @@ -1369,7 +1458,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 +1476,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 +1545,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 +1846,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 +1894,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 +2081,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 +2176,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 +2439,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 +2514,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..2739d2aa 100644 --- a/Options/AutoProfiles.lua +++ b/Options/AutoProfiles.lua @@ -2799,7 +2799,7 @@ function AutoProfilesUI:EnterEditing(contentType, profileIndex) -- 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. local PINNED_DEFAULTS = { - enabled = false, locked = false, showLabel = false, + enabled = false, showLabel = false, growDirection = "HORIZONTAL", unitsPerRow = 5, scale = 1.0, horizontalSpacing = 2, verticalSpacing = 2, frameAnchor = "START", columnAnchor = "START", diff --git a/Options/Options.lua b/Options/Options.lua index af85f6cb..4b13c9f0 100644 --- a/Options/Options.lua +++ b/Options/Options.lua @@ -2141,7 +2141,8 @@ 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 + -- 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 +2738,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 +3310,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 +3338,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 +3349,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 From c3722a93da11be6158f5d02fe0895179535f3173 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 16 Jun 2026 14:50:38 +0100 Subject: [PATCH 2/8] Pinned frames: size-invariant positioning + persist test-mode moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin each pinned container by its GROWTH corner (not its saved-point corner) via a shared PositionPinnedContainer helper, with pos.point kept as the screen reference plus a half-frame offset. The first frame's screen position no longer depends on the container's frame count, so test mode (sized to testCount) and live (sized to the visible count) place the frames identically — with no migration and no jump for existing positions. Also re-apply the saved position when leaving test mode: a test-mode drag updates set.position, but the live container was only re-reading it on a /reload, so it snapped back to its old spot. ExitTestMode now repositions each live container from set.position on the way out. --- Features/PinnedFrames.lua | 170 +++++++++++++++++++++----------------- 1 file changed, 93 insertions(+), 77 deletions(-) diff --git a/Features/PinnedFrames.lua b/Features/PinnedFrames.lua index c7208a65..e632a9f3 100644 --- a/Features/PinnedFrames.lua +++ b/Features/PinnedFrames.lua @@ -804,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 -- ============================================================ @@ -1234,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) @@ -1243,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) @@ -1318,9 +1350,10 @@ function PinnedFrames:CreateSetFrames(setIndex) end end) - -- Track starting mouse and container position - local startMouseX, startMouseY, startPosX, startPosY - + -- 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 @@ -1337,8 +1370,10 @@ function PinnedFrames:CreateSetFrames(setIndex) DF:SetPositionPanelMode("pinned") end - -- Get the current anchor for this set - local anchor = GetContainerAnchorPoint(liveSet) + -- 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() @@ -1369,12 +1404,9 @@ function PinnedFrames:CreateSetFrames(setIndex) newX, newY = DF:SnapToGrid(newX, newY) end - -- 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) -- Track the live drag in the DB + panel so the X/Y readouts update. - liveSet.position = { point = anchor, x = newX, y = newY } + liveSet.position = { point = dragRef, x = newX, y = newY } + PositionPinnedContainer(container, liveSet, liveSet.position, dragW, dragH) if DF.UpdatePositionPanel then DF:UpdatePositionPanel() end end) end) @@ -1387,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() @@ -1422,10 +1454,7 @@ function PinnedFrames:CreateSetFrames(setIndex) realSet.position = { point = anchor, x = finalX, y = finalY } 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 @@ -1832,23 +1861,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 @@ -1896,18 +1915,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 @@ -2311,27 +2327,26 @@ function PinnedFrames:ApplySetPosition(setIndex) local pos = set.position if not pos then return end - local anchor = pos.point or GetContainerAnchorPoint(set) + 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. + -- 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] - if container then - local s = container:GetScale() or 1 - container:ClearAllPoints() - container:SetPoint(anchor, UIParent, anchor, (pos.x or 0) / s, (pos.y or 0) / s) - end + 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 PositionTargetIsRaid(self) then + 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 = anchor + realSet.position.point = pos.point or GetContainerAnchorPoint(set) realSet.position.x = pos.x realSet.position.y = pos.y end @@ -3095,7 +3110,7 @@ local function AttachTestMover(container, set, isRaidMode, setIndex) end end) - local startMouseX, startMouseY, startPosX, startPosY + local startMouseX, startMouseY, startPosX, startPosY, dragRef, dragW, dragH mover:SetScript("OnDragStart", function(self) local currentSet = self.dfSet @@ -3105,7 +3120,11 @@ local function AttachTestMover(container, set, isRaidMode, setIndex) DF.positionPanelPinnedSet = self.dfSetIndex DF:SetPositionPanelMode("pinned") end - local dragAnchor = GetContainerAnchorPoint(currentSet) + -- 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 @@ -3125,11 +3144,9 @@ local function AttachTestMover(container, set, isRaidMode, setIndex) if sdb and sdb.pinnedSnapToGrid and DF.SnapToGrid then newX, newY = DF:SnapToGrid(newX, newY) end - local s = container:GetScale() or 1 - container:ClearAllPoints() - container:SetPoint(dragAnchor, UIParent, dragAnchor, newX / s, newY / s) -- Track the live drag in the DB + panel so the X/Y readouts update. - currentSet.position = { point = dragAnchor, x = newX, y = newY } + currentSet.position = { point = dragRef, x = newX, y = newY } + PositionPinnedContainer(container, currentSet, currentSet.position, dragW, dragH) if DF.UpdatePositionPanel then DF:UpdatePositionPanel() end end) end) @@ -3139,7 +3156,7 @@ local function AttachTestMover(container, set, isRaidMode, setIndex) 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 @@ -3151,10 +3168,8 @@ local function AttachTestMover(container, set, isRaidMode, setIndex) if sdb and sdb.pinnedSnapToGrid and DF.SnapToGrid then finalX, finalY = DF:SnapToGrid(finalX, finalY) end - 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) + 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. @@ -3162,7 +3177,7 @@ local function AttachTestMover(container, set, isRaidMode, setIndex) 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 = dragAnchor, x = finalX, y = finalY } + realSet.position = { point = anchor, x = finalX, y = finalY } end end @@ -3194,20 +3209,13 @@ 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, setIndex) @@ -3510,6 +3518,14 @@ 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 self.moversShown) end From d3260f346e82a2014c36977d94b24c0436bae5b7 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 16 Jun 2026 14:50:38 +0100 Subject: [PATCH 3/8] Pinned frames: drop the non-functional CENTER frame/column anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CENTER never actually centred the pinned frames — they grew START-style and only the anchor/label shifted. Remove it from the Frame Growth / Column Growth dropdowns (Start / End only) and normalise existing CENTER values to START (no visual change, since CENTER already rendered as START). The layout plumbing is left in place for a proper centred mode later. --- Options/Options.lua | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Options/Options.lua b/Options/Options.lua index 4b13c9f0..52ff0df0 100644 --- a/Options/Options.lua +++ b/Options/Options.lua @@ -2141,6 +2141,10 @@ 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 + -- 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 @@ -3736,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) From 50fb8ccc0967556d3b7d06ed29a892cebc4b5056 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 16 Jun 2026 17:09:51 +0100 Subject: [PATCH 4/8] =?UTF-8?q?Pinned=20frames:=20audit=20fixes=20?= =?UTF-8?q?=E2=80=94=20lock/position/test-mode=20correctness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Live-mover drag mirrors position to _realRaidDB only in raid (IsInRaid guard); party drags no longer overwrite the raid set's saved position. - A user Lock during combat clears the combat-restore intent so handles don't re-appear after combat (SetMoversShown(false) cancels moversShownBeforeCombat/ pendingMoversShown; LockAllForCombat re-arms it after). - SetEnabled doesn't show live chrome while test mode is active (the preview owns the screen) and re-asserts Hide-Mover alpha + active-highlight on re-show. - RemoveSet compacts _realRaidDB.pinnedFrames.sets too (raid, identity-guarded) so the index-based position mirror stays aligned after a remove. - FullProfileRefresh pinned loops use MAX_SETS (sets 3-4 refresh on profile switch). - Pinned snap-to-grid uses a point-snap (its coord is a point anchor, not a centred box) instead of the centre/edge model. --- Core.lua | 4 +- Features/PinnedFrames.lua | 79 ++++++++++++++++++++++++++++----------- Frames/Position.lua | 17 +++++---- 3 files changed, 70 insertions(+), 30 deletions(-) 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/Features/PinnedFrames.lua b/Features/PinnedFrames.lua index e632a9f3..0d88eb55 100644 --- a/Features/PinnedFrames.lua +++ b/Features/PinnedFrames.lua @@ -1442,16 +1442,19 @@ function PinnedFrames:CreateSetFrames(setIndex) -- 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 PositionPinnedContainer(container, liveSet, liveSet.position, dragW, dragH) @@ -2131,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) @@ -2143,12 +2153,17 @@ function PinnedFrames:SetEnabled(setIndex, enabled) end self:UpdateLabel(setIndex) - 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 + 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) @@ -2198,6 +2213,14 @@ function PinnedFrames:SetMoversShown(shown) return end + -- 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 if not self.initialized then return end @@ -2236,12 +2259,14 @@ end function PinnedFrames:LockAllForCombat() if not self.initialized then return end - self.moversShownBeforeCombat = self.moversShown - if self.moversShown then - -- SetMoversShown(false) hides every set's chrome; safe in combat. + 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). @@ -2599,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] diff --git a/Frames/Position.lua b/Frames/Position.lua index a1b6a990..73361254 100644 --- a/Frames/Position.lua +++ b/Frames/Position.lua @@ -1367,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 @@ -1374,13 +1384,6 @@ function DF:SnapToGrid(x, y) container = DF.personalTargetedSpellsMover or DF.container elseif DF.positionPanelMode == "targetedList" then container = DF.targetedListMoverFrame or DF.container - elseif DF.positionPanelMode == "pinned" then - local pf = DF.PinnedFrames - local i = DF.positionPanelPinnedSet or 1 - container = pf and ( - (pf.testModeActive and pf.testContainers and pf.testContainers[i]) - or (pf.containers and pf.containers[i]) - ) or DF.container else container = DF.container end From 4d89261bd07eee73ce601ed738ba03eb8c0a7cb4 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 16 Jun 2026 17:09:51 +0100 Subject: [PATCH 5/8] Pinned frames: preset/export audit fixes - RestampPinnedPresets restamps all MAX_SETS sets after a preset rename/delete (was sets 1-2 only), so sets 3-4 don't keep a stale/dead preset stamp. - Exporting/importing the pinnedFrames category carries the Aura/Text Designer preset libraries, so a shared pinned set doesn't land on a missing preset. - Trim the dead PINNED_DEFAULTS keys (incl. a contradictory keepOfflinePlayers default) to just {enabled}. --- DesignerPresets.lua | 8 +++++--- Options/AutoProfiles.lua | 11 +++++------ Profile.lua | 8 ++++---- 3 files changed, 14 insertions(+), 13 deletions(-) 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/Options/AutoProfiles.lua b/Options/AutoProfiles.lua index 2739d2aa..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, 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/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 From ac4a28b08f017b2f84bb0b37a73d8e210aa20319 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 16 Jun 2026 21:18:15 +0100 Subject: [PATCH 6/8] Changelog: add #161 entry to CHANGELOG.md (the build's source) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From b98123efb281f922c738277f5cc8f9d4365bbbb1 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 16 Jun 2026 23:59:35 +0100 Subject: [PATCH 7/8] Pinned frames: add pinnedSnapToGrid/pinnedHideMover defaults + drop dead locked writes --- Config.lua | 4 ++++ Options/Options.lua | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Config.lua b/Config.lua index 151608bc..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 @@ -3030,6 +3032,8 @@ DF.RaidDefaults = { permanentMoverWidth = 15, pixelPerfect = true, snapToGrid = true, + pinnedSnapToGrid = false, + pinnedHideMover = false, hideDragOverlay = false, -- Group Labels diff --git a/Options/Options.lua b/Options/Options.lua index 52ff0df0..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, }, From 0b21e134ca206cbff8744c789a063daeb799d4bc Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 17 Jun 2026 00:16:34 +0100 Subject: [PATCH 8/8] Changelog: drop Changelog.lua edit (generated from CHANGELOG.md at build) --- Changelog.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/Changelog.lua b/Changelog.lua index 11495bd9..acf1f271 100644 --- a/Changelog.lua +++ b/Changelog.lua @@ -19,7 +19,6 @@ DF.CHANGELOG_TEXT = [===[ ### 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