
local detailsFramework = _G["DetailsFramework"]
if (not detailsFramework or not DetailsFrameworkCanLoad) then
	return
end

---@cast detailsFramework detailsframework

local CreateFrame = CreateFrame
local unpack = unpack
local wipe = table.wipe

--maximum number of entries kept in the undo / redo stacks; oldest entries are dropped beyond this.
local MAX_UNDO_STACK = 50

--shallow equality for undo snapshots. used to skip pushing undo entries when nothing actually
--changed - this happens when BuildMenuVolatile fires set() during widget construction with the
--current value, which would otherwise create no-op undo entries that wipe the redo stack.
local function snapshotsEqual(a, b)
    if (a == b) then
        --same reference, same scalar, or both nil
        return true
    end
    if (type(a) ~= "table" or type(b) ~= "table") then
        return false
    end
    for k, v in pairs(a) do
        if (b[k] ~= v) then
            return false
        end
    end
    for k in pairs(b) do
        if (a[k] == nil) then
            return false
        end
    end
    return true
end

--[=[
    file description: this file has the code for the object editor
    the object editor itself is a frame and has a scrollframe as canvas showing another frame where there's the options for the editing object

--]=]


--the editor doesn't know which key in the profileTable holds the current value for an attribute, so it uses a map table to find it.
--the mapTable is a table with the attribute name as a key, and the value is the profile key. For example, {["size"] = "text_size"} means profileTable["text_size"] = 10.




---@class df_editor : frame, df_optionsmixin, df_editormixin
---@field options table
---@field registeredObjects df_editor_objectinfo[]
---@field registeredObjectsByID table<any, df_editor_objectinfo>
---@field editingObject uiobject
---@field editingProfileTable table
---@field editingProfileMap table
---@field editingOptions df_editobjectoptions
---@field editingExtraOptions table
---@field registeredObjectInfo df_editor_objectinfo
---@field onEditCallback function
---@field optionsFrame frame
---@field overTheTopFrame frame
---@field objectSelector df_scrollbox
---@field moverObject moverobject
---@field canvasScrollBox df_canvasscrollbox
---@field ObjectBackgroundTexture texture
---@field AnchorFrames df_editor_anchorframes
---@field SelectedTextures texture[]
---@field undoHistory undostate[]
---@field redoHistory undostate[]
---@field UndoButton df_button?
---@field RedoButton df_button?
---each undo entry is a pair of closures. coalesceKey lets adjacent entries from the same widget
---(e.g. continuous slider drag) merge into one stack entry instead of N.
---@class undostate : table
---@field objectId any
---@field coalesceKey string
---@field undo fun()
---@field redo fun()

---@class df_editor_attribute
---@field key string?
---@field label string?
---@field widget string?
---@field type string?
---@field default any?
---@field minvalue number?
---@field maxvalue number?
---@field step number?
---@field usedecimals boolean?
---@field subkey string?
---@field profileTable table? per-extra override of the registration's profileTable, so an option can read/write against a different scope (e.g., the profile root on a registration bound to a sub-table)
---@field setter fun(object:any, value:any)?
---@field dropdownFunc function?
---@field text? string label widget: literal text to display when type == "label"
---@field name? string label widget: localization key fallback when type == "label"
---@field get? fun():string label widget: dynamic text getter when type == "label"
---@field text_template? table label widget: font template applied when type == "label"
---@field color? any label widget: text color applied when type == "label"
---@field namePhraseId? string label widget: phrase id for language table lookup when type == "label"

---@class df_editor_objectinfo : table
---@field object uiobject alias for objects[1]; the canonical "primary" member kept for backward compatibility with external consumers that read .object directly
---@field objects uiobject[] every UIObject bound to this registration; length >= 1. multi-member registrations have the same options/profile mapping driving all members
---@field label string
---@field id any
---@field profiletable table
---@field profilekeymap table
---@field subtablepath string?
---@field extraoptions table
---@field callback fun(object:df_editor_objectinfo, key:string, value:any, profileTable:table, profileKey:string)
---@field options df_editobjectoptions
---@field selectButton button alias for selectButtons[1]
---@field selectButtons button[] one click-to-select overlay per member widget
---@field activeMemberIndex number? which member was last clicked (or 1 by default); brackets/mover follow this member
---@field refFrame frame usually the parent of the object registered

---@class df_editor_mover_movinginfo : table
---@field startX number
---@field startY number
---@field restingX number
---@field restingY number



--which object attributes are used to build the editor menu for each object type
local attributes = {
    ---@type df_editor_attribute[]
    Texture = {
        {
            key = "texture",
            label = "Texture",
            widget = "selectstatusbartexture",
            default = "",
            setter = function(widget, value) widget:SetTexture(value) end,
        },
        {
            key = "width",
            label = "Width",
            widget = "range",
            minvalue = 5,
            maxvalue = 120,
            setter = function(widget, value) widget:SetWidth(value) end,
        },
        {
            key = "height",
            label = "Height",
            widget = "range",
            minvalue = 5,
            maxvalue = 120,
            setter = function(widget, value) widget:SetHeight(value) end,
        },
        {
            key = "vertexcolor",
            label = "Color",
            widget = "color",
            setter = function(widget, value) widget:SetVertexColor(unpack(value)) end,
        },
        {
            key = "alpha",
            label = "Alpha",
            widget = "range",
            minvalue = 0,
            maxvalue = 1,
            usedecimals = true,
            setter = function(widget, value) widget:SetAlpha(value) end,
        },

        {widget = "blank"},
        {
            key = "anchor",
            label = "Anchor",
            widget = "anchordropdown",
            setter = function(widget, value) detailsFramework:SetAnchor(widget, value, widget:GetParent()) end,
        },
        {
            key = "anchoroffsetx",
            label = "Anchor X Offset",
            widget = "range",
            minvalue = -120,
            maxvalue = 120,
            setter = function(widget, value) detailsFramework:SetAnchor(widget, value, widget:GetParent()) end,
        },
        {
            key = "anchoroffsety",
            label = "Anchor Y Offset",
            widget = "range",
            minvalue = -120,
            maxvalue = 120,
            setter = function(widget, value) detailsFramework:SetAnchor(widget, value, widget:GetParent()) end,
        },
    },

    FontString = {
        {
            key = "text",
            label = "Text",
            widget = "textentry",
            default = "font string text",
            setter = function(widget, value) widget:SetText(value) end,
        },
        {
            key = "size",
            label = "Size",
            widget = "range",
            minvalue = 5,
            maxvalue = 120,
            setter = function(widget, value) widget:SetFont(widget:GetFont(), value, select(3, widget:GetFont())) end,
        },
        {
            key = "font",
            label = "Font",
            widget = "fontdropdown",
            setter = function(widget, value)
                local font = LibStub:GetLibrary("LibSharedMedia-3.0"):Fetch("font", value)
                widget:SetFont(font, select(2, widget:GetFont()))
            end,
        },
        {
            key = "color",
            label = "Color",
            widget = "color",
            setter = function(widget, value) widget:SetTextColor(unpack(value)) end,
        },
        {
            key = "alpha",
            label = "Alpha",
            widget = "range",
            minvalue = 0,
            maxvalue = 1,
            usedecimals = true,
            setter = function(widget, value) widget:SetAlpha(value) end,
        },
        {widget = "blank"},
        {
            key = "shadow",
            label = "Draw Shadow",
            widget = "toggle",
            setter = function(widget, value) widget:SetShadowColor(widget:GetShadowColor(), select(2, widget:GetShadowColor()), select(3, widget:GetShadowColor()), value and 0.5 or 0) end,
        },
        {
            key = "shadowcolor",
            label = "Shadow Color",
            widget = "color",
            setter = function(widget, value) widget:SetShadowColor(unpack(value)) end,
        },
        {
            key = "shadowoffsetx",
            label = "Shadow X Offset",
            widget = "range",
            minvalue = -10,
            maxvalue = 10,
            setter = function(widget, value) widget:SetShadowOffset(value, select(2, widget:GetShadowOffset())) end,
        },
        {
            key = "shadowoffsety",
            label = "Shadow Y Offset",
            widget = "range",
            minvalue = -10,
            maxvalue = 10,
            setter = function(widget, value) widget:SetShadowOffset(widget:GetShadowOffset(), value) end,
        },
        {
            key = "outline",
            label = "Outline",
            widget = "outlinedropdown",
            setter = function(widget, value) widget:SetFont(widget:GetFont(), select(2, widget:GetFont()), value) end,
        },
        {widget = "blank"},
        {
            key = "anchor",
            label = "Anchor",
            widget = "anchordropdown",
            setter = function(widget, value) detailsFramework:SetAnchor(widget, value, widget:GetParent()) end,
        },
        {
            key = "anchoroffsetx",
            label = "Anchor X Offset",
            widget = "range",
            minvalue = -120,
            maxvalue = 120,
            setter = function(widget, value) detailsFramework:SetAnchor(widget, value, widget:GetParent()) end,
        },
        {
            key = "anchoroffsety",
            label = "Anchor Y Offset",
            widget = "range",
            minvalue = -120,
            maxvalue = 120,
            setter = function(widget, value) detailsFramework:SetAnchor(widget, value, widget:GetParent()) end,
        },
        {
            key = "rotation",
            label = "Rotation",
            widget = "range",
            usedecimals = true,
            minvalue = 0,
            maxvalue = math.pi*2,
            setter = function(widget, value) widget:SetRotation(value) end,
        },
        {
            key = "scale",
            label = "Scale",
            widget = "range",
            usedecimals = true,
            minvalue = 0.65,
            maxvalue = 2.5,
            setter = function(widget, value) widget:SetScale(value) end,
        },
    },

    Frame = {
        {
            key = "width",
            label = "Width",
            widget = "range",
            minvalue = 5,
            maxvalue = 800,
            setter = function(widget, value) widget:SetWidth(value) end,
        },
        {
            key = "height",
            label = "Height",
            widget = "range",
            minvalue = 5,
            maxvalue = 600,
            setter = function(widget, value) widget:SetHeight(value) end,
        },
        --alpha
        {
            key = "alpha",
            label = "Alpha",
            widget = "range",
            minvalue = 0,
            maxvalue = 1,
            usedecimals = true,
            setter = function(widget, value) widget:SetAlpha(value) end,
        },
        --frame strata
        {
            key = "framestrata",
            label = "Frame Strata",
            widget = "selectframestrata",
            setter = function(widget, value) widget:SetFrameStrata(value) end,
        },

        {widget = "blank"},
        {
            key = "anchor",
            label = "Anchor",
            widget = "anchordropdown",
            setter = function(widget, value) detailsFramework:SetAnchor(widget, value, widget:GetParent()) end,
        },
        {
            key = "anchoroffsetx",
            label = "Anchor X Offset",
            widget = "range",
            minvalue = -400,
            maxvalue = 400,
            setter = function(widget, value) detailsFramework:SetAnchor(widget, value, widget:GetParent()) end,
        },
        {
            key = "anchoroffsety",
            label = "Anchor Y Offset",
            widget = "range",
            minvalue = -300,
            maxvalue = 300,
            setter = function(widget, value) detailsFramework:SetAnchor(widget, value, widget:GetParent()) end,
        },
    },
}

---@class df_editormixin : table
---@field CreateMoverFrame fun(self:df_editor):df_editor_mover[]
---@field CreateObjectSelectionList fun(self:df_editor, scroll_width:number, scroll_height:number, scroll_lines:number, scroll_line_height:number):df_scrollbox
---@field GetAllRegisteredObjects fun(self:df_editor):df_editor_objectinfo[]
---@field GetEditingObject fun(self:df_editor):uiobject
---@field GetEditingObjectIndex fun(self:df_editor):number?
---@field GetEditingOptions fun(self:df_editor):df_editobjectoptions
---@field GetExtraOptions fun(self:df_editor):table
---@field GetEditingProfile fun(self:df_editor):table, table
---@field GetOnEditCallback fun(self:df_editor):function
---@field GetOptionsFrame fun(self:df_editor):df_menu
---@field GetCanvasScrollBox fun(self:df_editor):df_canvasscrollbox
---@field GetObjectSelector fun(self:df_editor):df_scrollbox
---@field GetOverTheTopFrame fun(self:df_editor):frame
---@field GetMoverObject fun(self:df_editor):moverobject
---@field GetObjectById fun(self:df_editor, id:string):df_editor_objectinfo
---@field GetObjectByRef fun(self:df_editor, object:uiobject):df_editor_objectinfo
---@field GetObjectByIndex fun(self:df_editor, index:number):df_editor_objectinfo
---@field GetObjectByObjectInfo fun(self:df_editor, objectInfo:df_editor_objectinfo):df_editor_objectinfo
---@field GetEditingRegisteredObject fun(self:df_editor):df_editor_objectinfo
---@field EditObject fun(self:df_editor, object:df_editor_objectinfo, activeMember:uiobject?) when activeMember is omitted, falls back to the registration's last-clicked member (activeMemberIndex) or to objects[1].
---@field EditObjectById fun(self:df_editor, id:any)
---@field EditObjectByIndex fun(self:df_editor, index:number)
---@field ClearEditing fun(self:df_editor) tear down editor UI and clear all editing state
---@field CreateUndoManager fun(self:df_editor)
---@field AddToUndoHistory fun(self:df_editor, state:undostate)
---@field AddMoverUndoState fun(self:df_editor, registeredObject:df_editor_objectinfo, anchorTable:table, oldX:number, oldY:number, newX:number, newY:number)
---@field Undo fun(self:df_editor)
---@field Redo fun(self:df_editor)
---@field RefreshUndoButtons fun(self:df_editor) update enable/disable state of the toolbar undo/redo buttons
---@field PrepareObjectForEditing fun(self:df_editor)
---@field StartObjectMovement fun(self:df_editor, anchorSettings:df_anchor)
---@field StopObjectMovement fun(self:df_editor)
---@field RegisterObject fun(self:df_editor, object:uiobject|uiobject[], localizedLabel:string, id:string, profileTable:table, subTablePath:string, profileKeyMap:table, extraOptions:table?, callback:function?, options:df_editobjectoptions?, refFrame:frame):df_editor_objectinfo register one or more widgets under a single logical entry. When an array of widgets is passed, all members share the same option set and any in-place selection click selects the registration with the clicked member becoming the brackets/mover focus. All members must share the same object type.
---@field UnregisterObject fun(self:df_editor, object:uiobject)
---@field OnHide fun(self:df_editor)
---@field OnShow fun(self:df_editor)
---@field CreateSelectedTextures fun(self:df_editor)
---@field ShowSelectedTextures fun(self:df_editor, object:uiobject) 90 degree corner on each corner of the object
---@field HideSelectedTextures fun(self:df_editor) hide the four corner highlight textures
---@field SetSelectedBackgroundColor fun(self:df_editor, r:number, g:number, b:number, a:number) set the color of the filled rectangle drawn under the object being edited (defaults to magenta 1,0,1,0.25)
---@field GetProfileTableFromObject fun(self:df_editor, object:df_editor_objectinfo):table
---@field UpdateProfileTableOnAllRegisteredObjects fun(self:df_editor, profileTable:table)
---@field UpdateProfileTable fun(self:df_editor, identifier:any, profileTable:table):boolean change the profile table, identifier is the ID, index or object reference of the registered object info
---@field UpdateProfileSubTablePath fun(self:df_editor, identifier:any, subTablePath:string) change the subTablePath, identifier is the ID, index, object reference of the registered object info
---@field Refresh fun(self:df_editor) re-start editing the object that is currently being edited

---@class df_editobjectoptions : table
---@field use_colon boolean? if true a colon is shown after the option name
---@field can_move boolean? if true the object can be moved
---@field can_click boolean? if true the live-preview click-to-select overlay is shown for this object
---@field icon any atlasName atlasTable (from DF:CreateAtlas) or texture path|id

---@type df_editobjectoptions
local editObjectDefaultOptions = {
    use_colon = false,
    can_move = true,
    can_click = true,
}

---@class df_editor_defaultoptions : table
---@field width number
---@field height number
---@field options_width number
---@field create_object_list boolean
---@field object_list_width number
---@field object_list_height number
---@field object_list_lines number
---@field object_list_line_height number
---@field text_template table
---@field dropdown_template table
---@field switch_template table
---@field button_template table
---@field slider_template table
---@field no_anchor_points boolean
---@field start_editing_callback fun(editorFrame: df_editor, registeredObject: df_editor_objectinfo)?
---@field selection_texture string
---@field selection_size number
---@field show_undo_buttons boolean

---@type df_editor_defaultoptions
local editorDefaultOptions = {
    width = 400,
    height = 548,
    options_width = 340,
    create_object_list = true,
    object_list_width = 200,
    object_list_height = 420,
    object_list_lines = 20,
    object_list_line_height = 20,
    dropdown_template = detailsFramework:GetTemplate("dropdown", "OPTIONS_DROPDOWN_TEMPLATE"),
    switch_template = detailsFramework:GetTemplate("switch", "OPTIONS_CHECKBOX_TEMPLATE"),
    button_template = detailsFramework:GetTemplate("button", "OPTIONS_BUTTON_TEMPLATE"),
    slider_template = detailsFramework:GetTemplate("slider", "OPTIONS_SLIDER_TEMPLATE"),
    text_template = detailsFramework:GetTemplate("font", "OPTIONS_FONT_TEMPLATE"),
    no_anchor_points = false,
    start_editing_callback = nil,
    selection_texture = "GM_BehaviorMessage_CornerTopLeft_Frame",
    selection_size = 8,
    show_undo_buttons = true,
}


local getParentTable = function(profileTable, profileKey)
    local parentPath
    if (profileKey:match("%]$")) then
        parentPath = profileKey:gsub("%s*%[.*%]%s*$", "")
    else
        parentPath = profileKey:gsub("%.[^.]*$", "")
    end

    local parentTable = detailsFramework.table.getfrompath(profileTable, parentPath)
    return parentTable
end

---@param self df_editor
---@param identifier any
---@return df_editor_objectinfo?
local getRegisteredObject = function(self, identifier)
    if (type(identifier) == "string") then
        return self:GetObjectById(identifier)

    elseif (type(identifier) == "number") then
        return self:GetObjectByIndex(identifier)

    elseif (type(identifier) == "table" and identifier.GetObjectType) then
        return self:GetObjectByRef(identifier)

    elseif (type(identifier) == "table" and identifier.refFrame) then
        return self:GetObjectByObjectInfo(identifier)
    end

    return nil
end

detailsFramework.EditorMixin = {
    ---@param self df_editor
    GetEditingObject = function(self)
        return self.editingObject
    end,

    ---@param self df_editor
    Refresh = function(self)
        local registeredObject = self:GetEditingRegisteredObject()
        if (registeredObject) then
            self:EditObject(registeredObject)
        end
    end,

    ---@param self df_editor
    GetEditingObjectIndex = function(self)
        local object = self:GetEditingObject()
        local registeredObjects = self:GetAllRegisteredObjects()
        for i = 1, #registeredObjects do
            local objectRegistered = registeredObjects[i]
            --multi-member registrations: the editing widget may be any member, not just objects[1].
            local members = objectRegistered.objects or {objectRegistered.object}
            for j = 1, #members do
                if (members[j] == object) then
                    return i
                end
            end
        end
    end,

    ---@param self df_editor
    ---@return df_editobjectoptions
    GetEditingOptions = function(self)
        return self.editingOptions
    end,

    ---@param self df_editor
    ---@return table
    GetExtraOptions = function(self)
        return self.editingExtraOptions
    end,

    ---@param self df_editor
    ---@return table, table
    GetEditingProfile = function(self)
        return self.editingProfileTable, self.editingProfileMap
    end,

    ---@param self df_editor
    ---@return function
    GetOnEditCallback = function(self)
        return self.onEditCallback
    end,

    GetOptionsFrame = function(self)
        return self.optionsFrame
    end,

    GetOverTheTopFrame = function(self)
        return self.overTheTopFrame
    end,

    GetMoverObject = function(self)
        return self.moverObject
    end,

    GetCanvasScrollBox = function(self)
        return self.canvasScrollBox
    end,

    GetObjectSelector = function(self)
        return self.objectSelector
    end,

    ---@param self df_editor
    CreateSelectedTextures = function(self)
        self.SelectedTextures = {}
        local anchors = {"topleft", "topright", "bottomleft", "bottomright"}
        for i = 1, #anchors do
            local thisAnchor = anchors[i]
            local texture = self:GetOverTheTopFrame():CreateTexture(nil, "overlay")
            detailsFramework:SetTexture(texture, self.options.selection_texture)
            texture:SetDrawLayer("overlay", 6)
            texture:Hide()
            self.SelectedTextures[i] = texture

            if (thisAnchor == "topright") then
                texture:SetTexCoord(1, 0, 0, 1)
            elseif (thisAnchor == "bottomleft") then
                texture:SetTexCoord(0, 1, 1, 0)
            elseif (thisAnchor == "bottomright") then
                texture:SetTexCoord(1, 0, 1, 0)
            end
        end
    end,

    --90 degree corner on each corner of the object
    ShowSelectedTextures = function(self, object)
        local textures = self.SelectedTextures
        local size = self.options.selection_size

        --topleft
        textures[1]:ClearAllPoints()
        textures[1]:SetPoint("topleft", object, "topleft", -3, 3)
        textures[1]:SetSize(size, size)
        textures[1]:Show()

        --topright
        textures[2]:ClearAllPoints()
        textures[2]:SetPoint("topright", object, "topright", 3, 3)
        textures[2]:SetSize(size, size)
        textures[2]:Show()

        --bottomleft
        textures[3]:ClearAllPoints()
        textures[3]:SetPoint("bottomleft", object, "bottomleft", -3, -3)
        textures[3]:SetSize(size, size)
        textures[3]:Show()

        --bottomright
        textures[4]:ClearAllPoints()
        textures[4]:SetPoint("bottomright", object, "bottomright", 3, -3)
        textures[4]:SetSize(size, size)
        textures[4]:Show()
    end,

    --symmetric to ShowSelectedTextures. called from OnHide and ClearEditing so the corner
    --highlights don't linger over a previously-edited widget when the editor closes or the
    --active object is unregistered.
    ---@param self df_editor
    HideSelectedTextures = function(self)
        local textures = self.SelectedTextures
        for i = 1, #textures do
            textures[i]:Hide()
        end
    end,

    --set the color of the magenta rectangle drawn under the object being edited.
    --default is (1, 0, 1, 0.25); change it per-editor to better contrast against the consumer's preview.
    ---@param self df_editor
    ---@param r number
    ---@param g number
    ---@param b number
    ---@param a number
    SetSelectedBackgroundColor = function(self, r, g, b, a)
        ---@diagnostic disable-next-line: undefined-field
        self.ObjectBackgroundTexture:SetColorTexture(r, g, b, a)
    end,

---@class df_editor_anchorframes : table
---@field anchorFrames table
---@field DisableAllAnchors fun(self:df_editor_anchorframes)
---@field SetupAnchorsForObject fun(self:df_editor_anchorframes, anchorTable:table)
---@field SelectAnchorPoint fun(self:df_editor_anchorframes)
---@field GetAnchorFrame fun(self:df_editor_anchorframes, index:number):frame
---@field SetNotInUseForAllAnchors fun(self:df_editor_anchorframes)
---@field SetNotInUse fun(self:df_editor_anchorframes, anchorFrame:frame)
---@field SetInUse fun(self:df_editor_anchorframes, anchorFrame:frame)
---@field CreateNineAnchors fun(self:df_editor_anchorframes)

    --screen position offset is the XY screen position offset from the bottom left of the screen, they are always positive, they go from 0 to screen width or height
    CreateAnchorFrames = function(editorFrame)
        --table containing 9 buttons, each one in a different position of the object, indexes one to nine in this order: topleft, left, bottomleft, bottom, bottomright, right, topright, top, center
        editorFrame.AnchorFrames = {
            anchorFrames = {},

            DisableAllAnchors = function(self)
                for i = 1, 9 do
                    local anchorFrame = self:GetAnchorFrame(i)
                    self:SetNotInUse(anchorFrame)
                    anchorFrame:Hide()
                end
            end,

            SetupAnchorsForObject = function(self, anchorTable)
                editorFrame.AnchorFrames:DisableAllAnchors()

                local registeredObject = editorFrame:GetEditingRegisteredObject()

                if (registeredObject.refFrame) then
                    for i = 1, 9 do
                        local anchorFrame = self.anchorFrames[i]
                        local anchorName = detailsFramework.AnchorPointsByIndex[i]
                        anchorFrame:ClearAllPoints()
                        anchorFrame:SetPoint(anchorName, registeredObject.refFrame, anchorName, 0, 0)
                        anchorFrame:Show()
                    end

                    local sideSelected = anchorTable.side
                    local anchorFrameSelected = self:GetAnchorFrame(detailsFramework.InsidePointsToAnchor[sideSelected] or sideSelected)
                    self:SetInUse(anchorFrameSelected)

                    self.anchorTable = anchorTable
                end
            end,

            --when the user click on one of the anchor points, change the anchor side setting and recalculate the xy offset of the new point related to the same point in the object
            SelectAnchorPoint = function(anchorFrame)
                editorFrame.AnchorFrames:SetNotInUseForAllAnchors()

                --change the color of the anchor point to show it's selected
                anchorFrame.Texture:SetColorTexture(1, 0, 0, 0.5)

                --get the object being edited in the editor
                local object = editorFrame:GetEditingObject()

                --get the xy of the nine points of the object
                local ninePoints = detailsFramework.Math.GetNinePoints(object)

                --get the coordinates of the anchorIndex within the ninePoints table
                --the xy point in here is the XY screen position offset from the bottom left of the screen
                local screenPoint = ninePoints[anchorFrame.anchorIndex]
                local objectScreenPosX = screenPoint.x
                local objectScreenPosY = screenPoint.y

                --get the screen position offset of the anchor
                local anchorScreenPosX, anchorScreenPosY = anchorFrame:GetCenter()

                --calculate the xy offset of the anchor point related to the object
                local offsetX = objectScreenPosX - anchorScreenPosX
                local offsetY = objectScreenPosY - anchorScreenPosY

                --get the anchor settings table
                local anchorTable = editorFrame.AnchorFrames.anchorTable

                --set the anchor settings
                anchorTable.x = offsetX
                anchorTable.y = offsetY
                anchorTable.side = detailsFramework.AnchorPointsToInside[anchorFrame.anchorIndex]

                C_Timer.After(0, function()
                    editorFrame:PrepareObjectForEditing()
                end)
            end,

            GetAnchorFrame = function(self, anchorIndex)
                return self.anchorFrames[anchorIndex]
            end,

            SetNotInUseForAllAnchors = function(self)
                for i = 1, 9 do
                    local anchorFrame = self:GetAnchorFrame(i)
                    self:SetNotInUse(anchorFrame)
                end
            end,

            SetNotInUse = function(self, anchorFrame)
                anchorFrame.Texture:SetColorTexture(1, 1, 1, 0.5)
            end,

            SetInUse = function(self, anchorFrame)
                anchorFrame.Texture:SetColorTexture(1, 0, 0, 0.5)
            end,

            CreateNineAnchors = function(self)
                local overTheTopFrame = editorFrame:GetOverTheTopFrame()

                for i = 1, 9 do
                    local anchorFrame = CreateFrame("button", "$parentAnchorFrame" .. i, overTheTopFrame, "BackdropTemplate")
                    anchorFrame:SetSize(8, 8)
                    anchorFrame:SetBackdrop({bgFile = [[Interface\Tooltips\UI-Tooltip-Background]], tileSize = 64, tile = true})
                    anchorFrame:SetBackdropColor(1, 0, 0, 0.5)
                    anchorFrame:SetFrameStrata("TOOLTIP")
                    anchorFrame:SetFrameLevel(10)
                    anchorFrame.anchorIndex = i
                    anchorFrame:Hide()

                    anchorFrame:SetScript("OnClick", editorFrame.AnchorFrames.SelectAnchorPoint)

                    self.anchorFrames[i] = anchorFrame

                    anchorFrame.Texture = anchorFrame:CreateTexture("$parentTexture", "border")
                    anchorFrame.Texture:SetColorTexture(1, 1, 1, 0.5)
                    anchorFrame.Texture:SetAllPoints(anchorFrame)
                end
            end,
        }

        editorFrame.AnchorFrames:CreateNineAnchors()
        return editorFrame.AnchorFrames
    end,

    ---@class moverobject : table
    ---@field lastMoveInfo table
    ---@field MoverFrame df_editor_mover
    ---@field Setup fun(self:moverobject, object:uiobject, registeredObject:df_editor_objectinfo, onTickWhileMoving:function, onTickNotMoving:function)
    ---@field Hide fun(self:moverobject)
    ---@field Stop fun(self:moverobject)
    ---@field UpdatePosition fun(self:moverobject, moverFrame:df_editor_mover)

    ---@class df_editor_mover : frame
    ---@field MovingInfo df_editor_mover_movinginfo
    ---@field MoverIcon texture
    ---@field OnTickWhileMoving fun(self:df_editor_mover, deltaTime:number)
    ---@field OnTickNotMoving fun(self:df_editor_mover, deltaTime:number)

    ---create a frame to move the object, the frame is attached into the bottom right of the selected object
    ---@param editorFrame df_editor
    ---@return moverobject
    CreateMoverFrame = function(editorFrame)
        --frame that is used to move the object
        ---@type moverobject
        ---@diagnostic disable-next-line: missing-fields
        local moverObject = {
            Setup = function(self, object, registeredObject, onTickWhileMoving, onTickNotMoving)
                local moverFrame = self.MoverFrame
                moverFrame:Show()
                moverFrame:EnableMouse(true)
                moverFrame.OnTickWhileMoving = onTickWhileMoving
                moverFrame.OnTickNotMoving = onTickNotMoving

                moverFrame:SetScript("OnMouseDown", function()
                    --save the current position of the object
                    local startX, startY = moverFrame:GetCenter()
                    moverFrame.MovingInfo.startX = startX
                    moverFrame.MovingInfo.startY = startY

                    --snapshot the anchor offsets at drag start so OnMouseUp can push an
                    --accurate undo entry. captured here (not at StartObjectMovement time)
                    --so that anchor changes between drags don't pollute the snapshot.
                    local moveInfo = self.lastMoveInfo
                    if (moveInfo and moveInfo.anchorSettings) then
                        moveInfo.preMoveX = moveInfo.anchorSettings.x
                        moveInfo.preMoveY = moveInfo.anchorSettings.y
                    end

                    --start moving
                    moverFrame:SetScript("OnUpdate", onTickWhileMoving)
                    moverFrame:StartMoving()
                end)

                moverFrame:SetScript("OnMouseUp", function()
                    self:Stop()
                    moverFrame:EnableMouse(true)

                    --save the current position of the object selected
                    local x, y = object:GetCenter()
                    moverFrame.MovingInfo.restingX = x
                    moverFrame.MovingInfo.restingY = y
                    moverFrame:SetScript("OnUpdate", onTickNotMoving)

                    --push undo entry for the drag, but only if the offsets actually changed.
                    --skips entries for click-without-drag.
                    local moveInfo = self.lastMoveInfo
                    if (moveInfo and moveInfo.anchorSettings and moveInfo.preMoveX ~= nil) then
                        local anchor = moveInfo.anchorSettings
                        local oldX, oldY = moveInfo.preMoveX, moveInfo.preMoveY
                        local newX, newY = anchor.x, anchor.y
                        if (oldX ~= newX or oldY ~= newY) then
                            local edited = editorFrame:GetEditingRegisteredObject()
                            if (edited) then
                                editorFrame:AddMoverUndoState(edited, anchor, oldX, oldY, newX, newY)
                            end
                        end
                    end
                end)

                --anchor the mover so it covers the entire widget (topleft + bottomright pair
                --overrides SetSize). this lets the user click anywhere on the widget to drag it,
                --matching the purple ObjectBackgroundTexture that visually frames the same area.
                moverFrame:ClearAllPoints()
                moverFrame:SetPoint("topleft", object, "topleft", 0, 0)
                moverFrame:SetPoint("bottomright", object, "bottomright", 0, 0)

                local x, y = object:GetCenter()
                moverFrame.MovingInfo.restingX = x
                moverFrame.MovingInfo.restingY = y
                moverFrame:SetScript("OnUpdate", onTickNotMoving)
            end,

            Hide = function(self)
                self.MoverFrame:Hide()
            end,

            Stop = function(self)
                local moverFrame = self.MoverFrame
                moverFrame:StopMovingOrSizing()
                moverFrame:SetScript("OnUpdate", nil)
            end,

            UpdatePosition = function(self, moverFrame)
                local thisMoverFrame = self.MoverFrame
                if (thisMoverFrame ~= moverFrame) then --what?
                    thisMoverFrame.OnTickNotMoving(thisMoverFrame, 0)
                end
            end,
        }

        ---@type df_editor_mover
        local moverFrame = CreateFrame("button", editorFrame:GetName() .. "MoverFrame", UIParent, "BackdropTemplate")
        moverObject.MoverFrame = moverFrame
        moverFrame:SetFrameStrata("TOOLTIP")
        moverFrame:SetSize(16, 16)
        moverFrame:SetClampedToScreen(true)
        moverFrame:EnableMouse(true)
        moverFrame:SetMovable(true)
        moverFrame:SetFrameLevel(4)
        moverFrame.MovingInfo = {
            startX = 0,
            startY = 0,
            restingX = 0,
            restingY = 0,
        }
        --create the mover texture background
        moverFrame:SetBackdrop({bgFile = [[Interface\Tooltips\UI-Tooltip-Background]], tileSize = 64, tile = true})
        moverFrame:SetBackdropColor(1, 0, 0, 0.2)

        --white dot in the middle of the mover frame
        moverFrame.MoverIcon = moverFrame:CreateTexture("$parentMoverIcon", "overlay")
        moverFrame.MoverIcon:SetTexture([[Interface\CHATFRAME\CHATFRAMEBACKGROUND]])
        moverFrame.MoverIcon:SetSize(6, 6) --default: 6
        moverFrame.MoverIcon:SetPoint("center", moverFrame, "center", 0, 0)

        return moverObject
    end,

    EditObjectById = function(self, id)
        ---@type df_editor_objectinfo
        local objectRegistered = self:GetObjectById(id)
        assert(type(objectRegistered) == "table", "EditObjectById() object not found.")
        self:EditObject(objectRegistered)
    end,

    EditObjectByIndex = function(self, index)
        ---@type df_editor_objectinfo
        local objectRegistered = self:GetObjectByIndex(index)
        assert(type(objectRegistered) == "table", "EditObjectById() object not found.")
        self:EditObject(objectRegistered)
    end,

    ---@param self df_editor
    ---@param registeredObject df_editor_objectinfo
    ---@param activeMember uiobject? specific member widget to focus brackets/mover on; defaults to last-clicked member or members[1]
    EditObject = function(self, registeredObject, activeMember)
        --clear previous values
        self.editingObject = nil
        self.editingProfileMap = nil
        self.editingProfileTable = nil
        self.editingOptions = nil
        self.editingExtraOptions = nil
        self.onEditCallback = nil

        --resolve which member becomes the visual focus (brackets, mover, ObjectBackgroundTexture).
        --priority: explicit activeMember arg > registration's last-clicked memory > members[1].
        --activeMemberIndex is remembered on the registration so subsequent EditObject(reg) calls
        --(left-list clicks, OnShow restore, Undo replay) preserve the user's last in-place click.
        local members = registeredObject.objects
        local active = activeMember
        if (not active) then
            local lastIndex = registeredObject.activeMemberIndex
            active = (lastIndex and members[lastIndex]) or members[1]
        end
        if (active) then
            for i = 1, #members do
                if (members[i] == active) then
                    registeredObject.activeMemberIndex = i
                    break
                end
            end
        end

        local object = active or registeredObject.object
        local profileKeyMap = registeredObject.profilekeymap
        local extraOptions = registeredObject.extraoptions
        local callback = registeredObject.callback
        local options = registeredObject.options

        local profileTable = self:GetProfileTableFromObject(registeredObject)
        assert(type(profileTable) == "table", "EditObject() profileTable is invalid.")

        --as there's no other place which this members are set, there is no need to create setter functions
        self.registeredObjectInfo = registeredObject
        self.editingObject = object
        self.editingProfileMap = profileKeyMap
        self.editingProfileTable = profileTable
        self.editingOptions = options
        self.editingExtraOptions = extraOptions

        if (type(callback) == "function") then
            self.onEditCallback = callback
        end

        self:PrepareObjectForEditing()

        --refresh the left-panel selector so its highlight tracks the active object.
        --guarded because EditObject can technically be reachable before the selector is built.
        if (self.objectSelector) then
            self.objectSelector:RefreshMe()
        end

        if self.options.start_editing_callback then
            xpcall(self.options.start_editing_callback, geterrorhandler(), self, registeredObject)
        end
    end,

    ---tear down editor UI and clear all editing state. used when the active object is unregistered.
    ---@param self df_editor
    ClearEditing = function(self)
        self:StopObjectMovement()
        self:GetMoverObject():Hide()
        self.AnchorFrames:DisableAllAnchors()
        self:HideSelectedTextures()

        self.editingObject = nil
        self.editingProfileMap = nil
        self.editingProfileTable = nil
        self.editingOptions = nil
        self.editingExtraOptions = nil
        self.onEditCallback = nil
        self.registeredObjectInfo = nil
    end,

    ---initialize the empty undo / redo stacks. called once during editor construction.
    ---@param self df_editor
    CreateUndoManager = function(self)
        self.undoHistory = {}
        self.redoHistory = {}
        self:RefreshUndoButtons()
    end,

    ---enable / disable the undo / redo buttons based on stack contents. safe to call
    ---when the buttons don't exist (consumer turned them off via show_undo_buttons).
    ---@param self df_editor
    RefreshUndoButtons = function(self)
        if (self.UndoButton) then
            if (self.undoHistory and #self.undoHistory > 0) then
                self.UndoButton:Enable()
            else
                self.UndoButton:Disable()
            end
        end
        if (self.RedoButton) then
            if (self.redoHistory and #self.redoHistory > 0) then
                self.RedoButton:Enable()
            else
                self.RedoButton:Disable()
            end
        end
    end,

    ---push an undo state. coalesces with the previous state if its coalesceKey matches
    ---(continuous slider drags, color picker, etc. produce many set() calls but should
    ---collapse into a single undo entry from drag-start to drag-end). any new edit
    ---invalidates the redo stack.
    ---@param self df_editor
    ---@param state undostate
    AddToUndoHistory = function(self, state)
        local last = self.undoHistory[#self.undoHistory]
        if (last and last.coalesceKey and last.coalesceKey == state.coalesceKey) then
            --keep last.undo (the original "before" snapshot) but advance the redo target.
            last.redo = state.redo
        else
            self.undoHistory[#self.undoHistory + 1] = state
            if (#self.undoHistory > MAX_UNDO_STACK) then
                table.remove(self.undoHistory, 1)
            end
        end
        if (#self.redoHistory > 0) then
            wipe(self.redoHistory)
        end
        self:RefreshUndoButtons()
    end,

    ---convenience wrapper used by the mover. anchorTable is the live profile sub-table
    ---(e.g. profile.anchor); the undo / redo closures mutate its x and y in place and
    ---fire the consumer's edit callback so listeners (e.g. Plater) react.
    ---@param self df_editor
    ---@param registeredObject df_editor_objectinfo
    ---@param anchorTable table
    ---@param oldX number
    ---@param oldY number
    ---@param newX number
    ---@param newY number
    AddMoverUndoState = function(self, registeredObject, anchorTable, oldX, oldY, newX, newY)
        local profileTable, profileMap = self:GetEditingProfile()
        local profileKey = profileMap and profileMap.anchor
        --capture the members array at undo-creation time. for multi-member registrations the
        --replay must reposition every member, not just the primary, so all visual sibling
        --widgets stay in lockstep with the anchor data.
        local members = registeredObject.objects or {registeredObject.object}
        local callbackObject = members[1]
        local onEditCallback = self:GetOnEditCallback()

        local applyXY = function(x, y)
            anchorTable.x = x
            anchorTable.y = y
            --reposition every member; without this the data is restored but the widgets
            --visually stay where the drag left them. matches what option.setter does for the
            --slider-driven anchor undo path (which also fans out across members).
            for i = 1, #members do
                local member = members[i]
                detailsFramework:SetAnchor(member, anchorTable, member:GetParent())
            end
            if (onEditCallback) then
                onEditCallback(callbackObject, "x", x, profileTable, profileKey)
                onEditCallback(callbackObject, "y", y, profileTable, profileKey)
            end
        end

        self:AddToUndoHistory({
            objectId = registeredObject.id,
            coalesceKey = tostring(registeredObject.id) .. ":mover",
            undo = function() applyXY(oldX, oldY) end,
            redo = function() applyXY(newX, newY) end,
        })
    end,

    ---pop the most recent undo entry, restore the previous state, push it onto the redo
    ---stack, and re-render the affected object's menu so widgets reflect the restored value.
    ---no-op if the stack is empty.
    ---@param self df_editor
    Undo = function(self)
        local state = table.remove(self.undoHistory)
        if (not state) then
            self:RefreshUndoButtons()
            return
        end
        xpcall(state.undo, geterrorhandler())
        self.redoHistory[#self.redoHistory + 1] = state

        local registered = self:GetObjectById(state.objectId)
        if (registered) then
            self:EditObject(registered)
        end
        self:RefreshUndoButtons()
    end,

    ---inverse of Undo: pop from redo, re-apply, push back onto undo, re-render.
    ---@param self df_editor
    Redo = function(self)
        local state = table.remove(self.redoHistory)
        if (not state) then
            self:RefreshUndoButtons()
            return
        end
        xpcall(state.redo, geterrorhandler())
        self.undoHistory[#self.undoHistory + 1] = state

        local registered = self:GetObjectById(state.objectId)
        if (registered) then
            self:EditObject(registered)
        end
        self:RefreshUndoButtons()
    end,

    PrepareObjectForEditing = function(self) --~edit
        --get the object and its profile table with the current values
        local object = self:GetEditingObject()
        local profileTable, profileMap = self:GetEditingProfile()
        profileMap = profileMap or {}

        --capture the registration + callback at menu-build time so undo/redo replay always
        --acts on the right widgets. resolving these live inside applyValue via
        --self:GetEditingRegisteredObject() / self:GetOnEditCallback() breaks during
        --undo/redo: Undo/Redo run state.undo/.redo BEFORE calling EditObject(targetObject),
        --so the live editing context is whatever the previous replay step left active -
        --which fans the wrong registration's setter (e.g. FontString:SetFont) onto another
        --registration's members (e.g. a Texture), causing "attempt to call a nil value".
        local registeredForClosure = self:GetEditingRegisteredObject()
        local onEditCallbackForClosure = self:GetOnEditCallback()
        local fanoutMembers = registeredForClosure and registeredForClosure.objects or {object}

        self.AnchorFrames:DisableAllAnchors()

        local conditionalKeys = profileMap.enable_if or {}

        if (not object or not profileTable) then
            return
        end

        --reparent the background texture into the edited object's draw stack so it renders behind the
        --object's own artwork. without this, the texture sits in editorFrame's stack and any object whose
        --frame strata/level is below editorFrame ends up with the magenta tint painted over it instead of under.
        --Textures:SetParent requires a Frame; FontString/Texture object types aren't valid parents, so when
        --the edited object isn't a Frame we fall back to its parent (always a Frame for any region) and rely
        --on the BACKGROUND draw layer to keep the tint below the widget's own content in the shared stack.
        local backgroundParent = object
        if (object:GetObjectType() ~= "Frame") then
            backgroundParent = object:GetParent()
        end
        self.ObjectBackgroundTexture:SetParent(backgroundParent)
        self.ObjectBackgroundTexture:SetDrawLayer("BACKGROUND")
        self.ObjectBackgroundTexture:SetPoint("topleft", object, "topleft", -2, 2)
        self.ObjectBackgroundTexture:SetPoint("bottomright", object, "bottomright", 2, -2) --using points instead of size due to width height being secret values in some objects

        --get the object type
        local objectType = object:GetObjectType()
        local attributeList

        --options for the editor, e.g. text_template, use_colon, etc.
        local editingOptions = self:GetEditingOptions()

        --extra options for this object
        local extraOptions = self:GetExtraOptions()

        --get the attribute list for the object type
        if (objectType == "FontString") then
            ---@cast object fontstring
            attributeList = attributes[objectType]

        elseif (objectType == "Texture") then
            ---@cast object texture
            attributeList = attributes[objectType]

        elseif (objectType == "Frame" or objectType == "Button" or objectType == "StatusBar") then
            ---@cast object frame
            attributeList = attributes["Frame"]

        else
            --unrecognized type (e.g. EditBox, ScrollFrame, CheckButton, Cooldown, Slider). fall back to
            --empty so the loop doesn't crash on #attributeList; the consumer's extraOptions still
            --build into the menu. silent on purpose - this is an expected/valid registration
            --pattern (custom widget driven entirely by extraOptions), not an error worth chat-spamming.
            attributeList = {}
        end

        --if there's extra options, add the attributeList to a new table and right after the extra options
        if (extraOptions and #extraOptions > 0) then
            local attributeListWithExtraOptions = {}

            --only add the blank space if there's attributes before the extra options
            if (#attributeList > 0) then --#editingOptions is always zero
                for i = 1, #attributeList do
                    attributeListWithExtraOptions[#attributeListWithExtraOptions+1] = attributeList[i]
                end
                attributeListWithExtraOptions[#attributeListWithExtraOptions+1] = {widget = "blank", default = true}
            end

            for i = 1, #extraOptions do
                attributeListWithExtraOptions[#attributeListWithExtraOptions+1] = extraOptions[i]
            end

            attributeList = attributeListWithExtraOptions
        end

        local anchorSettings

        --table to use on DF:BuildMenuVolatile()
        local menuOptions = {}
        for i = 1, #attributeList do
            local option = attributeList[i]

            local widgetType = option.widget or option.type

            if (widgetType == "blank") then
                menuOptions[#menuOptions+1] = {type = "blank"}
            elseif (widgetType == "label") then
                menuOptions[#menuOptions+1] = {
                    type = "label",
                    text = option.text,
                    name = option.name,
                    get = option.get,
                    text_template = option.text_template,
                    color = option.color,
                    namePhraseId = option.namePhraseId,
                }
            elseif (widgetType == "execute" or widgetType == "button") then
                --action buttons in extras don't read or write profile state, so they skip the
                --value-resolution pipeline below (no get/set, no path lookup, no undo capture).
                --forward the BuildMenu execute-widget fields straight through; the consumer's
                --`func` is invoked on click.
                menuOptions[#menuOptions+1] = {
                    type = widgetType,
                    name = option.label,
                    func = option.func,
                    param1 = option.param1,
                    param2 = option.param2,
                    icontexture = option.icontexture,
                    icontexcoords = option.icontexcoords,
                    text_template = option.text_template,
                    width = option.width,
                    height = option.height,
                    id = option.key,
                }
            else
                --extras may override the registration's profileTable so a single option can read/write
                --against a different scope - e.g. a global/root-level setting exposed on a registration
                --that's otherwise bound to a sub-table via subTablePath.
                local entryProfileTable = option.profileTable or profileTable
                --get the key to be used on profile table
                local profileKey = profileMap[option.key] or option.key
                if profileKey then
                    local value
                    --if the key contains a dot or a bracket, it means it's a table path, example: "text_settings[1].width"
                    if (profileKey and (profileKey:match("%.") or profileKey:match("%["))) then --profileKey is a number
                        value = detailsFramework.table.getfrompath(entryProfileTable, profileKey)
                    else
                        value = entryProfileTable[profileKey]
                    end

                    local bHasValue = type(value) ~= "nil"

                    local minValue = option.minvalue
                    local maxValue = option.maxvalue

                    if (option.key == "anchoroffsetx") then
                        minValue = -math.floor(object:GetParent():GetWidth())
                        maxValue = math.floor(object:GetParent():GetWidth())
                    elseif (option.key == "anchoroffsety") then
                        minValue = -math.floor(object:GetParent():GetHeight())
                        maxValue = math.floor(object:GetParent():GetHeight())
                    end

                    if (bHasValue) then
                        local parentTable = getParentTable(entryProfileTable, profileKey)

                        assert(detailsFramework:IsValidWidgetForBuildMenu(option.widget), "Invalid widget type for option with key and name: " .. option.key .. ", " .. option.label)

                        if (option.key == "anchor" or option.key == "anchoroffsetx" or option.key == "anchoroffsety") then
                            anchorSettings = parentTable
                        end

                        --shared apply function used both by the live set() callback and by undo/redo
                        --replay. captures per-option upvalues so each undo entry just calls applyValue
                        --with a snapshot value rather than re-deriving the path/setter/callback chain.
                        local applyValue = function(value)
                            if (widgetType == "color") then
                                --mutate the live profile color table in place to preserve references
                                parentTable[1] = value[1]
                                parentTable[2] = value[2]
                                parentTable[3] = value[3]
                                parentTable[4] = value[4]
                                value = parentTable
                            else
                                detailsFramework.table.setfrompath(entryProfileTable, profileKey, value)
                            end

                            if (onEditCallbackForClosure) then
                                onEditCallbackForClosure(object, option.key, value, entryProfileTable, profileKey)
                            end

                            --update the widget visual
                            --anchoring uses SetAnchor() which requires the anchorTable to be passed.
                            --for multi-member registrations the setter fans out across every member so
                            --a single edit updates all of them in lockstep (e.g. 4 textures sharing one
                            --logical anchor all reposition together). consumer setters should be idempotent
                            --since this loop calls them N times per edit; non-idempotent side effects
                            --(network, telemetry) belong in the per-registration onEditCallback above.
                            --uses fanoutMembers / onEditCallbackForClosure captured at menu-build time
                            --rather than self:GetEditingRegisteredObject() / self:GetOnEditCallback() so
                            --undo/redo replay still targets this closure's registration (see prologue).

                            if (option.key == "anchor" or option.key == "anchoroffsetx" or option.key == "anchoroffsety") then
                                anchorSettings = parentTable

                                if (option.key == "anchor") then
                                    anchorSettings.x = 0
                                    anchorSettings.y = 0
                                end

                                self:StopObjectMovement()

                                if (option.setter) then
                                    for i = 1, #fanoutMembers do
                                        option.setter(fanoutMembers[i], parentTable)
                                    end
                                end

                                if (editingOptions.can_move) then
                                    self:StartObjectMovement(anchorSettings)
                                else
                                    --hide mover frame
                                    self.moverObject:Hide()
                                end
                            else
                                if (option.setter) then
                                    for i = 1, #fanoutMembers do
                                        option.setter(fanoutMembers[i], value)
                                    end
                                end
                            end
                        end

                        local optionTable = {
                            type = widgetType,
                            name = option.label,
                            get = function() return value end, --need to get the value directly from the profile table
                            set = function(widget, fixedValue, newValue, ...)
                                --normalize numeric inputs from sliders
                                if (widgetType == "range" or widgetType == "slider") then
                                    if (not option.usedecimals) then
                                        newValue = math.floor(newValue)
                                    end
                                end

                                --snapshot the OLD profile state before mutation, for undo replay
                                local oldSnapshot
                                if (widgetType == "color") then
                                    oldSnapshot = {parentTable[1], parentTable[2], parentTable[3], parentTable[4]}
                                else
                                    oldSnapshot = detailsFramework.table.getfrompath(entryProfileTable, profileKey)
                                end

                                --pack color callback args (r, g, b, alpha) into a single table
                                local payload
                                if (widgetType == "color") then
                                    payload = {fixedValue, newValue, ...}
                                else
                                    payload = newValue
                                end

                                applyValue(payload)

                                --capture the post-mutation NEW state for redo replay
                                local newSnapshot
                                if (widgetType == "color") then
                                    newSnapshot = {parentTable[1], parentTable[2], parentTable[3], parentTable[4]}
                                else
                                    newSnapshot = detailsFramework.table.getfrompath(entryProfileTable, profileKey)
                                end

                                --push undo entry. skip if the value didn't actually change - this
                                --filters out the no-op set() calls BuildMenuVolatile fires during
                                --widget construction, which would otherwise wipe redoHistory and
                                --break the second undo of a multi-step undo/redo cycle.
                                --AddToUndoHistory coalesces consecutive real edits to the same widget
                                --into one entry, so a slider drag becomes a single undo step.
                                if (not snapshotsEqual(oldSnapshot, newSnapshot)) then
                                    local registered = self:GetEditingRegisteredObject()
                                    if (registered) then
                                        self:AddToUndoHistory({
                                            objectId = registered.id,
                                            coalesceKey = tostring(registered.id) .. ":" .. tostring(option.key),
                                            undo = function() applyValue(oldSnapshot) end,
                                            redo = function() applyValue(newSnapshot) end,
                                        })
                                    end
                                end
                            end,
                            min = minValue,
                            max = maxValue,
                            step = option.step,
                            usedecimals = option.usedecimals,
                            id = option.key,
                        }

                        --forwarded for the generic `dropdown` widget; BuildMenu expects `values`
                        --as a function that returns the list of {value, label, ...} options. each
                        --option needs its own onclick because the generic select dispatches the
                        --callback per-option (not via widgetTable.set), so we inject the editor's
                        --universal `set` as onclick on any option that doesn't already define one -
                        --without this the user's selection updates the dropdown face but never reaches
                        --setfrompath/profile, and reload reverts the change.
                        if (option.dropdownFunc) then
                            optionTable.values = function(dropdownObject)
                                local opts = option.dropdownFunc(dropdownObject)
                                for optIndex = 1, #opts do
                                    if (opts[optIndex].onclick == nil) then
                                        opts[optIndex].onclick = optionTable.set
                                    end
                                end
                                return opts
                            end
                        end

                        if (conditionalKeys[option.key]) then
                            local bIsEnabled = conditionalKeys[option.key](object, entryProfileTable, profileKey)
                            if (not bIsEnabled) then
                                optionTable.disabled = true
                            end
                        end

                        menuOptions[#menuOptions+1] = optionTable
                    end
                end
            end
        end

        if (anchorSettings and not self.options.no_anchor_points) then
            self.AnchorFrames:SetupAnchorsForObject(anchorSettings)
        end

        --at this point, the optionsTable is ready to be used on DF:BuildMenuVolatile()
        menuOptions.align_as_pairs = true
        menuOptions.align_as_pairs_length = 150
        menuOptions.widget_width = 180
        menuOptions.slider_buttons_to_left = true

        local optionsFrame = self:GetOptionsFrame()
        local canvasScrollBox = self:GetCanvasScrollBox()

        local bUseColon = editingOptions.use_colon

        local bSwitchIsCheckbox = true
        local maxHeight = 5000

        local amountOfOptions = #menuOptions
        local optionsFrameHeight = amountOfOptions * 20
        optionsFrame:SetHeight(optionsFrameHeight)

        --templates
        local options_dropdown_template = self.options.dropdown_template
        local options_switch_template = self.options.switch_template
        local options_button_template = self.options.button_template
        local options_slider_template = self.options.slider_template
        local options_text_template = self.options.text_template

        --remove any blank spaces at the start of the menu
        while (true) do
            local option = menuOptions[1]
            if (option and option.type == "blank") then
                table.remove(menuOptions, 1)
            else
                break
            end
        end

        --search for two blanks in a row and remove one of them
        for i = #menuOptions, 2, -1 do
            local option = menuOptions[i]
            local previousOption = menuOptions[i-1]
            if (option and previousOption and option.type == "blank" and previousOption.type == "blank") then
                table.remove(menuOptions, i)
            end
        end

        --~build ~menu ~volatile
        menuOptions.no_refresh_on_change = true --the editor .get functions just return a value instead of getting the value from the profile
        detailsFramework:BuildMenuVolatile(optionsFrame, menuOptions, 2, -2, maxHeight, bUseColon, options_text_template, options_dropdown_template, options_switch_template, bSwitchIsCheckbox, options_slider_template, options_button_template)

        if (editingOptions.can_move) then
            self:StartObjectMovement(anchorSettings)
        else
            --hide mover frame
            self.moverObject:Hide()
        end

        self:ShowSelectedTextures(object)
    end,

    ---@param self df_editor
    ---@param anchorSettings df_anchor
    StartObjectMovement = function(self, anchorSettings)
        local registeredObject = self:GetEditingRegisteredObject()
        local object = self:GetEditingObject()

        assert(anchorSettings, "Object \"" .. registeredObject.id .. "\" without anchorSettings and can_move is 'True'.\nIf this came as surprise, add can_move = false into the table of the 10th argument of editor:RegisterObject().")

        local optionsFrame = self:GetOptionsFrame()
        local moverObject = self:GetMoverObject()
        --the mover frame set its point to the object being moved
        --the move frame center dot keep under the mouse, but the object moves much faster

        moverObject.lastMoveInfo = {
            anchorSettings = anchorSettings,
            keyX = "x",
            keyY = "y",
            defaultX = anchorSettings.x,
            defaultY = anchorSettings.y,
        }

        local onTickWhileMoving = function(moverFrame, deltaTime)
            local startX, startY = moverFrame:GetCenter()
            local xOffset = startX - moverFrame.MovingInfo.startX
            local yOffset = startY - moverFrame.MovingInfo.startY

            local uiScale = UIParent:GetEffectiveScale()
            local objectScale = object:GetEffectiveScale()
            local scaleScaled = uiScale / objectScale

            xOffset = xOffset * scaleScaled
            yOffset = yOffset * scaleScaled

            if (xOffset ~= 0 or yOffset ~= 0) then
                assert(anchorSettings, "StartObjectMovement() anchorSettings is nil for object: " .. registeredObject.id)
                moverFrame.MovingInfo.startX = startX
                moverFrame.MovingInfo.startY = startY

                anchorSettings.x = anchorSettings.x + xOffset
                anchorSettings.y = anchorSettings.y + yOffset

                --reposition every member of the registration. brackets/mover follow only the active
                --member (which is `object` here), but the sibling members must keep visual lockstep
                --with the shared anchor data. each member uses its own parent for SetAnchor in case
                --members are sibling widgets under different parents.
                local members = registeredObject.objects or {object}
                for i = 1, #members do
                    local member = members[i]
                    detailsFramework:SetAnchor(member, anchorSettings, member:GetParent())
                end

                --update the slider offset in the options frame
                local anchorXSlider = optionsFrame:GetWidgetById("anchoroffsetx")
                anchorXSlider:SetValueNoCallback(anchorSettings.x)
                local anchorYSlider = optionsFrame:GetWidgetById("anchoroffsety")
                anchorYSlider:SetValueNoCallback(anchorSettings.y)

                --anchorSettings IS the profile sub-table (assigned from getParentTable() in
                --PrepareObjectForEditing for the anchor option). lines 1440-1441 above already
                --mutated its .x and .y in place, so the data is persisted by the time we get
                --here. fire the consumer callback so listeners react.
                local profileTable, profileMap = self:GetEditingProfile()
                local profileKey = profileMap.anchor

                if (self:GetOnEditCallback()) then
                    self:GetOnEditCallback()(object, "x", anchorSettings.x, profileTable, profileKey)
                    self:GetOnEditCallback()(object, "y", anchorSettings.y, profileTable, profileKey)
                end

                moverObject:UpdatePosition(moverFrame)
            end
        end

        local onTickNotMoving = function(moverFrame, deltaTime)
            --current position of the selected object
            local objectX, objectY = object:GetCenter()
            --did the object move?
            if (objectX ~= moverFrame.MovingInfo.restingX or objectY ~= moverFrame.MovingInfo.restingY) then
                --re-anchor with the same dual-anchor pair used in Setup so the mover keeps
                --covering the whole widget after StartMoving/StopMovingOrSizing replaced its anchors.
                moverFrame:ClearAllPoints()
                moverFrame:SetPoint("topleft", object, "topleft", 0, 0)
                moverFrame:SetPoint("bottomright", object, "bottomright", 0, 0)
                moverFrame.MovingInfo.restingX = objectX
                moverFrame.MovingInfo.restingY = objectY
            end
        end

        moverObject:Setup(object, registeredObject, onTickWhileMoving, onTickNotMoving)
    end,

    ---@param self df_editor
    StopObjectMovement = function(self)
        local moverFrame = self:GetMoverObject()
        moverFrame:Stop()
    end,

    ---@param self df_editor
    ---@param object df_editor_objectinfo
    GetProfileTableFromObject = function(self, object)
        local profileTable = object.profiletable
        local subTablePath = object.subtablepath

        if (type(subTablePath) == "string" and subTablePath ~= "") then
            local subTable = detailsFramework.table.getfrompath(profileTable, subTablePath)
            assert(type(subTable) == "table", "GetProfileTableFromObject() subTablePath is invalid.")
            return subTable
        end

        return profileTable
    end,

    UpdateProfileTableOnAllRegisteredObjects = function(self, profileTable)
        assert(type(profileTable) == "table", "UpdateProfileTableOnAllRegisteredObjects() expects a table on #2 parameter.")

        local registeredObjects = self:GetAllRegisteredObjects()

        for i = 1, #registeredObjects do
            local objectRegistered = registeredObjects[i]
            objectRegistered.profiletable = profileTable
        end

        local objectSelector = self:GetObjectSelector()
        objectSelector:RefreshMe()
    end,

    UpdateProfileSubTablePath = function(self, identifier, subTablePath)
        assert(identifier, "UpdateProfileSubTablePath() expects a registered object identifier on #2 parameter.")
        assert(type(subTablePath) == "string", "UpdateProfileSubTablePath() expects a pathstring or nil on #3 parameter.")

        local objectRegistered = getRegisteredObject(self, identifier)
        assert(type(objectRegistered) == "table", "UpdateProfileSubTablePath() registered object not found.")

        objectRegistered.subtablepath = subTablePath

        return true
    end,

    ---@param self df_editor
    ---@param identifier any
    ---@param profileTable table
    ---@return boolean
    UpdateProfileTable = function(self, identifier, profileTable)
        assert(identifier, "UpdateProfileTable() expects a registered object identifier on #2 parameter.")
        assert(type(profileTable) == "table", "UpdateProfileTable() expects a table on #3 parameter.")

        local objectRegistered = getRegisteredObject(self, identifier)
        assert(type(objectRegistered) == "table", "UpdateProfileTable() registered object not found.")

        objectRegistered.profiletable = profileTable

        return true
    end,

    RegisterObject = function(self, object, localizedLabel, id, profileTable, subTablePath, profileKeyMap, extraOptions, callback, options, refFrame)
        assert(type(object) == "table", "editor:RegisterObject() expects a UIObject or array of UIObjects on #2 parameter.")
        assert(type(profileTable) == "table", "editor:RegisterObject() expects a table on #5 parameter.")
        assert(type(id) ~= "nil" and type(id) ~= "boolean", "editor:RegisterObject() expects an ID on parameter #4.")
        assert(type(callback) == "function" or callback == nil, "editor:RegisterObject() expects a function or nil as the #8 parameter.")

        --param #2 may be a single UIObject or an array of them. WoW UIObjects expose GetObjectType,
        --so the presence of that method on the value itself distinguishes the two shapes.
        local members
        if (object.GetObjectType) then
            members = {object}
        else
            assert(#object > 0, "editor:RegisterObject() expects at least one widget when an array is passed on #2.")
            local firstType = object[1] and object[1].GetObjectType and object[1]:GetObjectType()
            for i = 1, #object do
                assert(type(object[i]) == "table" and object[i].GetObjectType,
                    "editor:RegisterObject() array element " .. i .. " is not a UIObject.")
                assert(object[i]:GetObjectType() == firstType,
                    "editor:RegisterObject() all member widgets must share the same object type (element " .. i .. " differs from element 1).")
            end
            --shallow-copy so later mutation of the caller's array can't surprise us.
            members = {}
            for i = 1, #object do members[i] = object[i] end
        end

        local registeredObjects = self:GetAllRegisteredObjects()

        --duplicate check spans all members across all existing registrations - any widget
        --that's already bound (as the sole object of a single-member registration or as one
        --member of a multi-member one) cannot be registered again.
        for i = 1, #registeredObjects do
            local existing = registeredObjects[i]
            local existingMembers = existing.objects
            for j = 1, #existingMembers do
                for k = 1, #members do
                    if (existingMembers[j] == members[k]) then
                        error("editor:RegisterObject(UIObject) UIObject (param #2) already registered.")
                    end
                end
            end
        end

        --deploy the options table
        options = type(options) == "table" and options or {}
        detailsFramework.table.deploy(options, editObjectDefaultOptions)

        localizedLabel = type(localizedLabel) == "string" and localizedLabel or "invalid label"

        ---@type df_editor_objectinfo
        ---@diagnostic disable-next-line: missing-fields
        local objectRegistered = {
            object = members[1],
            objects = members,
            label = localizedLabel,
            id = id,
            profiletable = profileTable,
            subtablepath = subTablePath,
            profilekeymap = profileKeyMap,
            extraoptions = extraOptions or {},
            callback = callback,
            options = options,
            selectButtons = {},
            activeMemberIndex = 1,
            refFrame = refFrame,
        }

        registeredObjects[#registeredObjects+1] = objectRegistered
        self.registeredObjectsByID[id] = objectRegistered

        --one invisible click-to-select overlay per member widget. each overlay is parented to
        --its own member's parent and sized to that member, so any member can be clicked in the
        --live preview to select this registration. the clicked member becomes the active focus
        --for brackets/mover (passed as the 2nd arg to EditObject).
        for i = 1, #members do
            local member = members[i]
            local selectButton = CreateFrame("button", "$parentSelectButton" .. tostring(id) .. "_" .. i, member:GetParent())
            selectButton:SetAllPoints(member)

            --raise above the widget within the same parent so clicks land on selectButton, not the
            --widget itself. textures and fontstrings are regions (not frames) and have no
            --GetFrameLevel of their own, so treat their effective level as parent + 1
            --(mirroring how a child frame would sit). this is what lets a region nested inside a
            --registered frame win over the frame's own selectButton.
            ---@diagnostic disable-next-line: undefined-field
            local widgetLevel = member.GetFrameLevel and member:GetFrameLevel() or (member:GetParent():GetFrameLevel() + 1)
            selectButton:SetFrameLevel(widgetLevel + 1)

            selectButton:SetScript("OnClick", function()
                self:EditObject(objectRegistered, member)
            end)

            --suppress the click-to-select overlay when can_click is false. otherwise the
            --invisible full-size button covers the widget area and intercepts clicks meant for
            --overlapping registrations or child widgets.
            if (not options.can_click) then
                selectButton:Hide()
            end

            objectRegistered.selectButtons[i] = selectButton
        end

        --alias for backward compat with any external reader that touched .selectButton directly.
        objectRegistered.selectButton = objectRegistered.selectButtons[1]

        local objectSelector = self:GetObjectSelector()
        objectSelector:RefreshMe()

        --what to do after an object is registered?
        return objectRegistered
    end,

    UnregisterObject = function(self, object)
        local registeredObjects = self:GetAllRegisteredObjects()

        --locate the registration that owns this widget (as primary or as any member of a
        --multi-member registration). passing any member drops the whole registration -
        --registration is the unit; partial-member removal is intentionally not supported.
        local foundIndex
        local foundRegistration
        for i = 1, #registeredObjects do
            local objectRegistered = registeredObjects[i]
            local members = objectRegistered.objects or {objectRegistered.object}
            for j = 1, #members do
                if (members[j] == object) then
                    foundIndex = i
                    foundRegistration = objectRegistered
                    break
                end
            end
            if (foundIndex) then break end
        end

        if (not foundIndex) then
            return
        end

        --tear down the active edit if any member of this registration is being edited.
        --otherwise the editor would be left pointing at a removed registration's freed widgets.
        local editingObject = self:GetEditingObject()
        if (editingObject) then
            local members = foundRegistration.objects or {foundRegistration.object}
            for j = 1, #members do
                if (members[j] == editingObject) then
                    self:ClearEditing()
                    break
                end
            end
        end

        --hide every per-member overlay so the freed registration doesn't leave invisible
        --click-catchers in the live preview area.
        local selectButtons = foundRegistration.selectButtons
        if (selectButtons) then
            for j = 1, #selectButtons do
                selectButtons[j]:Hide()
                selectButtons[j]:SetScript("OnClick", nil)
            end
        elseif (foundRegistration.selectButton) then
            foundRegistration.selectButton:Hide()
            foundRegistration.selectButton:SetScript("OnClick", nil)
        end

        self.registeredObjectsByID[foundRegistration.id] = nil
        table.remove(registeredObjects, foundIndex)

        local objectSelector = self:GetObjectSelector()
        objectSelector:RefreshMe()
    end,

    ---@param self df_editor
    ---@return df_editor_objectinfo[]
    GetAllRegisteredObjects = function(self)
        return self.registeredObjects
    end,

    ---@param self df_editor
    ---@return df_editor_objectinfo?
    GetObjectByRef = function(self, object)
        local registeredObjects = self:GetAllRegisteredObjects()
        for i = 1, #registeredObjects do
            local objectRegistered = registeredObjects[i]
            --check every member; any widget in a multi-member registration's array resolves
            --back to the same registration.
            local members = objectRegistered.objects or {objectRegistered.object}
            for j = 1, #members do
                if (members[j] == object) then
                    return objectRegistered
                end
            end
        end
    end,

    GetObjectByObjectInfo = function(self, objectInfo)
        local registeredObjects = self:GetAllRegisteredObjects()
        for i = 1, #registeredObjects do
            local objectRegistered = registeredObjects[i]
            if (objectRegistered == objectInfo) then
                return objectRegistered
            end
        end
    end,

    ---@param self df_editor
    ---@return df_editor_objectinfo
    GetEditingRegisteredObject = function(self)
        return self.registeredObjectInfo
    end,

    GetObjectByIndex = function(self, index)
        local registeredObjects = self:GetAllRegisteredObjects()
        return registeredObjects[index]
    end,

    GetObjectById = function(self, id)
        return self.registeredObjectsByID[id]
    end,

    CreateObjectSelectionList = function(self, scroll_width, scroll_height, scroll_lines, scroll_line_height)
        local editorFrame = self

        local refreshFunc = function(self, data, offset, totalLines) --~refresh
            self.SelectionTexture:Hide()
            self.SelectionTexture:ClearAllPoints()
            local objectCurrentBeingEdited = editorFrame:GetEditingObject()

			for i = 1, totalLines do
				local index = i + offset
                ---@type df_editor_objectinfo
				local objectRegistered = data[index]

				if (objectRegistered) then
                    local line = self:GetLine(i)
                    line.index = index

                    local customIcon = objectRegistered.options.icon
                    if (customIcon) then
                        local isAtlas = C_Texture.GetAtlasInfo(customIcon)
                        if (isAtlas) then
                            line.Icon:SetAtlas(customIcon)

                        elseif (type(customIcon) == "table") then
                            local useAtlasSize = true
                            local resetTexCoords = true
                            detailsFramework:SetAtlas(line.Icon, customIcon, useAtlasSize, "TRILINEAR", resetTexCoords)

                        else
                            line.Icon:SetTexture(customIcon)
                            line.Icon:SetTexCoord(0, 1, 0, 1)
                            line.Icon:SetVertexColor(1, 1, 1, 1)
                        end

                    elseif (objectRegistered.object:GetObjectType() == "Texture") then
                        line.Icon:SetAtlas("AnimCreate_Icon_Texture")

                    elseif (objectRegistered.object:GetObjectType() == "FontString") then
                        line.Icon:SetAtlas("AnimCreate_Icon_Text")
                    end

                    line.Label:SetText(objectRegistered.label)

                    --multi-member registrations: highlight the row if any member is currently
                    --being edited, not just objects[1].
                    local rowMembers = objectRegistered.objects or {objectRegistered.object}
                    for memberIndex = 1, #rowMembers do
                        if (rowMembers[memberIndex] == objectCurrentBeingEdited) then
                            self.SelectionTexture:SetAllPoints(line)
                            self.SelectionTexture:Show()
                            break
                        end
                    end
                end
            end
        end

		local createLineFunc = function(self, index) -- ~createline --~line
			local line = CreateFrame("button", "$parentLine" .. index, self, "BackdropTemplate")
			line:SetPoint("topleft", self, "topleft", 1, -((index-1)*(scroll_line_height+1)) - 1)
			line:SetSize(scroll_width - 2, scroll_line_height)

			line:SetBackdrop({bgFile = [[Interface\Tooltips\UI-Tooltip-Background]], tileSize = 64, tile = true})
            if (index % 2 == 0) then
                line:SetBackdropColor(.1, .1, .1, .1)
            else
                line:SetBackdropColor(.1, .1, .1, .4)
            end

            detailsFramework:CreateHighlightTexture(line, "HighlightTexture")
    		detailsFramework:Mixin(line, detailsFramework.HeaderFunctions)

            line:SetScript("OnClick", function(self)
                local objectRegistered = editorFrame:GetObjectByIndex(self.index)
                editorFrame:EditObject(objectRegistered)
            end)

			--icon
			local objectIcon = line:CreateTexture("$parentIcon", "overlay")
			objectIcon:SetSize(scroll_line_height - 2, scroll_line_height - 2)

			--object label
			local objectLabel = line:CreateFontString("$parentLabel", "overlay", "GameFontNormal")

            objectIcon:SetPoint("left", line, "left", 2, 0)
            objectLabel:SetPoint("left", objectIcon, "right", 2, 0)

			line.Icon = objectIcon
			line.Label = objectLabel

			return line
		end

        local selectObjectScrollBox = detailsFramework:CreateScrollBox(self:GetParent(), "$parentSelectObjectScrollBox", refreshFunc, editorFrame:GetAllRegisteredObjects(), scroll_width, scroll_height, scroll_lines, scroll_line_height)
        detailsFramework:ReskinSlider(selectObjectScrollBox)

        local selectionTexture = selectObjectScrollBox:CreateTexture(nil, "overlay")
        selectionTexture:SetColorTexture(1, 1, 0, 0.2)
        selectObjectScrollBox.SelectionTexture = selectionTexture

		function selectObjectScrollBox:RefreshMe()
			selectObjectScrollBox:SetData(editorFrame:GetAllRegisteredObjects())
		    selectObjectScrollBox:Refresh()
		end

		--create lines
		for i = 1, scroll_lines do
			selectObjectScrollBox:CreateLine(createLineFunc)
		end

        return selectObjectScrollBox
    end,

    OnHide = function(self)
        self:StopObjectMovement()
        local moverObject = self:GetMoverObject()
        moverObject:Hide()
        self.AnchorFrames:DisableAllAnchors()
        self:HideSelectedTextures()
        self.overTheTopFrame:Hide()

        --stop receiving key events while hidden so Ctrl+Z / Ctrl+Y fall back to the
        --player's combat keybinds.
        self:EnableKeyboard(false)
    end,

    ---@param self df_editor
    OnShow = function(self)
        --get the editing object
        local objectIndex = self:GetEditingObjectIndex()
        if (objectIndex) then
            self:EditObjectByIndex(objectIndex)
        end
        self.overTheTopFrame:Show()

        --start receiving Ctrl+Z / Ctrl+Y while the editor is shown.
        self:EnableKeyboard(true)
        self:SetPropagateKeyboardInput(true)
    end,
}


function detailsFramework:CreateEditor(parent, name, options)
    name = name or ("DetailsFrameworkEditor" .. math.random(100000, 10000000))
    ---@type df_editor
    local editorFrame = CreateFrame("frame", name, parent, "BackdropTemplate")
    parent:SetToplevel(true)

    detailsFramework:Mixin(editorFrame, detailsFramework.EditorMixin)
    detailsFramework:Mixin(editorFrame, detailsFramework.OptionsFunctions)

    editorFrame:SetScript("OnShow", editorFrame.OnShow)
    editorFrame:SetScript("OnHide", editorFrame.OnHide)

    editorFrame.registeredObjects = {}
    editorFrame.registeredObjectsByID = {}

    editorFrame:BuildOptionsTable(editorDefaultOptions, options)

    editorFrame:SetSize(editorFrame.options.width, editorFrame.options.height)

    --The options frame holds the options for the object being edited. It is used as the parent frame for the BuildMenuVolatile() function.
    ---@type df_menu
    local optionsFrame = CreateFrame("frame", name .. "OptionsFrame", editorFrame, "BackdropTemplate")
    optionsFrame:SetSize(editorFrame.options.options_width, 5000)

    local canvasScrollBoxOptions = {
        width = editorFrame.options.options_width,
        height = 400,
        reskin_slider = true,
    }
    local canvasFrame = detailsFramework:CreateCanvasScrollBox(editorFrame, optionsFrame, name .. "CanvasScrollBox", canvasScrollBoxOptions)

    if (editorFrame.options.create_object_list) then
        local scrollWidth = editorFrame.options.object_list_width
        local scrollHeight = editorFrame.options.object_list_height
        local scrollLinesAmount = editorFrame.options.object_list_lines
        local scrollLineHeight = editorFrame.options.object_list_line_height

        local objectSelector = editorFrame:CreateObjectSelectionList(scrollWidth, scrollHeight, scrollLinesAmount, scrollLineHeight)
        objectSelector:SetPoint("topleft", editorFrame, "topleft", 0, -2)
        objectSelector:SetBackdropBorderColor(0, 0, 0, 0)
        editorFrame.objectSelector = objectSelector
        objectSelector:RefreshMe()

        local nScrollBarWidth = 30
        canvasFrame:SetPoint("topleft", objectSelector, "topright", nScrollBarWidth, 0)
        canvasFrame:SetPoint("bottomleft", objectSelector, "bottomright", -nScrollBarWidth, 0)
    else
        canvasFrame:SetPoint("topleft", editorFrame, "topleft", 2, -2)
        canvasFrame:SetPoint("bottomleft", editorFrame, "bottomleft", 2, 0)
    end

    --background below the selected object, its point is set when the movers are setup and set directly on the object being edited.
    --created on the "background" draw layer and reparented to the edited object inside PrepareObjectForEditing — cross-frame
    --z-order follows the parent's strata/level, not the texture's draw layer, so leaving the texture on editorFrame would render
    --it on top of any object whose frame sits below editorFrame.
    editorFrame.ObjectBackgroundTexture = editorFrame:CreateTexture("$parentMoverObjectBackground", "background")
    editorFrame.ObjectBackgroundTexture:SetColorTexture(1, 0, 1, 0.25)

    --over the top frame is a frame that is always on top of everything else
    local OTTFrame = CreateFrame("frame", "$parentOTTFrame", UIParent)
    OTTFrame:SetFrameStrata("TOOLTIP")
    editorFrame.overTheTopFrame = OTTFrame

    editorFrame:CreateAnchorFrames()
    editorFrame:CreateUndoManager()

    --Ctrl+Z / Ctrl+Y bindings. EnableKeyboard is toggled on/off by OnShow/OnHide so the
    --handler only fires while the editor is visible; combat keybinds work normally otherwise.
    --propagation is left enabled for non-shortcut keys so other keybinds still pass through.
    editorFrame:SetScript("OnKeyDown", function(self, key)
        if (IsControlKeyDown() and key == "Z") then
            self:SetPropagateKeyboardInput(false)
            if (IsShiftKeyDown()) then
                self:Redo()
            else
                self:Undo()
            end
        elseif (IsControlKeyDown() and key == "Y") then
            self:SetPropagateKeyboardInput(false)
            self:Redo()
        else
            self:SetPropagateKeyboardInput(true)
        end
    end)

    editorFrame.moverObject = editorFrame:CreateMoverFrame()

    editorFrame:CreateSelectedTextures()

    editorFrame.optionsFrame = optionsFrame
    editorFrame.canvasScrollBox = canvasFrame

    --undo / redo toolbar at the bottom-right of the editor frame.
    --placed below the build-menu canvas where there's empty space, so it doesn't fight the menu layout.
    if (editorFrame.options.show_undo_buttons) then
        local buttonTemplate = editorFrame.options.button_template
        --width bumped from 60 to 110 so the "(ctrl+z)" / "(ctrl+y)" hint fits next to the label.
        local buttonW, buttonH = 110, 22

        local undoButton = detailsFramework:CreateButton(editorFrame, function() editorFrame:Undo() end, buttonW, buttonH, "Undo (ctrl+z)", nil, nil, nil, nil, nil, nil, buttonTemplate)
        undoButton:SetPoint("bottomright", editorFrame, "bottomright", -(buttonW + 4), 6)
        editorFrame.UndoButton = undoButton

        local redoButton = detailsFramework:CreateButton(editorFrame, function() editorFrame:Redo() end, buttonW, buttonH, "Redo (ctrl+y)", nil, nil, nil, nil, nil, nil, buttonTemplate)
        redoButton:SetPoint("bottomright", editorFrame, "bottomright", -2, 6)
        editorFrame.RedoButton = redoButton

        --initial enable/disable state (both stacks empty at construction time)
        editorFrame:RefreshUndoButtons()
    end

    return editorFrame
end
