
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

--object types we've already warned about for "unrecognized type" so the message fires once per
--type per session, not on every EditObject() call. keyed by the GetObjectType() string.
local warnedUnrecognizedTypes = {}

--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?

---@class df_editor_objectinfo : table
---@field object uiobject
---@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
---@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)
---@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, localizedLabel:string, id:string, profileTable:table, subTablePath:string, profileKeyMap:table, extraOptions:table?, callback:function?, options:df_editobjectoptions?, refFrame:frame):df_editor_objectinfo
---@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 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]
            if (objectRegistered.object == object) then
                return i
            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,

---@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
    EditObject = function(self, registeredObject)
        --clear previous values
        self.editingObject = nil
        self.editingProfileMap = nil
        self.editingProfileTable = nil
        self.editingOptions = nil
        self.editingExtraOptions = nil
        self.onEditCallback = nil

        local object = 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
        local object = registeredObject.object
        local onEditCallback = self:GetOnEditCallback()

        local applyXY = function(x, y)
            anchorTable.x = x
            anchorTable.y = y
            --reposition the widget itself; without this the data is restored but the widget
            --visually stays where the drag left it. matches what option.setter does for the
            --slider-driven anchor undo path.
            detailsFramework:SetAnchor(object, anchorTable, object:GetParent())
            if (onEditCallback) then
                onEditCallback(object, "x", x, profileTable, profileKey)
                onEditCallback(object, "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 {}

        self.AnchorFrames:DisableAllAnchors()

        local conditionalKeys = profileMap.enable_if or {}

        if (not object or not profileTable) then
            return
        end

        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). fall back to
            --empty so the loop doesn't crash on #attributeList; the consumer's extraOptions still
            --build into the menu. warn once per type so a developer notices but the message
            --doesn't spam on every EditObject() call.
            attributeList = {}
            if (not warnedUnrecognizedTypes[objectType]) then
                warnedUnrecognizedTypes[objectType] = true
                print(("|cFFFFAA00DetailsFramework editor|r: object type %q has no built-in attributes; only extraOptions will appear in the menu."):format(tostring(objectType)))
            end
        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"}
            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 (self:GetOnEditCallback()) then
                                self:GetOnEditCallback()(object, option.key, value, entryProfileTable, profileKey)
                            end

                            --update the widget visual
                            --anchoring uses SetAnchor() which requires the anchorTable to be passed
                            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()

                                option.setter(object, parentTable)

                                if (editingOptions.can_move) then
                                    self:StartObjectMovement(anchorSettings)
                                else
                                    --hide mover frame
                                    self.moverObject:Hide()
                                end
                            else
                                option.setter(object, value)
                            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,
                        }

                        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 objectParent = object:GetParent()
        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

                detailsFramework:SetAnchor(object, anchorSettings, objectParent)

                --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 an UIObject on #2 parameter.")
        assert(object.GetObjectType, "editor:RegisterObject() expects an UIObject 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.")

        local registeredObjects = self:GetAllRegisteredObjects()

        --is object already registered?
        for i = 1, #registeredObjects do
            local objectRegistered = registeredObjects[i]
            if (objectRegistered.object == object) then
                error("editor:RegisterObject(UIObject) UIObject (param #2) already registered.")
            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"

        --invisible button overlaid on the widget so clicking the live preview selects it for
        --editing in the designer (in addition to clicking the left-panel list).
        local selectButton = CreateFrame("button", "$parentSelectButton" .. id, object:GetParent())
        selectButton:SetAllPoints(object)

        ---@type df_editor_objectinfo
        local objectRegistered = {
            object = object,
            label = localizedLabel,
            id = id,
            profiletable = profileTable,
            subtablepath = subTablePath,
            profilekeymap = profileKeyMap,
            extraoptions = extraOptions or {},
            callback = callback,
            options = options,
            selectButton = selectButton,
            refFrame = refFrame,
        }

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

        --raise above the widget within the same parent so clicks land on selectButton, not the
        --widget itself. previously used self:GetFrameLevel() + #registeredObjects which mixed
        --frame levels from two different parent trees and only worked by coincidence of strata.
        --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: the region's button ends up at parentLevel + 2 vs the parent's parentLevel + 1.
        ---@diagnostic disable-next-line: undefined-field
        local widgetLevel = object.GetFrameLevel and object:GetFrameLevel() or (object:GetParent():GetFrameLevel() + 1)
        selectButton:SetFrameLevel(widgetLevel + 1)

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

        selectButton:SetScript("OnClick", function()
            self:EditObject(objectRegistered)
        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

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

    UnregisterObject = function(self, object)
        --tear down the active edit if it's the object being unregistered.
        --otherwise the editor would be left pointing at a removed registration.
        if (self:GetEditingObject() == object) then
            self:ClearEditing()
        end

        local registeredObjects = self:GetAllRegisteredObjects()

        for i = 1, #registeredObjects do
            local objectRegistered = registeredObjects[i]
            if (objectRegistered.object == object) then
                self.registeredObjectsByID[objectRegistered.id] = nil
                table.remove(registeredObjects, i)
                break
            end
        end

        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]
            if (objectRegistered.object == object) then
                return objectRegistered
            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)

                    if (objectRegistered.object == objectCurrentBeingEdited) then
                        self.SelectionTexture:SetAllPoints(line)
                        self.SelectionTexture:Show()
                    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()
    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()
    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")

    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
    editorFrame.ObjectBackgroundTexture = editorFrame:CreateTexture("$parentMoverObjectBackground", "artwork")
    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()

    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
        local buttonW, buttonH = 60, 22

        local undoButton = detailsFramework:CreateButton(editorFrame, function() editorFrame:Undo() end, buttonW, buttonH, "Undo", 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", 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
