Files
ShaguTweaks-ClassicAPI/mods/move-unitframes.lua
T
Dusk 05d29bb798 Add reset support to Movable Unit Frames
Add per-frame reset support to Movable Unit Frames and document the reset action in the module tooltip.
2026-09-08 09:56:50 +02:00

687 lines
20 KiB
Lua

-- Movable Unit Frames
-- Extended frame support adapted from TokensWorth/ShaguTweaks-mods
-- (MIT, original copyright GryllsAddons).
local _G = ShaguTweaks.GetGlobalEnv()
local T = ShaguTweaks.T
local API = ShaguTweaks.API
local module = ShaguTweaks:register({
title = T["Movable Unit Frames"],
description = T["Player, Target, Party, Minimap, Clock, Timer, Buffs, Weapon Buffs and Debuffs can be moved while <Shift> and <Ctrl> are pressed together. Right-click a frame to reset its position."],
expansions = { ["vanilla"] = true },
category = T["Unit Frames"],
enabled = true,
})
module.enable = function(self)
ShaguTweaks_config = ShaguTweaks_config or {}
ShaguTweaks_config["MoveUnitframes"] = ShaguTweaks_config["MoveUnitframes"] or {}
local movedb = ShaguTweaks_config["MoveUnitframes"]
-- Shared live state lets other modules (notably Enlarged Minimap) respect
-- manually positioned aura anchors without fighting the mover while dragging.
ShaguTweaks.MovableUnitFramesState = ShaguTweaks.MovableUnitFramesState or {
manual = {},
dragging = {},
}
local sharedState = ShaguTweaks.MovableUnitFramesState
-- Preserve positions created by the former Extras module. The legacy table
-- is intentionally left untouched so downgrading does not destroy data.
local legacy = ShaguTweaks_config["MoveUnitframesExtended"]
if legacy then
for key, value in pairs(legacy) do
if movedb[key] == nil then
movedb[key] = value
end
end
end
-- Existing saved positions already mean those groups were manually placed.
-- This also covers positions migrated from the former Extras module.
sharedState.manual.buffs = movedb["BuffButton0"] ~= nil
sharedState.manual.debuffs = movedb["BuffButton32"] ~= nil
sharedState.manual.weapon = movedb["TempEnchant1"] ~= nil
sharedState.manual.clock = movedb["Clock"] ~= nil
sharedState.manual.timer = movedb["MinimapTimer"] ~= nil
sharedState.dragging.buffs = false
sharedState.dragging.debuffs = false
sharedState.dragging.weapon = false
sharedState.dragging.clock = false
sharedState.dragging.timer = false
local unlocked = false
local states = {}
local defaultPoints = {}
local grid
-- Player/Target keep the original ShaguTweaks user-placed behavior.
-- The formerly-Extended frames use ShaguTweaks_config so their positions
-- survive reload/relog consistently. defaultPoint is only a fallback for
-- frames whose live pre-move anchor cannot be recovered (for example a
-- layout-cache position restored before addons load).
local targets = {
{
name = "PlayerFrame", clamp = true, persist = false,
defaultPoint = { "TOPLEFT", "UIParent", "TOPLEFT", -19, -4 },
defaultUnclamped = true,
},
{
name = "TargetFrame", clamp = true, persist = false,
defaultPoint = { "TOPLEFT", "UIParent", "TOPLEFT", 250, -4 },
defaultUnclamped = true,
},
{
name = "PartyMemberFrame1", persist = true,
defaultPoint = { "TOPLEFT", "UIParent", "TOPLEFT", 10, -128 },
},
{
name = "PartyMemberFrame2", persist = true,
defaultPoint = { "TOPLEFT", "PartyMemberFrame1PetFrame", "BOTTOMLEFT", -23, -10 },
},
{
name = "PartyMemberFrame3", persist = true,
defaultPoint = { "TOPLEFT", "PartyMemberFrame2PetFrame", "BOTTOMLEFT", -23, -10 },
},
{
name = "PartyMemberFrame4", persist = true,
defaultPoint = { "TOPLEFT", "PartyMemberFrame3PetFrame", "BOTTOMLEFT", -23, -10 },
},
{
name = "Minimap", moveParent = true, persist = true, clamp = true,
defaultPoint = { "TOPRIGHT", "UIParent", "TOPRIGHT", 0, 0 },
},
{
name = "MinimapClock", persist = true, clamp = true,
manualGroup = "clock", suppressMouseDown = true,
defaultPoint = { "BOTTOM", "MinimapCluster", "BOTTOM", 8, 18 },
},
{
-- MiniMap Clock already owns Ctrl+Shift+RightClick for the timer and
-- resets both its value and its position, so keep that native handler.
name = "MinimapTimer", persist = true, clamp = true,
manualGroup = "timer", nativeReset = true,
defaultPoint = { "TOP", "MinimapClock", "BOTTOM", 0, 0 },
},
{
name = "BuffButton0", persist = true, manualGroup = "buffs",
cursorDrag = true, refreshAuras = true,
defaultPoint = { "TOPRIGHT", "BuffFrame", "TOPRIGHT", 0, 0 },
},
{
-- Turtle WoW dynamically positions the first debuff row, so let its
-- own BuffButtons_UpdatePositions() rebuild the natural relationship.
name = "BuffButton32", persist = true, manualGroup = "debuffs",
cursorDrag = true, auraRowReset = true,
},
{
name = "TempEnchant1", persist = true, manualGroup = "weapon",
cursorDrag = true, refreshAuras = true,
defaultPoint = { "TOPRIGHT", "TemporaryEnchantFrame", "TOPRIGHT", 0, 0 },
},
}
local function Resolve(target)
local handle = _G[target.name]
if not handle then return end
local moveFrame = target.moveParent and handle:GetParent() or handle
if not moveFrame then return end
return handle, moveFrame
end
local function PositionKey(target, moveFrame)
if moveFrame.GetName then
local name = moveFrame:GetName()
if name then return name end
end
return target.name
end
local function CapturePoints(frame)
if not frame or not frame.GetPoint then return end
local count = frame.GetNumPoints and frame:GetNumPoints() or 1
local points = {}
for i = 1, count do
local point, relativeTo, relativePoint, x, y = frame:GetPoint(i)
if point then
table.insert(points, {
point,
relativeTo,
relativePoint,
x or 0,
y or 0,
})
end
end
if table.getn(points) > 0 then return points end
end
local function CaptureDefault(target)
local _, moveFrame = Resolve(target)
if not moveFrame then return end
local key = PositionKey(target, moveFrame)
if target.persist and movedb[key] ~= nil then return end
-- A user-placed frame may already have been restored by layout-cache before
-- addon initialization. Do not mistake that old custom position for default.
if moveFrame.IsUserPlaced and moveFrame:IsUserPlaced() then return end
defaultPoints[target.name] = CapturePoints(moveFrame)
end
local function ApplyCapturedPoints(frame, points)
if not frame or not points or table.getn(points) == 0 then return false end
frame:ClearAllPoints()
for _, point in ipairs(points) do
frame:SetPoint(point[1], point[2], point[3], point[4], point[5])
end
return true
end
local function ApplyNamedDefault(target, frame)
local point = target.defaultPoint
if not point or not frame then return false end
local relativeTo = point[2] and _G[point[2]] or nil
if point[2] and not relativeTo then return false end
frame:ClearAllPoints()
frame:SetPoint(point[1], relativeTo, point[3], point[4] or 0, point[5] or 0)
return true
end
local function SetDragging(target, value)
if target.manualGroup then
sharedState.dragging[target.manualGroup] = value and true or false
end
end
local function MarkManual(target)
if target.manualGroup then
sharedState.manual[target.manualGroup] = true
end
end
local function ClearManual(target)
if target.manualGroup then
sharedState.manual[target.manualGroup] = false
sharedState.dragging[target.manualGroup] = false
end
end
local function SavePosition(target, moveFrame)
if not target.persist then return end
if not moveFrame then
local _, resolved = Resolve(target)
moveFrame = resolved
end
if not moveFrame then return end
local left = moveFrame:GetLeft()
local top = moveFrame:GetTop()
if not left or not top then return end
movedb[PositionKey(target, moveFrame)] = { left, top }
MarkManual(target)
end
local function RestorePosition(target)
if not target.persist then return end
local _, moveFrame = Resolve(target)
if not moveFrame then return end
local pos = movedb[PositionKey(target, moveFrame)]
if not pos or not pos[1] or not pos[2] then return end
if not target.cursorDrag then
moveFrame:SetMovable(true)
if moveFrame.SetUserPlaced then moveFrame:SetUserPlaced(true) end
end
moveFrame:ClearAllPoints()
moveFrame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", pos[1], pos[2])
if target.name == "Minimap" and ShaguTweaks.ScheduleMinimapClamp then
ShaguTweaks.ScheduleMinimapClamp()
end
end
-- Aura buttons are layout-owned frames on some Vanilla/Turtle UI builds.
-- Calling StartMoving() on them can fail with "Frame ... is not movable or
-- resizable" even after SetMovable(true). For those frames, follow the cursor
-- ourselves and only change their anchor; regular unit frames still use the
-- native StartMoving path.
local cursorDrag = CreateFrame("Frame")
cursorDrag:Hide()
local function CursorPosition()
local x, y = GetCursorPosition()
local scale = UIParent:GetEffectiveScale()
if not scale or scale <= 0 then scale = 1 end
return x / scale, y / scale
end
local function StartCursorDrag(state, target, moveFrame)
local left = moveFrame:GetLeft()
local top = moveFrame:GetTop()
if not left or not top then return false end
local x, y = CursorPosition()
state.cursorStartX = x
state.cursorStartY = y
state.frameStartLeft = left
state.frameStartTop = top
cursorDrag.state = state
cursorDrag.target = target
cursorDrag.moveFrame = moveFrame
cursorDrag:Show()
return true
end
local function StopCursorDrag(state)
if cursorDrag.state ~= state then return end
cursorDrag:Hide()
cursorDrag.state = nil
cursorDrag.target = nil
cursorDrag.moveFrame = nil
end
local function ResetTarget(state, target, moveFrame)
if not moveFrame then
local _, resolved = Resolve(target)
moveFrame = resolved
end
if not moveFrame then return end
if target.cursorDrag and state then StopCursorDrag(state) end
if target.persist then
movedb[PositionKey(target, moveFrame)] = nil
end
ClearManual(target)
if not target.cursorDrag and moveFrame.SetUserPlaced then
moveFrame:SetUserPlaced(false)
end
local restored = ApplyCapturedPoints(moveFrame, defaultPoints[target.name])
if not restored and target.auraRowReset then
moveFrame:ClearAllPoints()
if BuffButtons_UpdatePositions then
BuffButtons_UpdatePositions()
restored = true
end
end
if not restored then
if target.defaultUnclamped and moveFrame.SetClampedToScreen then
moveFrame:SetClampedToScreen(false)
end
restored = ApplyNamedDefault(target, moveFrame)
end
if target.refreshAuras and BuffButtons_UpdatePositions then
BuffButtons_UpdatePositions()
end
if target.name == "Minimap" and ShaguTweaks.ScheduleMinimapClamp then
ShaguTweaks.ScheduleMinimapClamp()
end
if state then
state.dragged = false
state.reset = true
state.suppressRightClick = true
state.userPlaced = false
end
end
cursorDrag:SetScript("OnUpdate", function()
local state = this.state
local target = this.target
local moveFrame = this.moveFrame
if not state or not target or not moveFrame then
this:Hide()
return
end
local x, y = CursorPosition()
local left = state.frameStartLeft + (x - state.cursorStartX)
local top = state.frameStartTop + (y - state.cursorStartY)
moveFrame:ClearAllPoints()
moveFrame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", left, top)
end)
local function CreateGrid()
if grid then return grid end
-- Keep the original WorldFrame coordinate space so the cells retain the
-- same visual size as the old grid. Instead of deriving the yellow center
-- lines from top-left offsets, build the whole grid symmetrically around
-- WorldFrame:CENTER. This guarantees the two yellow axes are truly centered
-- regardless of UI scale / widescreen coordinate quirks.
grid = CreateFrame("Frame", nil, WorldFrame)
grid:SetAllPoints(WorldFrame)
grid:Hide()
local size = 1
local step = GetScreenWidth() / 64
-- Vertical lines: 32 cells to the left and right of the exact center.
for i = -32, 32 do
local line = grid:CreateTexture(nil, i == 0 and "BORDER" or "BACKGROUND")
if i == 0 then
line:SetTexture(.8, .6, 0)
else
line:SetTexture(0, 0, 0, .2)
end
line:SetWidth(size)
line:SetPoint("TOP", grid, "TOP", i * step, 0)
line:SetPoint("BOTTOM", grid, "BOTTOM", i * step, 0)
end
-- Use the exact same step vertically so every cell remains square, as in
-- the original implementation. Extra off-screen lines are harmless.
for i = -32, 32 do
local line = grid:CreateTexture(nil, i == 0 and "BORDER" or "BACKGROUND")
if i == 0 then
line:SetTexture(.8, .6, 0)
else
line:SetTexture(0, 0, 0, .2)
end
line:SetHeight(size)
line:SetPoint("LEFT", grid, "LEFT", 0, i * step)
line:SetPoint("RIGHT", grid, "RIGHT", 0, i * step)
end
return grid
end
local function UnlockTarget(index, target)
local handle, moveFrame = Resolve(target)
if not handle or not moveFrame then return end
states[index] = states[index] or {}
local state = states[index]
if state.active then return end
state.active = true
state.dragged = false
state.reset = false
state.suppressRightClick = false
state.handle = handle
state.moveFrame = moveFrame
state.onDragStart = handle:GetScript("OnDragStart")
state.onDragStop = handle:GetScript("OnDragStop")
state.onMouseDown = handle:GetScript("OnMouseDown")
state.onMouseUp = handle:GetScript("OnMouseUp")
state.hasClick = handle.RegisterForClicks and true or false
if state.hasClick then
state.onClick = handle:GetScript("OnClick")
else
state.onClick = nil
end
if handle.IsMouseEnabled then
state.mouseEnabled = handle:IsMouseEnabled() and true or false
else
state.mouseEnabled = nil
end
if not target.cursorDrag and moveFrame.IsMovable then
state.movable = moveFrame:IsMovable() and true or false
else
state.movable = nil
end
if not target.cursorDrag and moveFrame.IsUserPlaced then
state.userPlaced = moveFrame:IsUserPlaced() and true or false
else
state.userPlaced = nil
end
if target.clamp and moveFrame.SetClampedToScreen then
moveFrame:SetClampedToScreen(true)
end
-- Aura buttons can reject StartMoving() on some clients. They only need
-- mouse/drag scripts because cursorDrag handles their position directly.
if not target.cursorDrag then
moveFrame:SetMovable(true)
end
handle:EnableMouse(true)
handle:RegisterForDrag("LeftButton")
-- While Ctrl+Shift mode owns the frame, Ctrl+Shift+RightClick resets only
-- that frame and swallows its normal right-click action (unit menu, buff
-- cancel, minimap zoom, etc.). MiniMap Timer keeps its own native reset.
if not target.nativeReset then
handle:SetScript("OnMouseDown", function()
if arg1 == "RightButton" then
state.suppressRightClick = true
ResetTarget(state, target, moveFrame)
return
end
state.suppressRightClick = false
if not target.suppressMouseDown and state.onMouseDown then
state.onMouseDown()
end
end)
handle:SetScript("OnMouseUp", function()
if arg1 == "RightButton" and state.suppressRightClick then return end
if state.onMouseUp then state.onMouseUp() end
end)
if state.hasClick then
handle:SetScript("OnClick", function()
if arg1 == "RightButton" and state.suppressRightClick then
state.suppressRightClick = false
return
end
if state.onClick then state.onClick() end
end)
end
end
handle:SetScript("OnDragStart", function()
state.dragged = true
state.reset = false
MarkManual(target)
SetDragging(target, true)
if not target.cursorDrag and moveFrame.SetUserPlaced then
moveFrame:SetUserPlaced(true)
end
if target.cursorDrag then
if not StartCursorDrag(state, target, moveFrame) then
state.dragged = false
SetDragging(target, false)
end
else
moveFrame:StartMoving()
end
end)
handle:SetScript("OnDragStop", function()
if target.cursorDrag then
StopCursorDrag(state)
else
moveFrame:StopMovingOrSizing()
end
if state.dragged then
SavePosition(target, moveFrame)
end
SetDragging(target, false)
end)
end
local function LockTarget(index, target)
local state = states[index]
if not state or not state.active then return end
local handle = state.handle
local moveFrame = state.moveFrame
if moveFrame then
if target.cursorDrag then
StopCursorDrag(state)
else
moveFrame:StopMovingOrSizing()
end
SetDragging(target, false)
if state.dragged then
SavePosition(target, moveFrame)
end
-- MiniMap Timer has its own Ctrl+Shift reset path. Detect its
-- SetUserPlaced(false) so releasing the modifiers does not undo it.
if target.nativeReset
and not state.dragged
and state.userPlaced
and moveFrame.IsUserPlaced
and not moveFrame:IsUserPlaced() then
state.reset = true
state.userPlaced = false
end
-- Restore UserPlaced while the frame is still temporarily movable.
-- Some Vanilla/Turtle frames (notably MinimapCluster) reject
-- SetUserPlaced() after SetMovable(false), which caused repeated
-- "not movable or resizable" errors when releasing Ctrl+Shift.
if not target.cursorDrag
and not state.dragged
and not state.reset
and state.userPlaced ~= nil
and moveFrame.SetUserPlaced then
moveFrame:SetUserPlaced(state.userPlaced)
end
if not target.cursorDrag and state.movable ~= nil then
moveFrame:SetMovable(state.movable)
end
end
if handle then
handle:SetScript("OnDragStart", state.onDragStart)
handle:SetScript("OnDragStop", state.onDragStop)
if not target.nativeReset then
handle:SetScript("OnMouseDown", state.onMouseDown)
handle:SetScript("OnMouseUp", state.onMouseUp)
if state.hasClick then
handle:SetScript("OnClick", state.onClick)
end
end
if state.mouseEnabled ~= nil then
handle:EnableMouse(state.mouseEnabled)
end
end
state.active = false
end
local function UnlockAll()
if unlocked then return end
unlocked = true
for i, target in ipairs(targets) do
UnlockTarget(i, target)
end
CreateGrid():Show()
end
local function LockAll()
if not unlocked then return end
for i, target in ipairs(targets) do
LockTarget(i, target)
end
if grid then grid:Hide() end
unlocked = false
end
local function UpdateLockState()
if API.IsShiftKeyDown() and API.IsControlKeyDown() then
UnlockAll()
else
LockAll()
end
end
local events = CreateFrame("Frame")
events:RegisterEvent("PLAYER_ENTERING_WORLD")
if API.modifierstate then
events:RegisterEvent("MODIFIER_STATE_CHANGED")
end
events:SetScript("OnEvent", function()
if event == "PLAYER_ENTERING_WORLD" then
for _, target in ipairs(targets) do
RestorePosition(target)
end
end
UpdateLockState()
end)
-- ClassicAPI supplies MODIFIER_STATE_CHANGED. Only old/fallback clients use
-- a small throttled key-state check.
if not API.modifierstate then
events.elapsed = 0
events:SetScript("OnUpdate", function()
this.elapsed = this.elapsed + (arg1 or 0)
if this.elapsed < .10 then return end
this.elapsed = 0
UpdateLockState()
end)
end
-- Capture clean pre-move anchors before this module applies any saved
-- positions. This preserves compatible addon/Turtle layouts when possible.
for _, target in ipairs(targets) do
CaptureDefault(target)
if target.clamp then
local _, moveFrame = Resolve(target)
if moveFrame and moveFrame.SetClampedToScreen then
moveFrame:SetClampedToScreen(true)
end
end
RestorePosition(target)
end
UpdateLockState()
end