diff --git a/EllesmereUI.lua b/EllesmereUI.lua index 1653b6ad..273fbe79 100644 --- a/EllesmereUI.lua +++ b/EllesmereUI.lua @@ -1697,6 +1697,7 @@ EllesmereUI.RESOURCE_BAR_ANCHOR_KEYS = { do local PARTY_FRAME_SOURCES = { + { addon = "EllesmereUIUnitFrames", prefix = "EllesmereUIPartyHeaderUnitButton", count = 5 }, { addon = "ElvUI", prefix = "ElvUF_PartyGroup1UnitButton", count = 5 }, { addon = "Cell", prefix = "CellPartyFrameMember", count = 5 }, { addon = nil, prefix = "CompactPartyFrameMember", count = 5 }, diff --git a/EllesmereUIUnitFrames/EllesmereUIPartyFrames.lua b/EllesmereUIUnitFrames/EllesmereUIPartyFrames.lua new file mode 100644 index 00000000..c1ade79d --- /dev/null +++ b/EllesmereUIUnitFrames/EllesmereUIPartyFrames.lua @@ -0,0 +1,536 @@ +local addonName, ns = ... + +local oUF = ns.oUF +local PP = ns.PP + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +-- Map sortOrder setting to SecureGroupHeader attributes +local SORT_CONFIGS = { + role = { groupBy = "ASSIGNEDROLE", groupingOrder = "TANK,HEALER,DAMAGER" }, + group = { groupBy = nil, groupingOrder = nil }, + alphabetical = { groupBy = nil, groupingOrder = nil, sortMethod = "NAME" }, +} + +-- Map growthDirection to header point/offset +local function GetGrowthAttributes(direction, spacing) + if direction == "horizontal" then + return "LEFT", spacing, 0 + else -- vertical (default) + return "TOP", 0, -spacing + end +end + +---------------------------------------------------------------------- +-- Role Icon +---------------------------------------------------------------------- + +local ROLE_TEXCOORDS = { + TANK = { 0, 19/64, 22/64, 41/64 }, + HEALER = { 20/64, 39/64, 1/64, 20/64 }, + DAMAGER = { 20/64, 39/64, 22/64, 41/64 }, +} + +local function CreateRoleIcon(frame, settings) + local icon = frame:CreateTexture(nil, "OVERLAY") + local sz = math.max(math.floor((settings.textSize or 11) + 2), 10) + PP.Size(icon, sz, sz) + icon:SetTexture("Interface\\LFGFrame\\UI-LFG-ICON-PORTRAITROLES") + icon:Hide() + + frame._roleIcon = icon + return icon +end + +local function UpdateRoleIcon(frame) + local icon = frame._roleIcon + if not icon then return end + + local db = ns.db + local settings = db and db.profile and db.profile.party + if not settings or settings.showRoleIcon == false then + icon:Hide() + return + end + + local unit = frame.unit or frame:GetAttribute("unit") + if not unit then icon:Hide(); return end + + local role = UnitGroupRolesAssigned(unit) + local coords = ROLE_TEXCOORDS[role] + if coords then + icon:SetTexCoord(unpack(coords)) + icon:Show() + else + icon:Hide() + end +end + +---------------------------------------------------------------------- +-- Party Aura Filter +---------------------------------------------------------------------- + +local function PartyAuraFilter(element, unit, data) + -- Prioritize debuffs the player can dispel + if data.isDebuff then + return true + end + return false +end + +local function CreatePartyAuras(frame, settings) + if not settings.showDebuffs and not settings.showBuffs then return end + + if settings.showDebuffs then + local debuffs = CreateFrame("Frame", nil, frame) + debuffs:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 1, 1) + debuffs.size = math.floor(settings.healthHeight * 0.5) + debuffs.num = settings.maxDebuffs or 3 + debuffs["growth-x"] = "RIGHT" + debuffs.FilterAura = PartyAuraFilter + + debuffs.PostCreateButton = function(self, button) + button.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + -- Dispellable glow + if not button._dispelGlow then + local glow = button:CreateTexture(nil, "OVERLAY") + glow:SetAllPoints() + glow:SetColorTexture(1, 1, 1, 0) + button._dispelGlow = glow + end + end + + debuffs.PostUpdateButton = function(self, button, unit, data) + if button._dispelGlow then + local db = ns.db + local s = db and db.profile and db.profile.party + if s and s.highlightDispellable and data.isDebuff and data.dispelName then + button._dispelGlow:SetColorTexture(0, 0.8, 1, 0.3) + else + button._dispelGlow:SetColorTexture(1, 1, 1, 0) + end + end + end + + frame.Debuffs = debuffs + end + + if settings.showBuffs and (settings.maxBuffs or 0) > 0 then + local buffs = CreateFrame("Frame", nil, frame) + buffs:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -1, 1) + buffs.size = math.floor(settings.healthHeight * 0.5) + buffs.num = settings.maxBuffs or 0 + buffs["growth-x"] = "LEFT" + buffs.PostCreateButton = function(self, button) + button.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + end + frame.Buffs = buffs + end +end + +---------------------------------------------------------------------- +-- StylePartyFrame +---------------------------------------------------------------------- + +local function StylePartyFrame(frame, unit) + local db = ns.db + if not db then return end + + -- oUF may call style function before a unit is assigned (e.g. test mode) + -- Default to "player" so builders that require a unit don't error + unit = unit or frame:GetAttribute("unit") or "player" + + local settings = db.profile.party + if not settings then return end + + local ppPos = settings.powerPosition or "below" + local ppIsAtt = (ppPos == "below" or ppPos == "above") + local powerHeight = ppIsAtt and (settings.powerHeight or 4) or 0 + local totalHeight = settings.healthHeight + powerHeight + local totalWidth = settings.frameWidth + + -- Portrait adds width when visible + local showPortrait = settings.showPortrait ~= false + and (db.profile.portraitStyle or "attached") ~= "none" + if showPortrait then + totalWidth = totalHeight + settings.frameWidth + end + + PP.Size(frame, totalWidth, totalHeight) + + -- Health bar + local healthRightInset = showPortrait and totalHeight or 0 + frame.Health = ns.CreateHealthBar(frame, unit, settings.healthHeight, 0, settings, healthRightInset) + + -- Absorb bar + ns.CreateAbsorbBar(frame, unit, settings) + + -- Power bar + if ppPos ~= "none" then + frame.Power = ns.CreatePowerBar(frame, unit, settings) + end + + -- Portrait (always create, hide backdrop when disabled — same pattern as boss) + frame.Portrait = ns.CreatePortrait(frame, "left", totalHeight, unit) + frame._portraitSide = "left" + if frame.Portrait and not showPortrait then + frame.Portrait.backdrop:Hide() + end + + -- Re-anchor health bar to portrait's snapped width (same fix as boss frames) + if frame.Portrait and frame.Portrait.backdrop and showPortrait and frame.Health then + local snappedPortW = frame.Portrait.backdrop:GetWidth() + local powerAboveOff = (ppPos == "above") and (settings.powerHeight or 4) or 0 + frame.Health:ClearAllPoints() + PP.Point(frame.Health, "TOPLEFT", frame, "TOPLEFT", snappedPortW, -powerAboveOff) + PP.Point(frame.Health, "RIGHT", frame, "RIGHT", 0, 0) + PP.Height(frame.Health, settings.healthHeight) + frame.Health._xOffset = snappedPortW + frame.Health._rightInset = 0 + frame.Health._topOffset = powerAboveOff + end + + -- Border + ns.CreateUnifiedBorder(frame, unit) + ns.UpdateBordersForScale(frame, unit) + + -- Clip bars to prevent overflow + ns.ReparentBarsToClip(frame) + + -- Text overlay + local textOverlay = CreateFrame("Frame", nil, frame.Health) + textOverlay:SetAllPoints(frame.Health) + textOverlay:SetFrameLevel(frame.Health:GetFrameLevel() + 12) + frame._textOverlay = textOverlay + + local ts = settings.textSize or 11 + local leftContent = settings.leftTextContent or "name" + local rightContent = settings.rightTextContent or "perhp" + local centerContent = settings.centerTextContent or "none" + + local leftText = textOverlay:CreateFontString(nil, "OVERLAY") + ns.SetFSFont(leftText, ts) + leftText:SetWordWrap(false) + leftText:SetTextColor(1, 1, 1) + frame.LeftText = leftText + + local rightText = textOverlay:CreateFontString(nil, "OVERLAY") + ns.SetFSFont(rightText, ts) + rightText:SetWordWrap(false) + rightText:SetTextColor(1, 1, 1) + frame.RightText = rightText + + local centerText = textOverlay:CreateFontString(nil, "OVERLAY") + ns.SetFSFont(centerText, ts) + centerText:SetWordWrap(false) + centerText:SetTextColor(1, 1, 1) + frame.CenterText = centerText + + frame.NameText = leftText + frame.HealthValue = rightText + + -- Tag system (same pattern as boss frames) + local function ApplyTextTags(lc, rc, cc) + local ltag = ns.ContentToTag(lc) + local rtag = ns.ContentToTag(rc) + local ctag = ns.ContentToTag(cc) + if leftText._curTag then frame:Untag(leftText); leftText._curTag = nil end + if rightText._curTag then frame:Untag(rightText); rightText._curTag = nil end + if centerText._curTag then frame:Untag(centerText); centerText._curTag = nil end + if ltag then frame:Tag(leftText, ltag); leftText._curTag = ltag end + if rtag then frame:Tag(rightText, rtag); rightText._curTag = rtag end + if ctag then frame:Tag(centerText, ctag); centerText._curTag = ctag end + if frame.UpdateTags then frame:UpdateTags() end + end + ApplyTextTags(leftContent, rightContent, centerContent) + frame._applyTextTags = ApplyTextTags + + -- Text positioning (same pattern as boss frames) + local function ApplyTextPositions(s) + local lc = s.leftTextContent or "name" + local rc = s.rightTextContent or "perhp" + local cc = s.centerTextContent or "none" + local barW = s.frameWidth or 160 + + -- Account for role icon width on the left + local roleOffset = (s.showRoleIcon ~= false) and (ts + 6) or 0 + + if cc ~= "none" then + centerText:ClearAllPoints() + centerText:SetPoint("CENTER", frame.Health, "CENTER", 0, 0) + centerText:SetWidth(0) + centerText:Show() + leftText:Hide(); rightText:Hide() + else + centerText:Hide() + if lc ~= "none" then + leftText:ClearAllPoints() + leftText:SetPoint("LEFT", frame.Health, "LEFT", 5 + roleOffset, 0) + leftText:SetJustifyH("LEFT") + if rc ~= "none" then + local rightUsed = ns.EstimateUFTextWidth(rc) + PP.Width(leftText, math.max(barW - rightUsed - 10 - roleOffset, 20)) + else + leftText:SetWidth(0) + end + leftText:Show() + else leftText:Hide() end + if rc ~= "none" then + rightText:ClearAllPoints() + rightText:SetPoint("RIGHT", frame.Health, "RIGHT", -5, 0) + rightText:SetJustifyH("RIGHT") + if lc ~= "none" then + local leftUsed = ns.EstimateUFTextWidth(lc) + PP.Width(rightText, math.max(barW - leftUsed - 10 - roleOffset, 20)) + else + rightText:SetWidth(0) + end + rightText:Show() + else rightText:Hide() end + end + end + ApplyTextPositions(settings) + frame._applyTextPositions = ApplyTextPositions + + -- Role icon (anchored to left of health bar, before name text) + local roleIcon = CreateRoleIcon(frame, settings) + roleIcon:SetPoint("LEFT", frame.Health, "LEFT", 4, 0) + + -- Auras + CreatePartyAuras(frame, settings) + + -- Range fading + if settings.enableRangeFade ~= false then + frame.Range = { + insideAlpha = 1, + outsideAlpha = settings.rangeFadeAlpha or 0.4, + } + end + + -- Threat indicator (border glow) + if settings.showThreat ~= false then + local threat = frame:CreateTexture(nil, "OVERLAY") + threat:SetAllPoints() + threat:Hide() + frame.ThreatIndicator = threat + end + + -- Ready check + local readyCheck = frame:CreateTexture(nil, "OVERLAY", nil, 7) + readyCheck:SetSize(16, 16) + readyCheck:SetPoint("CENTER", frame, "CENTER", 0, 0) + frame.ReadyCheckIndicator = readyCheck + + -- Leader indicator + local leader = frame:CreateTexture(nil, "OVERLAY", nil, 7) + leader:SetSize(12, 12) + leader:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -2) + frame.LeaderIndicator = leader + + -- Assistant indicator + local assist = frame:CreateTexture(nil, "OVERLAY", nil, 7) + assist:SetSize(12, 12) + assist:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -2) + frame.AssistantIndicator = assist + + -- Resurrection indicator + local resurrect = frame:CreateTexture(nil, "OVERLAY", nil, 7) + resurrect:SetSize(20, 20) + resurrect:SetPoint("CENTER", frame, "CENTER", 0, 0) + frame.ResurrectIndicator = resurrect + + -- Summon indicator + local summon = frame:CreateTexture(nil, "OVERLAY", nil, 7) + summon:SetSize(24, 24) + summon:SetPoint("CENTER", frame, "CENTER", 0, 0) + frame.SummonIndicator = summon + + -- Hook for role icon updates using oUF's RegisterEvent + -- Third arg = true marks these as unitless (they don't fire for specific units) + frame:RegisterEvent("GROUP_ROSTER_UPDATE", UpdateRoleIcon, true) + frame:RegisterEvent("PLAYER_ROLES_ASSIGNED", UpdateRoleIcon, true) + frame:HookScript("OnShow", function(self) + UpdateRoleIcon(self) + end) + + -- Right-click menu + if ns.SetupUnitMenu then + ns.SetupUnitMenu(frame, unit or "party") + end +end + +---------------------------------------------------------------------- +-- SpawnPartyHeader +---------------------------------------------------------------------- + +local partyHeader +local partyAnchor -- persistent anchor frame for positioning (survives header hide) + +local function SpawnPartyHeader() + local db = ns.db + if not db then return end + + local settings = db.profile.party + if not settings then return end + + local point, xOff, yOff = GetGrowthAttributes( + settings.growthDirection or "vertical", + settings.spacing or 1 + ) + + -- Sort config + local sortCfg = SORT_CONFIGS[settings.sortOrder or "role"] or SORT_CONFIGS.role + + -- Register style once — oUF stores styles in a local table (not oUF.styles), + -- so we track registration ourselves to avoid "already registered" errors. + if not ns._partyStyleRegistered then + oUF:RegisterStyle("EllesmereParty", StylePartyFrame) + ns._partyStyleRegistered = true + end + oUF:SetActiveStyle("EllesmereParty") + + local headerArgs = { + "showPlayer", settings.showPlayer or false, + "showParty", true, + "showSolo", false, + "point", point, + "xOffset", xOff, + "yOffset", yOff, + "oUF-initialConfigFunction", ([[ + self:SetWidth(%d) + self:SetHeight(%d) + ]]):format(settings.frameWidth or 160, (settings.healthHeight or 36) + ((settings.powerPosition ~= "none") and (settings.powerHeight or 4) or 0)), + } + + -- Add sort attributes + if sortCfg.groupBy then + headerArgs[#headerArgs + 1] = "groupBy" + headerArgs[#headerArgs + 1] = sortCfg.groupBy + end + if sortCfg.groupingOrder then + headerArgs[#headerArgs + 1] = "groupingOrder" + headerArgs[#headerArgs + 1] = sortCfg.groupingOrder + end + if sortCfg.sortMethod then + headerArgs[#headerArgs + 1] = "sortMethod" + headerArgs[#headerArgs + 1] = sortCfg.sortMethod + end + + -- Create a persistent anchor frame for positioning. + -- The header hides when solo (SecureGroupHeader visibility driver), + -- which breaks Unlock Mode positioning. The anchor stays visible and + -- holds the saved position; the header attaches to it. + if not partyAnchor then + partyAnchor = CreateFrame("Frame", "EllesmereUIPartyAnchor", UIParent) + partyAnchor:SetSize(settings.frameWidth or 160, (settings.healthHeight or 36) + ((settings.powerPosition ~= "none") and (settings.powerHeight or 4) or 0)) + end + ns.ApplyFramePosition(partyAnchor, "party") + + partyHeader = oUF:SpawnHeader( + "EllesmereUIPartyHeader", + nil, + "custom [@party1,exists] show;hide", + unpack(headerArgs) + ) + + -- Attach header to the anchor + partyHeader:SetPoint("TOPLEFT", partyAnchor, "TOPLEFT", 0, 0) + + local enabled = db.profile.enabledFrames + if enabled.party == false then + RegisterAttributeDriver(partyHeader, "state-visibility", "hide") + end + + -- Store references + ns.partyHeader = partyHeader + ns.partyAnchor = partyAnchor + + return partyHeader +end + +---------------------------------------------------------------------- +-- UpdatePartyLayout (out of combat only) +---------------------------------------------------------------------- + +local function UpdatePartyLayout() + if InCombatLockdown() or not partyHeader then return end + + local db = ns.db + if not db then return end + + local settings = db.profile.party + if not settings then return end + + local point, xOff, yOff = GetGrowthAttributes( + settings.growthDirection or "vertical", + settings.spacing or 1 + ) + + partyHeader:SetAttribute("point", point) + partyHeader:SetAttribute("xOffset", xOff) + partyHeader:SetAttribute("yOffset", yOff) + partyHeader:SetAttribute("showPlayer", settings.showPlayer or false) + + -- Always set all sort attributes to clear stale values when switching modes + local sortCfg = SORT_CONFIGS[settings.sortOrder or "role"] or SORT_CONFIGS.role + partyHeader:SetAttribute("groupBy", sortCfg.groupBy) + partyHeader:SetAttribute("groupingOrder", sortCfg.groupingOrder) + partyHeader:SetAttribute("sortMethod", sortCfg.sortMethod) + + if partyAnchor then + ns.ApplyFramePosition(partyAnchor, "party") + end +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +ns.SpawnPartyHeader = SpawnPartyHeader +ns.UpdatePartyLayout = UpdatePartyLayout +ns.StylePartyFrame = StylePartyFrame + +---------------------------------------------------------------------- +-- Test Mode: /partytest +---------------------------------------------------------------------- +-- Spawns a standalone oUF frame styled as a party frame using the +-- player unit. This avoids SecureGroupHeader issues with showSolo +-- where oUF's initObject crashes on nil objectUnit. +---------------------------------------------------------------------- + +local testFrame + +SLASH_EUIPARTYTEST1 = "/partytest" +SlashCmdList.EUIPARTYTEST = function() + if InCombatLockdown() then + print("|cff0cd29f[EUI Party]|r Cannot toggle test mode during combat.") + return + end + + if testFrame and testFrame:IsShown() then + testFrame:Hide() + testFrame:SetAttribute("unit", nil) + print("|cff0cd29f[EUI Party]|r Test mode |cffff6060OFF|r") + return + end + + if not testFrame then + -- Style is already registered by SpawnPartyHeader, just set it active + oUF:SetActiveStyle("EllesmereParty") + testFrame = oUF:Spawn("player", "EllesmereUIPartyTest") + end + + -- Position at the party anchor (always visible, unlike the header) + testFrame:ClearAllPoints() + if partyAnchor then + testFrame:SetPoint("TOPLEFT", partyAnchor, "TOPLEFT", 0, 0) + else + testFrame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", 20, -200) + end + + testFrame:SetAttribute("unit", "player") + testFrame:Show() + print("|cff0cd29f[EUI Party]|r Test mode |cff00ff00ON|r — showing party frame preview (player unit). Type /partytest again to hide.") +end diff --git a/EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua b/EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua index ff618472..0cf29af1 100644 --- a/EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua +++ b/EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua @@ -6,6 +6,7 @@ local string_format = string.format local oUF = ns.oUF or oUF local PP = EllesmereUI.PP +ns.PP = PP if not oUF then error("EllesmereUIUnitFrames: oUF library not found! Please install oUF to Libraries\\oUF\\ folder.") return @@ -501,6 +502,33 @@ local defaults = { borderColor = { r = 0, g = 0, b = 0 }, highlightColor = { r = 1, g = 1, b = 1 }, }, + party = { + frameWidth = 160, + healthHeight = 36, + powerPosition = "below", + powerHeight = 4, + leftTextContent = "name", + rightTextContent = "perhp", + centerTextContent = "none", + textSize = 11, + healthBarOpacity = 90, + powerBarOpacity = 100, + showPortrait = false, + showRoleIcon = true, + showCastbar = false, + showThreat = true, + enableRangeFade = true, + rangeFadeAlpha = 0.4, + showDebuffs = true, + maxDebuffs = 3, + showBuffs = false, + maxBuffs = 0, + highlightDispellable = true, + growthDirection = "vertical", + sortOrder = "role", + spacing = 1, + showPlayer = false, + }, enabledFrames = { player = true, target = true, @@ -509,6 +537,7 @@ local defaults = { targettarget = true, focustarget = false, boss = true, + party = true, }, positions = { player = { point = "CENTER", relPoint = "CENTER", x = -317, y = -193.5 }, @@ -519,6 +548,7 @@ local defaults = { focustarget = { point = "CENTER", relPoint = "CENTER", x = 50, y = -261 }, boss = { point = "CENTER", relPoint = "CENTER", x = 661, y = 251 }, classPower = { point = "CENTER", relPoint = "CENTER", x = 0, y = -220 }, + party = { point = "TOPLEFT", relPoint = "TOPLEFT", x = 20, y = -200 }, }, bossSpacing = 60, } @@ -638,6 +668,7 @@ local function UnitToSettingsKey(unit) if unit:match("^boss%d$") then return "boss" end if unit == "targettarget" or unit == "focustarget" then return "totPet" end if unit == "pet" then return "pet" end + if unit:match("^party%d$") then return "party" end if db.profile[unit] then return unit end return nil end @@ -890,6 +921,10 @@ local function GetSettingsForUnit(unit) for i = 1, 5 do unitSettingsMap["boss" .. i] = db.profile.boss end + for i = 1, 4 do + unitSettingsMap["party" .. i] = db.profile.party + end + unitSettingsMap["party"] = db.profile.party end return unitSettingsMap[unit] or db.profile.player end @@ -3489,6 +3524,26 @@ local function StyleBossFrame(frame, unit) frame._applyTextPositions = ApplyTextPositions end +-- Expose builders for party/raid frame files +ns.CreateHealthBar = CreateHealthBar +ns.CreateAbsorbBar = CreateAbsorbBar +ns.CreatePowerBar = CreatePowerBar +ns.CreatePortrait = CreatePortrait +ns.CreateCastBar = CreateCastBar +ns.CreateUnifiedBorder = CreateUnifiedBorder +ns.ReparentBarsToClip = ReparentBarsToClip +ns.UpdateBordersForScale = UpdateBordersForScale +ns.ApplyFramePosition = ApplyFramePosition +ns.SetFSFont = SetFSFont +ns.ContentToTag = ContentToTag +ns.EstimateUFTextWidth = EstimateUFTextWidth +ns.GetSettingsForUnit = GetSettingsForUnit +ns.GetCastbarColor = GetCastbarColor +ns.ApplyHealthBarTexture = ApplyHealthBarTexture +ns.ApplyDarkTheme = ApplyDarkTheme +ns.ApplyHealthBarAlpha = ApplyHealthBarAlpha +ns.db = nil +ns.frames = frames local function RegisterStylesOnce() if _G.EllesmereUF_StylesRegistered then @@ -4004,7 +4059,7 @@ local function ReloadFrames() -- Normalize opacity values: old profiles stored 0-1 floats, new format is 0-100 integers do local prof = db.profile - local UNITS = { "player", "target", "focus", "boss", "pet", "totPet" } + local UNITS = { "player", "target", "focus", "boss", "pet", "totPet", "party" } if prof.healthBarOpacity and prof.healthBarOpacity <= 1.0 then prof.healthBarOpacity = math.floor(prof.healthBarOpacity * 100 + 0.5) end @@ -5675,6 +5730,7 @@ function InitializeFrames() frame:HookScript("OnEnter", UnitFrame_OnEnter) frame:HookScript("OnLeave", UnitFrame_OnLeave) end + ns.SetupUnitMenu = SetupUnitMenu -- Always spawn all frames; hide disabled ones for zero performance impact oUF:SetActiveStyle("EllesmerePlayer") @@ -6199,6 +6255,23 @@ function InitializeFrames() end end + -- Party frames (spawned via header in EllesmereUIPartyFrames.lua) + if ns.SpawnPartyHeader then + ns.SpawnPartyHeader() + end + + -- Hide Blizzard party frames when our party frames are enabled + if enabled.party ~= false then + if CompactPartyFrame then + CompactPartyFrame:UnregisterAllEvents() + CompactPartyFrame:Hide() + end + if PartyFrame then + PartyFrame:UnregisterAllEvents() + PartyFrame:Hide() + end + end + -- Disable oUF elements for frames where features are initially off. -- Portrait backdrop is already hidden by style functions, but oUF -- auto-enables the element at spawn time since frame.Portrait is always set. @@ -6564,6 +6637,7 @@ function SetupOptionsPanel() playerCastbar = "Player Cast Bar", targetCastbar = "Target Cast Bar", focusCastbar = "Focus Cast Bar", + party = "Party Frames", } local elements = {} local orderBase = 100 @@ -6587,6 +6661,7 @@ function SetupOptionsPanel() return nil end if k == "classPower" then return frames._classPowerBar end + if k == "party" then return ns.partyAnchor or ns.partyHeader end return frames[k] end, getSize = function(k) @@ -6614,10 +6689,23 @@ function SetupOptionsPanel() end return 120, 14 end + if k == "party" then + local s = db.profile.party + if not s then return 160, 36 end + local ppPos = s.powerPosition or "below" + local ppIsAtt = (ppPos == "below" or ppPos == "above") + local ph = ppIsAtt and (s.powerHeight or 4) or 0 + local frameH = s.healthHeight + ph + local frameW = s.frameWidth + local showPortrait = s.showPortrait ~= false and (db.profile.portraitStyle or "attached") ~= "none" + if showPortrait then frameW = frameW + frameH end + return frameW, frameH + end if k == "boss" then return GetFrameDimensions("boss1") end return GetFrameDimensions(k) end, setWidth = function(k, w) + if k == "party" then return end if k == "playerCastbar" then db.profile.player.playerCastbarWidth = math.max(math.floor(w + 0.5), 30) local cbBg = frames.player and frames.player.Castbar and frames.player.Castbar:GetParent() @@ -6654,6 +6742,7 @@ function SetupOptionsPanel() Rebuild() end, setHeight = function(k, h) + if k == "party" then return end if k == "playerCastbar" then local newH = math.max(math.floor(h + 0.5), 5) db.profile.player.playerCastbarHeight = newH @@ -6719,6 +6808,12 @@ function SetupOptionsPanel() frames._classPowerBar:ClearAllPoints() frames._classPowerBar:SetPoint(point, UIParent, relPoint, x, y) end + elseif k == "party" then + local anchor = ns.partyAnchor or ns.partyHeader + if anchor then + anchor:ClearAllPoints() + anchor:SetPoint(point, UIParent, relPoint, x, y) + end else local fr = frames[k] if fr then @@ -6756,6 +6851,12 @@ function SetupOptionsPanel() frames._classPowerBar:ClearAllPoints() frames._classPowerBar:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, pos.x, pos.y) end + elseif k == "party" then + local anchor = ns.partyAnchor or ns.partyHeader + if anchor then + anchor:ClearAllPoints() + anchor:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, pos.x, pos.y) + end else local fr = frames[k] if fr then @@ -6775,6 +6876,7 @@ function SetupOptionsPanel() elements[#elements + 1] = MakeUFElement("targettarget", 5) elements[#elements + 1] = MakeUFElement("focustarget", 6) elements[#elements + 1] = MakeUFElement("boss", 7) + elements[#elements + 1] = MakeUFElement("party", 8) -- Conditional elements if db.profile.player.showClassPowerBar and not db.profile.player.lockClassPowerToFrame then @@ -6853,6 +6955,7 @@ local EllesmereUF = EllesmereUI.Lite.NewAddon("EllesmereUIUnitFrames") function EllesmereUF:OnInitialize() db = EllesmereUI.Lite.NewDB("EllesmereUIUnitFramesDB", defaults, true) + ns.db = db ResolveFontPath() diff --git a/EllesmereUIUnitFrames/EllesmereUIUnitFrames.toc b/EllesmereUIUnitFrames/EllesmereUIUnitFrames.toc index 9f7d40d0..b8f3474e 100644 --- a/EllesmereUIUnitFrames/EllesmereUIUnitFrames.toc +++ b/EllesmereUIUnitFrames/EllesmereUIUnitFrames.toc @@ -15,6 +15,7 @@ Libs\oUF\oUF.xml # Main Luas EllesmereUIUnitFrames.lua +EllesmereUIPartyFrames.lua # Options EUI_UnitFrames_Options.lua diff --git a/docs/superpowers/plans/2026-03-17-party-frames.md b/docs/superpowers/plans/2026-03-17-party-frames.md new file mode 100644 index 00000000..e71832de --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-party-frames.md @@ -0,0 +1,954 @@ +# Party Frames Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add configurable party frames to EllesmereUIUnitFrames using oUF SpawnHeader, reusing existing component builders via the addon namespace. + +**Architecture:** Hybrid Header — oUF:SpawnHeader manages group lifecycle (create/destroy/sort/visibility), while a new StylePartyFrame function reuses existing component builders (CreateHealthBar, CreatePowerBar, etc.) exposed via the `ns` namespace table. New code lives in EllesmereUIPartyFrames.lua, loaded after the main file. + +**Tech Stack:** Lua (WoW addon), oUF unit frame framework, Blizzard SecureGroupHeaderTemplate + +**Spec:** `docs/superpowers/specs/2026-03-17-party-frames-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua` | Modify | Add party defaults, extend UnitToSettingsKey/GetSettingsForUnit, expose builders via ns, call SpawnPartyHeader, register Unlock Mode | +| `EllesmereUIUnitFrames/EllesmereUIPartyFrames.lua` | Create | StylePartyFrame, SpawnPartyHeader, UpdatePartyLayout, role icon, aura filter, range fade, CDM registration | +| `EllesmereUIUnitFrames/EllesmereUIUnitFrames.toc` | Modify | Add EllesmereUIPartyFrames.lua to load order | +| `EllesmereUI.lua` | Modify | Add EllesmereUI party frames to PARTY_FRAME_SOURCES for CDM integration | + +--- + +## Chunk 1: Foundation — Defaults, Mappings, and ns Exports + +### Task 1: Add party defaults and enabledFrames entry + +**Files:** +- Modify: `EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua:476-498` + +- [ ] **Step 1: Add `party = true` to `enabledFrames`** + +In `defaults.profile.enabledFrames` (line 477), add `party = true` after `boss = true`: + +```lua +enabledFrames = { + player = true, + target = true, + focus = true, + pet = true, + targettarget = true, + focustarget = false, + boss = true, + party = true, +}, +``` + +- [ ] **Step 2: Add `party` position to `positions`** + +In `defaults.profile.positions` (line 486), add after the `classPower` entry: + +```lua +party = { point = "TOPLEFT", x = 20, y = -40 }, +``` + +- [ ] **Step 3: Add the full `party` defaults sub-table** + +After the `boss` defaults sub-table (around line 476, before `enabledFrames`) add: + +```lua +party = { + frameWidth = 160, + healthHeight = 36, + powerPosition = "below", + powerHeight = 4, + leftTextContent = "name", + rightTextContent = "perhp", + centerTextContent = "none", + textSize = 11, + healthBarOpacity = 90, + powerBarOpacity = 100, + showPortrait = false, + showRoleIcon = true, + showCastbar = false, + showThreat = true, + enableRangeFade = true, + rangeFadeAlpha = 0.4, + showDebuffs = true, + maxDebuffs = 3, + showBuffs = false, + maxBuffs = 0, + highlightDispellable = true, + growthDirection = "vertical", + sortOrder = "role", + spacing = 1, + showPlayer = false, +}, +``` + +- [ ] **Step 4: Add "party" to the opacity normalization list in ReloadFrames** + +In `ReloadFrames()` (line 3981), add `"party"` to the UNITS table: + +```lua +local UNITS = { "player", "target", "focus", "boss", "pet", "totPet", "party" } +``` + +- [ ] **Step 5: Commit** + +```bash +git add EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua +git commit -m "feat(party): add party frame defaults and enabledFrames entry" +``` + +--- + +### Task 2: Extend UnitToSettingsKey and GetSettingsForUnit + +**Files:** +- Modify: `EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua:610-868` + +- [ ] **Step 1: Add party pattern to UnitToSettingsKey** + +At line 612, **before** the `db.profile[unit]` fallback (line 615), add the party pattern match: + +```lua +local function UnitToSettingsKey(unit) + if not unit then return nil end + if unit:match("^boss%d$") then return "boss" end + if unit == "targettarget" or unit == "focustarget" then return "totPet" end + if unit == "pet" then return "pet" end + if unit:match("^party%d$") then return "party" end + if db.profile[unit] then return unit end + return nil +end +``` + +- [ ] **Step 2: Add party entries to GetSettingsForUnit** + +In `GetSettingsForUnit` (line 856), add party entries **inside** the `if not unitSettingsMap then` lazy-init block, right after the boss loop (line 866): + +```lua + for i = 1, 5 do + unitSettingsMap["boss" .. i] = db.profile.boss + end + for i = 1, 4 do + unitSettingsMap["party" .. i] = db.profile.party + end + unitSettingsMap["party"] = db.profile.party + end + return unitSettingsMap[unit] or db.profile.player +``` + +This ensures party entries are part of the initial cache build, not appended after. + +- [ ] **Step 3: Commit** + +```bash +git add EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua +git commit -m "feat(party): extend UnitToSettingsKey and GetSettingsForUnit for party units" +``` + +--- + +### Task 3: Expose component builders via ns namespace + +**Files:** +- Modify: `EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua` + +The component builders are all `local function` declarations. We need to expose them on the `ns` table so the party frames file can access them. Add these assignments **after** all the builder function definitions but **before** `RegisterStylesOnce()` (around line 3459). + +- [ ] **Step 1: Add ns exports block** + +Find the area just before `RegisterStylesOnce()` (line 3459) and add: + +```lua +-- Expose builders for party/raid frame files +ns.CreateHealthBar = CreateHealthBar +ns.CreateAbsorbBar = CreateAbsorbBar +ns.CreatePowerBar = CreatePowerBar +ns.CreatePortrait = CreatePortrait +ns.CreateCastBar = CreateCastBar +ns.CreateUnifiedBorder = CreateUnifiedBorder +ns.ReparentBarsToClip = ReparentBarsToClip +ns.UpdateBordersForScale = UpdateBordersForScale +ns.ApplyFramePosition = ApplyFramePosition +ns.SetFSFont = SetFSFont +ns.ContentToTag = ContentToTag +ns.EstimateUFTextWidth = EstimateUFTextWidth +ns.GetSettingsForUnit = GetSettingsForUnit +ns.SetupUnitMenu = nil -- will be set later inside spawn function +ns.GetCastbarColor = GetCastbarColor +ns.ApplyHealthBarTexture = ApplyHealthBarTexture +ns.ApplyDarkTheme = ApplyDarkTheme +ns.ApplyHealthBarAlpha = ApplyHealthBarAlpha +``` + +Note: `SetupUnitMenu` is defined as a local inside the spawn function scope (line 5403). It needs to be exported from there. Add this line inside the spawn function after `SetupUnitMenu` is defined (after line 5408): + +```lua +ns.SetupUnitMenu = SetupUnitMenu +``` + +Also expose the `db`, `frames`, and `oUF` references the party file will need: + +**Important:** `ns.PP` must be available at file-load time for the party file. Add this near the top of the file (after line 4, `local PP = EllesmereUI.PP`): + +```lua +ns.PP = PP +``` + +Then add the remaining shared state exports in the same block near `RegisterStylesOnce()`: + +```lua +ns.db = nil -- set after DB init +ns.frames = frames +``` + +Then after `db = EUILite.NewDB(...)` runs (find the DB init line), add: + +```lua +ns.db = db +``` + +- [ ] **Step 2: Commit** + +```bash +git add EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua +git commit -m "feat(party): expose component builders and shared state via ns namespace" +``` + +--- + +### Task 4: Update .toc file + +**Files:** +- Modify: `EllesmereUIUnitFrames/EllesmereUIUnitFrames.toc` + +- [ ] **Step 1: Add party frames file to load order** + +Add `EllesmereUIPartyFrames.lua` after the main file: + +``` +# oUF +Libs\oUF\oUF.xml + +# Main Luas +EllesmereUIUnitFrames.lua +EllesmereUIPartyFrames.lua + +# Options +EUI_UnitFrames_Options.lua +``` + +- [ ] **Step 2: Commit** + +```bash +git add EllesmereUIUnitFrames/EllesmereUIUnitFrames.toc +git commit -m "feat(party): add EllesmereUIPartyFrames.lua to toc load order" +``` + +--- + +## Chunk 2: Core Party Frames File — StylePartyFrame and SpawnPartyHeader + +### Task 5: Create EllesmereUIPartyFrames.lua with StylePartyFrame + +**Files:** +- Create: `EllesmereUIUnitFrames/EllesmereUIPartyFrames.lua` + +This is the core new file. It accesses all builders through `ns.*`. + +- [ ] **Step 1: Write the complete party frames file** + +Create `EllesmereUIUnitFrames/EllesmereUIPartyFrames.lua` with the following content: + +```lua +local addonName, ns = ... + +local oUF = ns.oUF +local PP = ns.PP + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +-- Map sortOrder setting to SecureGroupHeader attributes +local SORT_CONFIGS = { + role = { groupBy = "ASSIGNEDROLE", groupingOrder = "TANK,HEALER,DAMAGER" }, + group = { groupBy = nil, groupingOrder = nil }, + alphabetical = { groupBy = nil, groupingOrder = nil, sortMethod = "NAME" }, +} + +-- Map growthDirection to header point/offset +local function GetGrowthAttributes(direction, spacing) + if direction == "horizontal" then + return "LEFT", spacing, 0 + else -- vertical (default) + return "TOP", 0, -spacing + end +end + +---------------------------------------------------------------------- +-- Role Icon +---------------------------------------------------------------------- + +local ROLE_TEXCOORDS = { + TANK = { 0, 19/64, 22/64, 41/64 }, + HEALER = { 20/64, 39/64, 1/64, 20/64 }, + DAMAGER = { 20/64, 39/64, 22/64, 41/64 }, +} + +local function CreateRoleIcon(frame, settings) + local icon = frame:CreateTexture(nil, "OVERLAY") + local sz = math.max(math.floor((settings.textSize or 11) + 2), 10) + PP.Size(icon, sz, sz) + icon:SetTexture("Interface\\LFGFrame\\UI-LFG-ICON-PORTRAITROLES") + icon:Hide() + + frame._roleIcon = icon + return icon +end + +local function UpdateRoleIcon(frame) + local icon = frame._roleIcon + if not icon then return end + + local db = ns.db + local settings = db and db.profile and db.profile.party + if not settings or settings.showRoleIcon == false then + icon:Hide() + return + end + + local unit = frame.unit or frame:GetAttribute("unit") + if not unit then icon:Hide(); return end + + local role = UnitGroupRolesAssigned(unit) + local coords = ROLE_TEXCOORDS[role] + if coords then + icon:SetTexCoord(unpack(coords)) + icon:Show() + else + icon:Hide() + end +end + +---------------------------------------------------------------------- +-- Party Aura Filter +---------------------------------------------------------------------- + +local function PartyAuraFilter(element, unit, data) + -- Prioritize debuffs the player can dispel + if data.isDebuff then + return true + end + return false +end + +local function CreatePartyAuras(frame, settings) + if not settings.showDebuffs and not settings.showBuffs then return end + + if settings.showDebuffs then + local debuffs = CreateFrame("Frame", nil, frame) + debuffs:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 1, 1) + debuffs.size = math.floor(settings.healthHeight * 0.5) + debuffs.num = settings.maxDebuffs or 3 + debuffs["growth-x"] = "RIGHT" + debuffs.FilterAura = PartyAuraFilter + + debuffs.PostCreateButton = function(self, button) + button.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + -- Dispellable glow + if not button._dispelGlow then + local glow = button:CreateTexture(nil, "OVERLAY") + glow:SetAllPoints() + glow:SetColorTexture(1, 1, 1, 0) + button._dispelGlow = glow + end + end + + debuffs.PostUpdateButton = function(self, button, unit, data) + if button._dispelGlow then + local db = ns.db + local s = db and db.profile and db.profile.party + if s and s.highlightDispellable and data.isDebuff and data.dispelName then + button._dispelGlow:SetColorTexture(0, 0.8, 1, 0.3) + else + button._dispelGlow:SetColorTexture(1, 1, 1, 0) + end + end + end + + frame.Debuffs = debuffs + end + + if settings.showBuffs and (settings.maxBuffs or 0) > 0 then + local buffs = CreateFrame("Frame", nil, frame) + buffs:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -1, 1) + buffs.size = math.floor(settings.healthHeight * 0.5) + buffs.num = settings.maxBuffs or 0 + buffs["growth-x"] = "LEFT" + buffs.PostCreateButton = function(self, button) + button.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + end + frame.Buffs = buffs + end +end + +---------------------------------------------------------------------- +-- StylePartyFrame +---------------------------------------------------------------------- + +local function StylePartyFrame(frame, unit) + local db = ns.db + if not db then return end + + local settings = db.profile.party + if not settings then return end + + local ppPos = settings.powerPosition or "below" + local ppIsAtt = (ppPos == "below" or ppPos == "above") + local powerHeight = ppIsAtt and (settings.powerHeight or 4) or 0 + local totalHeight = settings.healthHeight + powerHeight + local totalWidth = settings.frameWidth + + -- Portrait adds width when visible + local showPortrait = settings.showPortrait ~= false + and (db.profile.portraitStyle or "attached") ~= "none" + if showPortrait then + totalWidth = totalHeight + settings.frameWidth + end + + PP.Size(frame, totalWidth, totalHeight) + + -- Health bar + local healthRightInset = showPortrait and totalHeight or 0 + frame.Health = ns.CreateHealthBar(frame, unit, settings.healthHeight, 0, settings, healthRightInset) + + -- Absorb bar + ns.CreateAbsorbBar(frame, unit, settings) + + -- Power bar + if ppPos ~= "none" then + frame.Power = ns.CreatePowerBar(frame, unit, settings) + end + + -- Portrait (always create, hide backdrop when disabled — same pattern as boss) + frame.Portrait = ns.CreatePortrait(frame, "left", totalHeight, unit) + frame._portraitSide = "left" + if frame.Portrait and not showPortrait then + frame.Portrait.backdrop:Hide() + end + + -- Re-anchor health bar to portrait's snapped width (same fix as boss frames) + if frame.Portrait and frame.Portrait.backdrop and showPortrait and frame.Health then + local snappedPortW = frame.Portrait.backdrop:GetWidth() + local powerAboveOff = (ppPos == "above") and (settings.powerHeight or 4) or 0 + frame.Health:ClearAllPoints() + PP.Point(frame.Health, "TOPLEFT", frame, "TOPLEFT", snappedPortW, -powerAboveOff) + PP.Point(frame.Health, "RIGHT", frame, "RIGHT", 0, 0) + PP.Height(frame.Health, settings.healthHeight) + frame.Health._xOffset = snappedPortW + frame.Health._rightInset = 0 + frame.Health._topOffset = powerAboveOff + end + + -- Border + ns.CreateUnifiedBorder(frame, unit) + ns.UpdateBordersForScale(frame, unit) + + -- Clip bars to prevent overflow + ns.ReparentBarsToClip(frame) + + -- Text overlay + local textOverlay = CreateFrame("Frame", nil, frame.Health) + textOverlay:SetAllPoints(frame.Health) + textOverlay:SetFrameLevel(frame.Health:GetFrameLevel() + 12) + frame._textOverlay = textOverlay + + local ts = settings.textSize or 11 + local leftContent = settings.leftTextContent or "name" + local rightContent = settings.rightTextContent or "perhp" + local centerContent = settings.centerTextContent or "none" + + local leftText = textOverlay:CreateFontString(nil, "OVERLAY") + ns.SetFSFont(leftText, ts) + leftText:SetWordWrap(false) + leftText:SetTextColor(1, 1, 1) + frame.LeftText = leftText + + local rightText = textOverlay:CreateFontString(nil, "OVERLAY") + ns.SetFSFont(rightText, ts) + rightText:SetWordWrap(false) + rightText:SetTextColor(1, 1, 1) + frame.RightText = rightText + + local centerText = textOverlay:CreateFontString(nil, "OVERLAY") + ns.SetFSFont(centerText, ts) + centerText:SetWordWrap(false) + centerText:SetTextColor(1, 1, 1) + frame.CenterText = centerText + + frame.NameText = leftText + frame.HealthValue = rightText + + -- Tag system (same pattern as boss frames) + local function ApplyTextTags(lc, rc, cc) + local ltag = ns.ContentToTag(lc) + local rtag = ns.ContentToTag(rc) + local ctag = ns.ContentToTag(cc) + if leftText._curTag then frame:Untag(leftText); leftText._curTag = nil end + if rightText._curTag then frame:Untag(rightText); rightText._curTag = nil end + if centerText._curTag then frame:Untag(centerText); centerText._curTag = nil end + if ltag then frame:Tag(leftText, ltag); leftText._curTag = ltag end + if rtag then frame:Tag(rightText, rtag); rightText._curTag = rtag end + if ctag then frame:Tag(centerText, ctag); centerText._curTag = ctag end + if frame.UpdateTags then frame:UpdateTags() end + end + ApplyTextTags(leftContent, rightContent, centerContent) + frame._applyTextTags = ApplyTextTags + + -- Text positioning (same pattern as boss frames) + local function ApplyTextPositions(s) + local lc = s.leftTextContent or "name" + local rc = s.rightTextContent or "perhp" + local cc = s.centerTextContent or "none" + local barW = s.frameWidth or 160 + + -- Account for role icon width on the left + local roleOffset = (s.showRoleIcon ~= false) and (ts + 6) or 0 + + if cc ~= "none" then + centerText:ClearAllPoints() + centerText:SetPoint("CENTER", frame.Health, "CENTER", 0, 0) + centerText:SetWidth(0) + centerText:Show() + leftText:Hide(); rightText:Hide() + else + centerText:Hide() + if lc ~= "none" then + leftText:ClearAllPoints() + leftText:SetPoint("LEFT", frame.Health, "LEFT", 5 + roleOffset, 0) + leftText:SetJustifyH("LEFT") + if rc ~= "none" then + local rightUsed = ns.EstimateUFTextWidth(rc) + PP.Width(leftText, math.max(barW - rightUsed - 10 - roleOffset, 20)) + else + leftText:SetWidth(0) + end + leftText:Show() + else leftText:Hide() end + if rc ~= "none" then + rightText:ClearAllPoints() + rightText:SetPoint("RIGHT", frame.Health, "RIGHT", -5, 0) + rightText:SetJustifyH("RIGHT") + if lc ~= "none" then + local leftUsed = ns.EstimateUFTextWidth(lc) + PP.Width(rightText, math.max(barW - leftUsed - 10 - roleOffset, 20)) + else + rightText:SetWidth(0) + end + rightText:Show() + else rightText:Hide() end + end + end + ApplyTextPositions(settings) + frame._applyTextPositions = ApplyTextPositions + + -- Role icon (anchored to left of health bar, before name text) + local roleIcon = CreateRoleIcon(frame, settings) + roleIcon:SetPoint("LEFT", frame.Health, "LEFT", 4, 0) + + -- Auras + CreatePartyAuras(frame, settings) + + -- Range fading + if settings.enableRangeFade ~= false then + frame.Range = { + insideAlpha = 1, + outsideAlpha = settings.rangeFadeAlpha or 0.4, + } + end + + -- Threat indicator (border glow) + if settings.showThreat ~= false then + local threat = frame:CreateTexture(nil, "OVERLAY") + threat:SetAllPoints() + threat:Hide() + frame.ThreatIndicator = threat + end + + -- Ready check + local readyCheck = frame:CreateTexture(nil, "OVERLAY", nil, 7) + readyCheck:SetSize(16, 16) + readyCheck:SetPoint("CENTER", frame, "CENTER", 0, 0) + frame.ReadyCheckIndicator = readyCheck + + -- Leader indicator + local leader = frame:CreateTexture(nil, "OVERLAY", nil, 7) + leader:SetSize(12, 12) + leader:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -2) + frame.LeaderIndicator = leader + + -- Assistant indicator + local assist = frame:CreateTexture(nil, "OVERLAY", nil, 7) + assist:SetSize(12, 12) + assist:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -2) + frame.AssistantIndicator = assist + + -- Resurrection indicator + local resurrect = frame:CreateTexture(nil, "OVERLAY", nil, 7) + resurrect:SetSize(20, 20) + resurrect:SetPoint("CENTER", frame, "CENTER", 0, 0) + frame.ResurrectIndicator = resurrect + + -- Summon indicator + local summon = frame:CreateTexture(nil, "OVERLAY", nil, 7) + summon:SetSize(24, 24) + summon:SetPoint("CENTER", frame, "CENTER", 0, 0) + frame.SummonIndicator = summon + + -- Hook for role icon updates + -- Note: WoW Frame:RegisterEvent takes only event name (no callback arg). + -- Use OnEvent script + OnShow hook instead. + frame:RegisterEvent("GROUP_ROSTER_UPDATE") + frame:RegisterEvent("PLAYER_ROLES_ASSIGNED") + frame:HookScript("OnEvent", function(self, event) + if event == "GROUP_ROSTER_UPDATE" or event == "PLAYER_ROLES_ASSIGNED" then + UpdateRoleIcon(self) + end + end) + frame:HookScript("OnShow", function(self) + UpdateRoleIcon(self) + end) + + -- Right-click menu + if ns.SetupUnitMenu then + ns.SetupUnitMenu(frame, unit or "party") + end +end + +---------------------------------------------------------------------- +-- SpawnPartyHeader +---------------------------------------------------------------------- + +local partyHeader + +local function SpawnPartyHeader() + local db = ns.db + if not db then return end + + local settings = db.profile.party + if not settings then return end + + local point, xOff, yOff = GetGrowthAttributes( + settings.growthDirection or "vertical", + settings.spacing or 1 + ) + + -- Sort config + local sortCfg = SORT_CONFIGS[settings.sortOrder or "role"] or SORT_CONFIGS.role + + -- Guard: oUF errors on duplicate style registration (e.g. during ReloadFrames) + if not oUF.styles or not oUF.styles["EllesmereParty"] then + oUF:RegisterStyle("EllesmereParty", StylePartyFrame) + end + oUF:SetActiveStyle("EllesmereParty") + + local headerArgs = { + "showPlayer", settings.showPlayer or false, + "showParty", true, + "showSolo", false, + "point", point, + "xOffset", xOff, + "yOffset", yOff, + "oUF-initialConfigFunction", ([[ + self:SetWidth(%d) + self:SetHeight(%d) + ]]):format(settings.frameWidth or 160, (settings.healthHeight or 36) + ((settings.powerPosition ~= "none") and (settings.powerHeight or 4) or 0)), + } + + -- Add sort attributes + if sortCfg.groupBy then + headerArgs[#headerArgs + 1] = "groupBy" + headerArgs[#headerArgs + 1] = sortCfg.groupBy + end + if sortCfg.groupingOrder then + headerArgs[#headerArgs + 1] = "groupingOrder" + headerArgs[#headerArgs + 1] = sortCfg.groupingOrder + end + if sortCfg.sortMethod then + headerArgs[#headerArgs + 1] = "sortMethod" + headerArgs[#headerArgs + 1] = sortCfg.sortMethod + end + + partyHeader = oUF:SpawnHeader( + "EllesmereUIPartyHeader", + nil, + "custom [@party1,exists] show;hide", + unpack(headerArgs) + ) + + ns.ApplyFramePosition(partyHeader, "party") + + local enabled = db.profile.enabledFrames + if enabled.party == false then + RegisterAttributeDriver(partyHeader, "state-visibility", "hide") + end + + -- Store reference + ns.partyHeader = partyHeader + + return partyHeader +end + +---------------------------------------------------------------------- +-- UpdatePartyLayout (out of combat only) +---------------------------------------------------------------------- + +local function UpdatePartyLayout() + if InCombatLockdown() or not partyHeader then return end + + local db = ns.db + if not db then return end + + local settings = db.profile.party + if not settings then return end + + local point, xOff, yOff = GetGrowthAttributes( + settings.growthDirection or "vertical", + settings.spacing or 1 + ) + + partyHeader:SetAttribute("point", point) + partyHeader:SetAttribute("xOffset", xOff) + partyHeader:SetAttribute("yOffset", yOff) + partyHeader:SetAttribute("showPlayer", settings.showPlayer or false) + + local sortCfg = SORT_CONFIGS[settings.sortOrder or "role"] or SORT_CONFIGS.role + if sortCfg.groupBy then + partyHeader:SetAttribute("groupBy", sortCfg.groupBy) + partyHeader:SetAttribute("groupingOrder", sortCfg.groupingOrder) + end + if sortCfg.sortMethod then + partyHeader:SetAttribute("sortMethod", sortCfg.sortMethod) + end + + ns.ApplyFramePosition(partyHeader, "party") +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +ns.SpawnPartyHeader = SpawnPartyHeader +ns.UpdatePartyLayout = UpdatePartyLayout +ns.StylePartyFrame = StylePartyFrame +``` + +- [ ] **Step 2: Commit** + +```bash +git add EllesmereUIUnitFrames/EllesmereUIPartyFrames.lua +git commit -m "feat(party): create EllesmereUIPartyFrames.lua with StylePartyFrame and SpawnPartyHeader" +``` + +--- + +### Task 6: Call SpawnPartyHeader from main spawn section + +**Files:** +- Modify: `EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua:5919-5920` + +- [ ] **Step 1: Add SpawnPartyHeader call after boss frame spawning** + +After the boss frame spawn loop (line 5919) and the blizzard boss hide loop (line 5927), add: + +```lua + -- Party frames (spawned via header in EllesmereUIPartyFrames.lua) + if ns.SpawnPartyHeader then + ns.SpawnPartyHeader() + end +``` + +- [ ] **Step 2: Commit** + +```bash +git add EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua +git commit -m "feat(party): call SpawnPartyHeader from main spawn section" +``` + +--- + +## Chunk 3: Unlock Mode and CDM Integration + +### Task 7: Register party header with Unlock Mode + +**Files:** +- Modify: `EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua:6228-6392` + +- [ ] **Step 1: Add "party" to UNIT_LABELS** + +In the `UNIT_LABELS` table (line 6230), add `party`: + +```lua +local UNIT_LABELS = { + player = "Player", target = "Target", focus = "Focus", + pet = "Pet", targettarget = "Target of Target", + focustarget = "Focus Target", boss = "Boss Frames", + party = "Party Frames", + classPower = "Class Resource", +} +``` + +- [ ] **Step 2: Add party to element list** + +After the boss element (line 6385), add: + +```lua +elements[#elements + 1] = MakeUFElement("party", 8) +``` + +- [ ] **Step 3: Handle party in getFrame callback** + +In the `getFrame` function (line 6247), add a party case before the default return: + +```lua +if k == "party" then return ns.partyHeader end +``` + +- [ ] **Step 4: Handle party in getSize callback** + +The party header's size for Unlock Mode should return the total group size. In `getSize` (line 6258), add: + +```lua +if k == "party" then + local s = db.profile.party + if not s then return 160, 36 end + local ppPos = s.powerPosition or "below" + local ppIsAtt = (ppPos == "below" or ppPos == "above") + local ph = ppIsAtt and (s.powerHeight or 4) or 0 + local frameH = s.healthHeight + ph + local frameW = s.frameWidth + -- Account for portrait + local showPortrait = s.showPortrait ~= false and (db.profile.portraitStyle or "attached") ~= "none" + if showPortrait then frameW = frameW + frameH end + return frameW, frameH +end +``` + +- [ ] **Step 5: Handle party in savePos callback** + +In the `savePos` function (line 6324), add a party case: + +```lua +elseif k == "party" then + if ns.partyHeader then + ns.partyHeader:ClearAllPoints() + ns.partyHeader:SetPoint(point, UIParent, relPoint, x, y) + end +``` + +- [ ] **Step 6: Handle party in applyPos callback** + +In the `applyPos` function (line 6351), add: + +```lua +elseif k == "party" then + if ns.partyHeader then + ns.partyHeader:ClearAllPoints() + ns.partyHeader:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, pos.x, pos.y) + end +``` + +- [ ] **Step 7: Commit** + +```bash +git add EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua +git commit -m "feat(party): register party header with Unlock Mode" +``` + +--- + +### Task 8: CDM integration — add party frames to FindPlayerPartyFrame + +**Files:** +- Modify: `EllesmereUI.lua:1700-1703` + +- [ ] **Step 1: Add EllesmereUI party frames to PARTY_FRAME_SOURCES** + +At the top of the `PARTY_FRAME_SOURCES` table (line 1700), add our party frames as the first entry so they take priority: + +```lua +local PARTY_FRAME_SOURCES = { + { addon = "EllesmereUIUnitFrames", prefix = "EllesmereUIPartyHeaderUnitButton", count = 5 }, + { addon = "ElvUI", prefix = "ElvUF_PartyGroup1UnitButton", count = 5 }, + { addon = "Cell", prefix = "CellPartyFrameMember", count = 5 }, + { addon = nil, prefix = "CompactPartyFrameMember", count = 5 }, +``` + +- [ ] **Step 2: Commit** + +```bash +git add EllesmereUI.lua +git commit -m "feat(party): add native party frames to CDM FindPlayerPartyFrame sources" +``` + +--- + +## Out of Scope (Phase 4) + +- **Options panel** (`EUI_UnitFrames_Options.lua`) — party settings UI deferred to Phase 4. Settings can be changed via SavedVariables or `/run` commands for testing. +- **Combat-queued layout updates** — `UpdatePartyLayout()` currently returns early in combat. A future enhancement should queue changes and apply on `PLAYER_REGEN_ENABLED`. +- **Castbar** — deferred to Phase 4. +- **`EllesmereUI.PartyFrames` registration table** — deferred to Phase 3. CDM integration currently uses `PARTY_FRAME_SOURCES` prefix matching instead. + +--- + +## Chunk 4: Verification + +### Task 9: Verify addon loads without errors + +- [ ] **Step 1: Verify Lua syntax of the new file** + +```bash +luac -p EllesmereUIUnitFrames/EllesmereUIPartyFrames.lua +``` + +If `luac` is not available, use: + +```bash +lua -e "loadfile('EllesmereUIUnitFrames/EllesmereUIPartyFrames.lua')" 2>&1 +``` + +Expected: no output (no syntax errors). + +- [ ] **Step 2: Verify all modified files parse correctly** + +```bash +luac -p EllesmereUIUnitFrames/EllesmereUIUnitFrames.lua +``` + +Expected: no output (no syntax errors). + +- [ ] **Step 3: Verify the .toc file lists all files in correct order** + +Read the `.toc` and confirm `EllesmereUIPartyFrames.lua` appears after `EllesmereUIUnitFrames.lua` and before `EUI_UnitFrames_Options.lua`. + +- [ ] **Step 4: Review: scan for common issues** + +Grep for potential problems: +- Any reference to `CreateHealthBar` (without `ns.`) in the party file → should all use `ns.CreateHealthBar` +- Any `local db` shadowing in party file that might conflict +- Verify `ns.db` is set before `SpawnPartyHeader` is called + +- [ ] **Step 5: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix(party): address any issues found during verification" +``` diff --git a/docs/superpowers/specs/2026-03-17-party-frames-design.md b/docs/superpowers/specs/2026-03-17-party-frames-design.md new file mode 100644 index 00000000..0fa5337f --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-party-frames-design.md @@ -0,0 +1,240 @@ +# Party Frames Design Spec + +## Overview + +Add configurable party frames to EllesmereUIUnitFrames, built on oUF's `SpawnHeader` system. Party frames display party1-4 (optionally including the player) with the same component builder pattern used by existing unit frames. The architecture is designed to extend to raid frames in the future without structural changes. + +## Requirements + +- Configurable for healer or DPS use cases via settings +- Party only (4 members + optional player), raid-ready architecture +- Player inclusion in party group is toggleable +- Every visual component is independently toggleable +- Growth direction: vertical or horizontal +- Sort order: role, group index, or alphabetical +- Uses existing drag-to-position system for the header anchor +- Lives in a new file within the existing `EllesmereUIUnitFrames` addon +- Component builders shared via addon namespace (`ns`) + +## Architecture + +### Approach: Hybrid Header + +Use `oUF:SpawnHeader()` for group lifecycle management (create/destroy/sort/visibility) with a custom `StylePartyFrame` function that reuses existing component builders for appearance. + +- Blizzard's `SecureGroupHeaderTemplate` handles combat lockdown, group join/leave, and sorting +- `StylePartyFrame` calls the same `CreateHealthBar`, `CreatePowerBar`, etc. used by other frames +- Layout changes (sort, growth, spacing) require out-of-combat — standard for all group frame addons + +### Frame Anatomy + +Each party member frame contains (all toggleable): + +| Component | Builder | Default | +|---|---|---| +| Health Bar | `CreateHealthBar` (existing) | On | +| Absorb Bar | `CreateAbsorbBar` (existing) | On | +| Power Bar | `CreatePowerBar` (existing) | On, below | +| Portrait | `CreatePortrait` (existing) | Off | +| Castbar | `CreateCastBar` (existing) | Off | +| Name/Health Text | oUF Tag system (existing) | Name left, HP% right | +| Border | `CreateUnifiedBorder` (existing) | On | +| Auras/Debuffs | New party-specific filter (not `CreateTargetAuras` — party needs debuff-priority filtering with dispel highlights, which differs from the target's full aura display) | Debuffs on, buffs off | +| Role Icon | New | On | +| Range Fading | oUF Range element | On | +| Threat Indicator | oUF ThreatIndicator | On | +| Ready Check | oUF ReadyCheckIndicator | Always on | +| Leader/Assist | oUF LeaderIndicator | Always on | + +Layout: portrait is optional on the left side. Name and role icon sit on the health bar to the right of the portrait (or at the left edge when portrait is off). Health percentage on the far right of the health bar. + +### Header Spawn Pattern + +```lua +oUF:RegisterStyle("EllesmereParty", StylePartyFrame) +oUF:SetActiveStyle("EllesmereParty") + +partyHeader = oUF:SpawnHeader( + "EllesmereUIPartyHeader", + nil, + "custom [@party1,exists] show;hide", + "showPlayer", settings.showPlayer, + "showParty", true, + "showSolo", false, + "point", growthPoint, -- "TOP" or "LEFT" + "xOffset", xOff, + "yOffset", yOff, + "groupBy", "ASSIGNEDROLE", + "groupingOrder", "TANK,HEALER,DAMAGER" +) + +ApplyFramePosition(partyHeader, "party") +``` + +Layout settings map directly to header attributes: + +| Setting | Header Attribute | +|---|---| +| `growthDirection` | `point` + `xOffset`/`yOffset` | +| `sortOrder` | `groupBy` + `groupingOrder` | +| `spacing` | `xOffset`/`yOffset` value | +| `showPlayer` | `showPlayer` | +| Position | `ApplyFramePosition` (existing system) | + +### Settings Structure + +New `party` sub-table in `db.profile`: + +```lua +party = { + -- Frame dimensions + frameWidth = 160, + healthHeight = 36, + + -- Power bar + powerPosition = "below", + powerHeight = 4, + + -- Text (oUF tags) + leftTextContent = "name", + rightTextContent = "perhp", + centerTextContent = "none", + textSize = 11, + + -- Bar opacity + healthBarOpacity = 90, + powerBarOpacity = 100, + + -- Portrait + showPortrait = false, + + -- Party-specific components + showRoleIcon = true, + showCastbar = false, + showThreat = true, + enableRangeFade = true, + rangeFadeAlpha = 0.4, + + -- Auras + showDebuffs = true, + maxDebuffs = 3, + showBuffs = false, + maxBuffs = 0, + highlightDispellable = true, + + -- Layout (header attributes) + growthDirection = "vertical", + sortOrder = "role", + spacing = 1, + showPlayer = false, +}, +``` + +Shared settings (`borderSize`, `borderColor`, `healthBarTexture`, `darkTheme`, `portraitMode`) are read from top-level `db.profile`, same as boss frames. + +`enabledFrames.party` controls the master toggle. `positions.party` stores the header anchor. + +### File Structure + +``` +EllesmereUIUnitFrames/ +├── Libs/oUF/oUF.xml +├── EllesmereUIUnitFrames.lua ← modified (defaults, mappings, ns exports) +├── EllesmereUIPartyFrames.lua ← NEW (~300-400 lines) +├── EUI_UnitFrames_Options.lua ← modified (party options panel) +└── EllesmereUIUnitFrames.toc ← add EllesmereUIPartyFrames.lua +``` + +### Changes to Existing Files + +**`EllesmereUIUnitFrames.lua`:** +- Add `party` defaults to `defaults.profile` +- Add `party = true` to `enabledFrames` +- Add `party` position to `positions` +- Extend `UnitToSettingsKey`: add `if unit:match("^party%d$") then return "party" end` **before** the `db.profile[unit]` fallback probe (line 615), otherwise `party1`–`party4` will return nil since they are not keys in `db.profile` +- Extend `GetSettingsForUnit` map: add `party = db.profile.party` to `unitSettingsMap`. Note: this map is lazily cached — either nil out `unitSettingsMap` after extending it, or add the party entries at the same point where boss entries are built +- Expose component builders via `ns` (complete list): + - `ns.CreateHealthBar` + - `ns.CreateAbsorbBar` + - `ns.CreatePowerBar` + - `ns.CreatePortrait` + - `ns.CreateCastBar` + - `ns.CreateUnifiedBorder` + - `ns.ReparentBarsToClip` (required — every style function calls this for the overflow clip fix) + - `ns.UpdateBordersForScale` (called after border creation in every style function) + - `ns.ApplyFramePosition` + - `ns.SetFSFont` + - `ns.ContentToTag` + - `ns.EstimateUFTextWidth` + - `ns.GetSettingsForUnit` + - `ns.SetupUnitMenu` +- Call `SpawnPartyHeader()` from spawn section +- Register party header with `EllesmereUI.RegisterUnlockElements` for Unlock Mode drag support + +**`.toc` file:** +- Add `EllesmereUIPartyFrames.lua` after `EllesmereUIUnitFrames.lua` + +### New File: `EllesmereUIPartyFrames.lua` + +Contents: +- `StylePartyFrame(frame, unit)` — style function using `ns.*` builders +- `SpawnPartyHeader()` — creates oUF header with settings-driven attributes +- `UpdatePartyLayout()` — applies layout changes out of combat +- Role icon creation helper (small texture on health bar) +- Party aura filter with dispellable highlight logic +- Range fade setup via oUF Range element +- CDM registration: `EllesmereUI.PartyFrames[unit] = frame` + +### CDM Integration + +Party frames register in `EllesmereUI.PartyFrames` lookup table. `FindPlayerPartyFrame()` in `EllesmereUI.lua` must be modified to check this table first before iterating `PARTY_FRAME_SOURCES`. + +oUF names header child frames as `EllesmereUIPartyHeaderUnitButton1` through `EllesmereUIPartyHeaderUnitButton5`. Two integration options: + +1. **Preferred**: Add a first-check path in `FindPlayerPartyFrame()` that reads `EllesmereUI.PartyFrames` before the `PARTY_FRAME_SOURCES` loop +2. **Alternative**: Add `{ addon = "EllesmereUIUnitFrames", prefix = "EllesmereUIPartyHeaderUnitButton", count = 5 }` to `PARTY_FRAME_SOURCES` + +```lua +EllesmereUI.PartyFrames = EllesmereUI.PartyFrames or {} +-- Updated dynamically as header assigns units to child frames +``` + +**Note:** This requires a code change in `EllesmereUI.lua`, not just the unit frames addon. + +## Implementation Phases + +### Phase 1: Core Frame +- `StylePartyFrame` with health, power, absorb, name text, role icon, border +- `SpawnHeader` with growth direction, sort order, spacing, showPlayer +- DB defaults and `enabledFrames` toggle +- Position system integration + +### Phase 2: Auras & Indicators +- Party-specific debuff display with dispellable highlighting +- Buff tracking (optional) +- Threat indicator +- Range fading +- Ready check / resurrection / leader icons + +### Phase 3: CDM Integration +- Register party frames in `EllesmereUI.PartyFrames` +- Update `FindPlayerPartyFrame()` to check native frames first + +### Phase 4: Polish +- Castbar (toggleable per-member) +- Portrait support (2D/3D/class art modes) +- Options panel for party settings +- Live-update support (change settings without /reload) + +## Behavioral Notes + +- **Solo visibility**: Party frames are hidden when solo (`showSolo = false`). Even with `showPlayer = true`, the header only renders when in a group. This is intentional — the standalone player frame handles the solo case. +- **Combat lockdown**: Layout attribute changes (growth direction, sort order, spacing, showPlayer) require out-of-combat. The `UpdatePartyLayout()` function should queue changes and apply them on `PLAYER_REGEN_ENABLED` if called during combat. +- **Unlock Mode**: The party header anchor is registered with `EllesmereUI.RegisterUnlockElements` so it can be dragged in Unlock Mode like all other frames. Individual child frames are not independently draggable — only the header anchor moves. + +## Non-Goals + +- Raid frames (future, same architecture) +- Arena frames +- Per-member customization (all party members share settings) +- CDM bars on party members (separate feature)