# editor.md — Object Editor

This document explains the object editor defined in `editor.lua`. The editor is a runtime configuration UI: you give it some live UI objects (textures, font strings, frames, status bars), tell it where in your profile each object's settings live, and the editor builds a three-panel control surface that lets the end user inspect, edit, drag, and undo changes to those objects.

The editor is consumed in production by `Plater Nameplates` (`AddOns/Plater/Plater_Designer.lua`) — that is the canonical reference for "what a real consumer looks like" and is referenced throughout this document.

---

## Mental model

Think of the editor as **three coupled panels**:

```
┌─────────────────┬──────────────────────────────┬──────────────────────────┐
│ Left            │ Center                       │ Right                    │
│ object selector │ build-menu form              │ live preview             │
│ (df_scrollbox)  │ (BuildMenuVolatile inside    │ (consumer-rendered;      │
│                 │  a canvas scrollbox)         │  editor only adds 4      │
│                 │                              │  selection corners +     │
│                 │                              │  16x16 mover handle)     │
└─────────────────┴──────────────────────────────┴──────────────────────────┘
```

**Left panel** — list of every registered object. Built once, refreshed when registrations change. Clicking a row calls `EditObject(registeredObjectInfo)` on that entry.

**Center panel** — automatically rebuilt every time a different object is selected. Each option (Size, Font, Color, Anchor X Offset, ...) becomes a `BuildMenuVolatile` widget. The mapping between "an option label visible to the user" and "the actual key in the profile table where the value lives" is bridged by the `profileKeyMap` you provide per object.

**Right panel** — the consumer's responsibility. The editor does NOT render the preview itself. It only stamps four small corner-bracket textures around the currently-edited widget (`SelectedTextures`) and overlays a 16×16 mover handle on it. The Plater designer constructs an entire fake nameplate as preview; another consumer might just point at live in-world frames.

**The split that matters most**: the data (`profile.actorname_text_size = 11`) lives in the consumer's profile table. The UI rendering (the slider that shows `11` and writes new values back) is generated by the editor from a tiny declarative description (the `attributes` table inside `editor.lua` plus consumer-supplied `extraOptions`). The mapping between them is the `profileKeyMap`.

---

## Library access

The editor is exposed as a method on the framework table:

```lua
local DF = _G["DetailsFramework"] -- or LibStub("DetailsFramework-1.0")
local editorFrame = DF:CreateEditor(parentFrame, name, options)
```

`CreateEditor` returns a `df_editor` — a `Frame` with the `EditorMixin` and `OptionsFunctions` mixed in.

---

## CreateEditor signature

```lua
---@param parent frame
---@param name string?
---@param options df_editor_defaultoptions?
---@return df_editor
function detailsFramework:CreateEditor(parent, name, options)
```

`options` is merged onto `editorDefaultOptions`. Recognized keys:

| Key | Type | Default | Purpose |
|---|---|---|---|
| `width` | number | `400` | Editor frame width. |
| `height` | number | `548` | Editor frame height. |
| `options_width` | number | `340` | Width of the center panel (the build-menu canvas). |
| `create_object_list` | boolean | `true` | If false, the left panel is omitted; the editor becomes a single-pane form. |
| `object_list_width` | number | `200` | Left-panel width. |
| `object_list_height` | number | `420` | Left-panel height. |
| `object_list_lines` | number | `20` | Visible rows in the left-panel scroll. |
| `object_list_line_height` | number | `20` | Pixel height of each row. |
| `dropdown_template` | template | OPTIONS_DROPDOWN_TEMPLATE | Visual style for dropdowns in the build-menu. |
| `switch_template` | template | OPTIONS_CHECKBOX_TEMPLATE | Visual style for toggles. |
| `button_template` | template | OPTIONS_BUTTON_TEMPLATE | Visual style for buttons (incl. the Undo/Redo toolbar buttons). |
| `slider_template` | template | OPTIONS_SLIDER_TEMPLATE | Visual style for sliders. |
| `text_template` | template | OPTIONS_FONT_TEMPLATE | Visual style for label text in the build-menu. |
| `no_anchor_points` | boolean | `false` | If true, suppresses the anchor-point selection UI for the anchor option. |
| `start_editing_callback` | function? | nil | `function(editorFrame, registeredObjectInfo)` called at the end of every `EditObject`. |
| `selection_texture` | string | `"GM_BehaviorMessage_CornerTopLeft_Frame"` | Atlas / texture used for the four selection corners. |
| `selection_size` | number | `8` | Pixel size of each selection corner. |
| `show_undo_buttons` | boolean | `true` | If true, places an Undo / Redo button pair at the bottom-right of the editor. Set to false if you want to wire your own keybinds (`editor:Undo()` / `editor:Redo()`) instead. |

### Minimal example

```lua
local editorFrame = DF:CreateEditor(parentPanel, "MyAddon_Editor")
editorFrame:SetPoint("topleft", parentPanel, "topleft", 5, -5)
```

### Plater-style example (richer)

```lua
local editorOptions = {
    width  = 1000,
    height = 600,
    options_width = 360,
    object_list_lines = 24,
    show_undo_buttons = true,
    start_editing_callback = function(editor, registered)
        --e.g. swap the right-panel preview to highlight this widget
        myPreviewModule:Spotlight(registered.id)
    end,
}

local editor = DF:CreateEditor(designerWindow, "Plater_LayoutEditor", editorOptions)
editor:SetPoint("topleft", designerWindow, "topleft", 5, -16)
editor:SetFrameLevel(designerWindow:GetFrameLevel() + 10)
editor:SetFrameStrata("HIGH") -- if you want it to float above the rest of the UI
```

---

## RegisterObject — the central API

After the editor exists, the consumer calls `RegisterObject` once per editable object:

```lua
---@return df_editor_objectinfo
editor:RegisterObject(
    object,            -- 1: the actual UIObject (Texture, FontString, Frame, StatusBar, ...)
    localizedLabel,    -- 2: human-readable label shown in the left panel
    id,                -- 3: unique string id (used by EditObjectById, undo states, etc.)
    profileTable,      -- 4: the table where this object's settings live
    subTablePath,      -- 5: dot/bracket pathstring inside profileTable (or "" for root)
    profileKeyMap,     -- 6: maps the editor's attribute keys to the actual profile keys
    extraOptions,      -- 7: array of additional widgets to append after the built-in ones
    callback,          -- 8: function fired on every set; per-object onEditCallback
    options,           -- 9: df_editobjectoptions for THIS object (use_colon, can_move, icon)
    refFrame           -- 10: the parent that owns the object; used by anchor sliders for size limits
)
```

The 9th-parameter `options` is per-object (overrides anchor lock, icon, etc.); the editor-wide options are passed at `CreateEditor` time. Don't confuse them.

### df_editobjectoptions (per-object)

| Key | Type | Default | Purpose |
|---|---|---|---|
| `use_colon` | boolean | `false` | If true, "Size" becomes "Size:" in the menu. |
| `can_move` | boolean | `true` | If true, the mover handle is shown over the object and the user can drag it. Set to false for objects that shouldn't be repositioned (a fixed health bar, a non-movable icon). |
| `icon` | atlas / texture | nil | Icon shown next to `localizedLabel` in the left panel. Accepts atlas name, atlas table from `DF:CreateAtlas`, or a texture path/id. |

### Why these arguments are split into pairs

The split is deliberate:

- **`object` vs `refFrame`**: `object` is what the editor visually highlights and edits (e.g., the unit-name FontString). `refFrame` is the *parent context* used when computing anchor slider min/max (so the slider's range scales with the parent's width/height) and as the anchor target for `SetAnchor`. They're often the same thing's parent; sometimes you want to edit a child but anchor it to a sibling.
- **`profileTable` + `subTablePath`**: lets multiple variants share the same `profileKeyMap`. Plater uses this to swap "Enemy NPC" / "Friendly Player" / etc. — it changes only `subTablePath` (via `UpdateProfileSubTablePath`) and keeps the rest of the registration intact.
- **`profileKeyMap` vs `extraOptions`**: the first is *re-use* of built-in attribute definitions (Texture/FontString/Frame defaults) at different profile keys. The second is *extension* — adding bespoke options not in the built-in set.

---

## profileKeyMap — re-using built-in attribute definitions

The editor ships with three built-in attribute lists, one per object type:

- `attributes.Texture` — texture, width, height, vertexcolor, alpha, anchor, anchoroffsetx, anchoroffsety
- `attributes.FontString` — text, size, font, color, alpha, shadow, shadowcolor, shadowoffsetx, shadowoffsety, outline, anchor, anchoroffsetx, anchoroffsety, rotation, scale
- `attributes.Frame` — width, height, alpha, framestrata, anchor, anchoroffsetx, anchoroffsety (also used for `Button` and `StatusBar`)

Each entry has a `key` (the editor's internal attribute name, e.g. `"size"`, `"color"`, `"anchor"`). The editor doesn't know which key in *your* profile table holds the value for that attribute — you tell it via `profileKeyMap`:

```lua
local unitNameMap = {
    -- editor attribute key → path inside profileTable
    size           = "actorname_text_size",
    font           = "actorname_text_font",
    color          = "actorname_text_color",          -- a {r,g,b,a} table in the profile
    outline        = "actorname_text_outline",
    shadowcolor    = "actorname_text_shadow_color",
    shadowoffsetx  = "actorname_text_shadow_color_offset[1]", -- bracket index path
    shadowoffsety  = "actorname_text_shadow_color_offset[2]",
    anchor         = "actorname_text_anchor.side",     -- dot path
    anchoroffsetx  = "actorname_text_anchor.x",
    anchoroffsety  = "actorname_text_anchor.y",
}
```

Path strings support both `.field` and `[index]` segments. `detailsFramework.table.getfrompath(profileTable, "actorname_text_anchor.x")` walks the path.

### Convention to know about — anchor field grouping

The `anchor`, `anchoroffsetx`, and `anchoroffsety` mappings should resolve to **fields on the same parent table**. The reason: `detailsFramework:SetAnchor(widget, anchorTable, parent)` reads `anchorTable.side`, `.x`, `.y` from a single table. Plater always groups them:

```lua
anchor         = "actorname_text_anchor.side"  -- parent: profile.actorname_text_anchor
anchoroffsetx  = "actorname_text_anchor.x"     -- same parent
anchoroffsety  = "actorname_text_anchor.y"     -- same parent
```

If you split them across different parent tables, the visual-update path will silently misbehave — `SetAnchor` won't see all three values together. The editor's mover-drag tick relies on this grouping too: `onTickWhileMoving` mutates `anchorSettings.x/y` directly on what it expects to be the live anchor table.

### Skipping built-in attributes

If `profileKeyMap[someAttributeKey]` is missing for an attribute that the built-in list contains, the lookup falls through to `option.key` itself. If neither path resolves to a value in the profile table, the option is **not rendered**. So you can suppress unwanted built-ins by simply omitting them from your map AND ensuring no profile key matches their default name:

```lua
-- This map gives no FontString.text mapping. The "Text" textentry won't appear
-- (unless the profile happens to have a top-level `text` key, which it usually doesn't).
local unitLevelMap = {
    size  = "level_text_size",
    font  = "level_text_font",
    -- ... no `text` key → no "Text" widget in the menu
}
```

There's no explicit "hide this built-in" flag; suppression is by-omission.

---

## extraOptions — appending custom widgets

`extraOptions` is an array of additional attribute definitions, appended after the built-in ones (with a blank separator inserted between, when both groups are non-empty). Each entry has the same shape as the built-in attributes:

```lua
local healthBarExtra = {
    {
        key      = "health_statusbar_texture",   -- profile key (no profileKeyMap indirection)
        label    = "Texture",                    -- shown in the menu
        widget   = "selectstatusbartexture",     -- which BuildMenu widget type to render
        default  = Plater.db.profile.health_statusbar_texture,
        setter   = function(widget, value)
            widget:SetTexture(value)
            designer.UpdateAllNameplates()
        end,
    },
    {
        key      = "border_thickness",
        label    = "Border Thickness",
        widget   = "slider",
        minvalue = 0,
        maxvalue = 10,
        step     = 1,
        default  = Plater.db.profile.border_thickness,
        setter   = function(widget, value)
            Plater.UpdatePlateBorders(widget.unitFrame.PlateFrame)
            designer.UpdateAllNameplates()
        end,
    },

    {type = "blank"}, -- visual spacer

    {
        key    = "health_selection_overlay",
        label  = "Target Overlay",
        widget = "selectstatusbartexture",
        default = Plater.db.profile.health_selection_overlay,
        setter = function(widget, value)
            widget.unitFrame.targetOverlayTexture:SetTexture(LSM:Fetch("statusbar", value))
        end,
    },
}
```

Important behavior differences vs. the built-ins:

- **`extraOptions` keys are profile keys directly** — there's no per-option indirection. The editor reads `profileTable[entry.key]` (or via path).
- **You provide `setter` directly**, since there's no built-in fallback for these.
- **`{type = "blank"}` (or `{widget = "blank"}`)** inserts a vertical spacer in the menu. The editor's blank-collapsing logic strips leading blanks and collapses adjacent blank pairs.

### Each attribute entry — full field list

Recognized fields in any attribute (built-in or extra):

| Field | Type | Purpose |
|---|---|---|
| `key` | string | Attribute identifier. For built-ins, looked up via `profileKeyMap`. For extras, used as the profile key directly. |
| `label` | string | Shown in the menu next to the widget. |
| `widget` (or `type`) | string | BuildMenu widget type. See "Available widget types" below. |
| `setter` | function | `function(widget, value)` — applies the value to the live UI object. Required if you want visual feedback from edits. |
| `default` | any | Used by some BuildMenu widgets as a fallback display value. |
| `minvalue` / `maxvalue` | number | Slider range bounds. For `anchoroffsetx`/`anchoroffsety` the editor overrides these from the parent's width/height at edit time. |
| `step` | number | Slider increment. |
| `usedecimals` | boolean | If true, slider stores fractional values (color alphas, scales, rotations). Otherwise the value is `math.floor`-ed. |
| `subkey` | string | Reserved for nested addressing within compound widgets. Most widgets don't use this. |

---

## Available widget types

These are the widget types `BuildMenuVolatile` recognises. Each has a specific `set()` callback signature.

| Widget type | Visual | `set` signature | Notes |
|---|---|---|---|
| `range` / `slider` | horizontal slider with +/- buttons | `(widget, fixed, value)` | Editor floors value if `usedecimals` is false. |
| `toggle` / `switch` | checkbox | `(widget, fixed, value)` | `value` is boolean. |
| `color` | color swatch | `(widget, r, g, b, alpha)` | Multi-arg. Editor packs them into `{r,g,b,a}` and mutates the live profile table in place to preserve table-reference identity. |
| `textentry` | text input | `(widget, fixed, value)` | Free-form string. |
| `dropdown` | dropdown | `(widget, fixed, value)` | Caller provides options via `dropdownFunc`. |
| `selectstatusbartexture` | dropdown of LibSharedMedia textures | `(widget, fixed, value)` | Specialised dropdown. |
| `fontdropdown` | dropdown of LibSharedMedia fonts | `(widget, fixed, value)` | |
| `outlinedropdown` | dropdown of font outline modes | `(widget, fixed, value)` | |
| `anchordropdown` | dropdown of nine anchor sides | `(widget, fixed, value)` | The editor zeros `.x`/`.y` when the side changes. |
| `selectframestrata` | dropdown of strata names | `(widget, fixed, value)` | |
| `button` | clickable button | `function(...)` | For action buttons (no value). |
| `execute` | callable button | `function(...)` | Same shape as button; alternate name. |
| `select` | small select | `(widget, value)` | |
| `label` | static text | n/a | Display-only. |
| `blank` | empty spacer | n/a | Used for visual grouping. |

The editor's universal `set` handler normalises across these — see "How edits flow" below.

---

## How edits flow — the universal `set` handler

When the user changes any widget value, the same handler runs (defined inside `PrepareObjectForEditing`). The flow:

1. **Normalise input.** If widget is `range`/`slider` and `usedecimals` is false, `math.floor` the value.
2. **Snapshot OLD value.** Either `parentTable[1..4]` (for color) or `getfrompath(profileTable, profileKey)` (for everything else). This captures the pre-edit state for undo replay.
3. **Apply the value via the shared `applyValue` closure**, which:
   - For `color`: mutates `parentTable[1..4]` in place (preserving the table reference held by other code), then re-assigns `value = parentTable`.
   - Otherwise: `setfrompath(profileTable, profileKey, value)`.
   - Calls `onEditCallback(object, option.key, value, profileTable, profileKey)` if set.
   - Calls `option.setter(object, value)` for non-anchor keys; for anchor keys, passes `parentTable` (the anchor table) so `SetAnchor` can read `.side`, `.x`, `.y` together.
   - For anchor keys: stops the mover, applies the new anchor, restarts the mover (or hides it if `can_move = false`).
4. **Snapshot NEW value** the same way.
5. **Push undo entry**, but only if `oldSnapshot ~= newSnapshot` (using `snapshotsEqual`, which handles shallow-table compares for color tables). The skip-on-no-op is important — see "Undo/Redo" below.

### Why color is special-cased

Most BuildMenu widgets pass `(widget, fixedParam, newValue)`. The color widget passes `(widget, r, g, b, alpha)`. The editor packs `r, g, b, alpha` into a payload table and additionally mutates `parentTable[1..4]` in place. The in-place mutation matters because the consumer's profile may have the same color table referenced from multiple places, and replacing it (vs mutating) would orphan the references.

### Why anchor keys are special-cased

The three anchor keys (`anchor`, `anchoroffsetx`, `anchoroffsety`) call `option.setter(object, parentTable)` instead of `option.setter(object, value)`. The setter needs the full anchor table (with `.side`, `.x`, `.y`) to drive `SetAnchor`. Additionally, when the user changes the anchor *side* (the dropdown), the editor zeros `.x`/`.y` so the offsets reset to the anchor's new origin.

---

## The onEditCallback

The 8th `RegisterObject` argument is a per-object callback fired on every edit. Its signature is:

```lua
function(object, optionKey, newValue, profileTable, profileKey)
```

In Plater, a single `onSettingChanged` is shared across every registration:

```lua
local onSettingChanged = function(UIObject, optionKey, newValue, profileTable, profileKey)
    -- example: keep the legacy combat/non-combat health-size mirror in sync
    if profileKey:find("health_incombat") then
        if optionKey == "width" then
            profileTable.health[1] = newValue   -- mirror to non-combat width
        else
            profileTable.health[2] = newValue   -- height
        end
    end

    -- Trigger live re-render of every nameplate
    designer.UpdateAllNameplates()
end
```

**Pattern**: one callback per addon, branching on `profileKey` or `optionKey`. Keeps the registration call sites short and centralises the "what to do when something changes" logic.

The callback fires:
- After every `set()` from a widget edit.
- During mover drag (each tick that produces a non-zero offset, the editor calls it twice — once for `"x"`, once for `"y"`).
- During Undo/Redo replay.

So make it **idempotent** — same input, same output. Don't accumulate state inside it.

---

## The edit lifecycle

### EditObject

`editor:EditObject(registeredObjectInfo)` is the master switch. It:

1. Clears all `editing*` fields on the editor.
2. Resolves `profileTable` (via `subTablePath` if set) and stores it.
3. Stores the new registered object info, callback, options, etc.
4. Calls `PrepareObjectForEditing` — which rebuilds the build-menu, sets up anchor frames, starts the mover (if `can_move`), and shows the four selection corners.
5. Calls `objectSelector:RefreshMe()` so the left-panel selection highlight tracks the new active object. (This is folded into `EditObject` itself — Step 6 of the audit cleanup.)
6. Fires `start_editing_callback(editor, registered)` if configured.

There are convenience variants:

```lua
editor:EditObjectById("HEALTHBAR")    -- by id
editor:EditObjectByIndex(3)           -- by 1-based registration order
editor:Refresh()                       -- re-edit the currently-edited object (e.g. after profile import)
```

All paths funnel into `EditObject` — only one place needs to refresh the selector now.

### ClearEditing

`editor:ClearEditing()` tears down the editing state cleanly: stops mover movement, hides the mover frame, disables anchor frames, hides the four selection corners, and nils every `editing*` field. Used internally by `UnregisterObject` when the active object is the one being unregistered, so the editor doesn't end up pointing at a freed registration.

### UnregisterObject

`editor:UnregisterObject(object)` removes a registration. If the object is the currently-edited one, `ClearEditing` runs first.

### OnHide / OnShow

The editor wires its own scripts:
- `OnHide` — stops mover, hides mover, disables anchor frames, hides selection corners (Step 7), hides the over-the-top frame.
- `OnShow` — re-enters editing for whichever index was active before (so re-opening the editor lands on the same widget).

---

## Mover (drag-to-position)

When the user clicks any non-interactive part of the active widget, they can drag it to reposition. Mechanics:

- `moverObject.MoverFrame` is created at construction time and parented to `UIParent`. It's a Button frame, mouse-enabled, `SetMovable(true)`.
- During `Setup`, the mover is anchored to the active widget via dual `SetPoint("topleft", object, "topleft", 0, 0)` + `SetPoint("bottomright", object, "bottomright", 0, 0)`. This stretches the mover to cover the entire widget (overriding its `SetSize(16, 16)`), so clicking anywhere on the widget hits the mover.
- `OnMouseDown`: snapshots `anchorSettings.x/y` into `lastMoveInfo.preMoveX/Y` (used for undo on release), records the mover's center as the drag start, and calls `StartMoving()`.
- `OnTickWhileMoving` fires each frame during a drag. It computes the cursor offset relative to the drag start, mutates `anchorSettings.x/y` directly (which IS the live profile sub-table), and calls `SetAnchor(object, anchorSettings, objectParent)` to physically reposition the widget. It also syncs the anchor offset sliders via `SetValueNoCallback` so the build-menu reflects the live position.
- `OnMouseUp`: ends the drag. Compares `preMoveX/Y` to current `anchor.x/y`; if non-zero delta, pushes a mover undo entry via `AddMoverUndoState`.
- `onTickNotMoving` (the idle script): if the object position changed since last tick (e.g. consumer re-anchored it externally), re-applies the dual-anchor pair so the mover keeps tracking.

### Why the mover covers the whole widget

Plater's nameplate sub-widgets (Texture, FontString) have no clickable children. Making the mover the full size of the widget means *clicking anywhere on the widget* drags it. The audit's earlier reading suggested replacing this with a 16×16 grab handle for the sake of preserving child-click access. This was reverted because it broke the user's drag-anywhere expectation; the dual-anchor full-coverage approach is correct for this consumer.

If you ever register an object that has interactive children inside, you'd want to either set `can_move = false` for that registration (no mover) or accept that the mover intercepts those child clicks while editing.

---

## Anchor frames

When the active widget has an `anchor` attribute, the editor draws nine clickable frames at the parent's nine anchor points (top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right). Clicking one sets the anchor side. This is the visual alternative to the anchor-side dropdown.

The `AnchorFrames` system is set up by `CreateAnchorFrames()` once per editor and reused. It's controlled by `editor:GetEditingOptions().no_anchor_points` (per editor) — set true to suppress.

---

## Undo / Redo

The editor maintains two stacks (`undoHistory`, `redoHistory`) capped at `MAX_UNDO_STACK = 50` entries each. State entries are closure-based:

```lua
---@class undostate
---@field objectId any           -- which registration this entry belongs to
---@field coalesceKey string     -- merges adjacent entries from the same widget/drag
---@field undo fun()             -- restore the previous state
---@field redo fun()             -- re-apply the state
```

### What gets recorded

- **Every menu-driven edit** (slider, dropdown, color, anchor side, etc.) captures `oldSnapshot` / `newSnapshot` and pushes an entry whose `undo`/`redo` closures call the same `applyValue` with the snapshot they need.
- **Every drag** (mover release with non-zero delta) pushes a separate entry whose `undo`/`redo` mutate `anchorTable.x/y` and call `SetAnchor` directly.

### Coalescing

A slider drag fires `set()` 100 times between mouse-down and mouse-up. Without coalescing the stack would explode. `AddToUndoHistory` checks if the new entry's `coalesceKey` matches the previous entry on the stack; if so, it keeps the original `undo` (the pre-drag value) and replaces only `redo` (the latest value). Net result: one stack entry per logical user action.

`coalesceKey` shape:
- Slider/dropdown/etc.: `<objectId>:<option.key>` (e.g. `"UNITNAME:size"`)
- Mover: `<objectId>:mover`

Continuous edits to the same key on the same object coalesce. Switching widgets or switching keys breaks the coalesce chain.

### Skip on no-op (`snapshotsEqual`)

Critical detail: `BuildMenuVolatile` fires each widget's `set()` callback during construction with the widget's initial value. Without protection, every `EditObject` call would push N spurious "no-change" undo entries (one per widget). Each push would also wipe `redoHistory` (because *any* new edit invalidates redo). Net effect: Undo/Redo cycles silently break after the first iteration.

The `snapshotsEqual(oldSnapshot, newSnapshot)` helper detects no-op pushes and skips them. Comparison is:
- Reference / scalar equality first (`a == b`).
- Otherwise shallow table comparison (handles color `{r,g,b,a}` snapshots).

Without this guard, the second Undo of a multi-step Undo/Redo cycle does nothing — the original state has been displaced from the stack by spurious construction-time pushes, and the redo stack has been wiped. With the guard, only real value changes hit the stack.

### Public API

```lua
editor:Undo()                -- pops from undoHistory, replays state.undo, pushes to redoHistory, EditObject(theAffectedObject)
editor:Redo()                -- inverse
editor:RefreshUndoButtons()  -- re-evaluates Enable/Disable for the toolbar buttons (called automatically from Undo/Redo/AddToUndoHistory/CreateUndoManager)
```

The toolbar buttons are auto-managed: disabled when their stack is empty, enabled when not. If `show_undo_buttons = false`, only the methods exist — wire them to a keybind or your own UI yourself.

### Object-switch on Undo

`Undo` and `Redo` both call `EditObject(GetObjectById(state.objectId))` after replaying — the undo of a change to widget A also brings the editor's selection back to widget A so the user can see the change reflected in the menu. If the object was unregistered between push and pop, `GetObjectById` returns nil and the data restoration still happens (the closures still hold valid profile-table references), but the UI doesn't switch.

### What's NOT recorded

- Programmatic mutations to the profile table that bypass the editor's `set` path (e.g. `profile.size = 5` written from another addon).
- Drag operations with zero net displacement (the user mouse-downs and mouse-ups without moving — no entry is pushed).
- Unregister/Register events.

---

## Selection: list panel + selectButton

There are two ways to select a widget for editing:

1. **Click the row in the left-panel list.** The row's `OnClick` calls `editor:EditObject(registeredObjectInfo)`.
2. **Click the live widget itself in the preview area.** Each `RegisterObject` call creates an invisible `selectButton` parented to the widget's parent, sized to cover the widget (`SetAllPoints(object)`), with frame level set to `widgetLevel + 1` so it sits on top. Its `OnClick` calls `EditObject` on its own registration.

Both paths go through the same `EditObject`. Mutually compatible.

The frame-level math used to be `editor:GetFrameLevel() + #registeredObjects` — that mixed levels from two parent trees (the editor and the widget's parent) and only worked by coincidence. Now it's `widgetLevel + 1` (or `widgetParentLevel + 1` for Region children that lack their own frame level).

---

## Common patterns

### Copy defaults, then mutate

Plater's idiom — keep a "default options" table, copy it, mutate the copy:

```lua
local editObjectDefaults = { use_colon = false, can_move = true }

local healthBarOpts = DF.table.copy({}, editObjectDefaults)
healthBarOpts.can_move = false   -- health bar shouldn't be drag-movable

editor:RegisterObject(healthBar, "Health Bar", "HEALTHBAR", profile, "", healthBarMap, healthBarExtras, onSettingChanged, healthBarOpts, healthBar)
```

Without `DF.table.copy`, mutating `editObjectDefaults` directly would leak across other registrations.

### One callback for all objects

```lua
local onSettingChanged = function(uiObject, key, newValue, profileTable, profileKey)
    if key == "anchor" then
        -- e.g. zero offsets, refresh, etc.
    end
    designer.UpdateAllNameplates()  -- universal "redraw" trigger
end

-- Pass the same callback to every RegisterObject call.
editor:RegisterObject(unitName, "Unit Name", "UNITNAME", profile, subPath, unitNameMap, unitNameExtras, onSettingChanged, options, refFrame)
editor:RegisterObject(unitLevel, "Unit Level", "UNITLEVEL", profile, subPath, unitLevelMap, unitLevelExtras, onSettingChanged, options, refFrame)
-- ...
```

Branching inside the callback by `key` or `profileKey` is more maintainable than per-object callbacks for most use cases.

### Swapping subTablePath at runtime (Plater's "Enemy NPC / Friendly NPC" dropdown)

Plater lets the user pick which "plate config" they're editing (Enemy NPC, Friendly NPC, Enemy Player, Friendly Player). Each plate config is a sibling sub-table under `profile.plate_config`. Switching the dropdown changes `subTablePath` on every plate-config-bound registration:

```lua
local plateConfigObjectsInfo = {} -- collected during registration
local subTablePath = "enemynpc"   -- the active plate config

local onClickPlateConfigOption = function(self, fixedParam, newSubTablePath)
    subTablePath = newSubTablePath

    for _, objectInfo in ipairs(plateConfigObjectsInfo) do
        editor:UpdateProfileSubTablePath(objectInfo, subTablePath)
    end

    editor:Refresh()  -- re-edit the currently-edited object so the menu reflects the new config
end
```

`UpdateProfileSubTablePath` mutates the registration's `subtablepath` field. The next `EditObject` call (triggered by `Refresh()`) reads the new path from the registration and resolves to the correct sub-table.

### Suppressing built-in attributes

You can't explicitly hide a built-in attribute. Workarounds:

1. **Don't include it in the `profileKeyMap`** — if the editor can't find the key in the profile, the option is dropped.
2. **Use `extraOptions` exclusively** and register against a Frame whose profile happens to lack the built-in keys — only the extras get rendered.

Plater uses (1) for `vertexcolor` on the cast-bar spark (no entry in the map for `height`/`anchor`, etc., even though those are in the Texture defaults).

---

## Pitfalls

### Mover undo and the SetAnchor call

If you write a custom undo entry that mutates anchor data without calling `detailsFramework:SetAnchor(object, anchorTable, parent)`, the data restores but the widget visually stays where it was. The mover undo (`AddMoverUndoState`) correctly invokes SetAnchor inside its `applyXY` closure — copy that pattern if you build other anchor-touching undo entries.

### Color tables are mutated in place, not replaced

The universal `set` handler for color does:
```lua
parentTable[1] = r
parentTable[2] = g
-- ...
value = parentTable
```

Not:
```lua
profile.color = {r, g, b, a}
```

This preserves references — if anything else in your addon holds a reference to the color table, that reference stays valid and gets the new color. If your code expects to replace the table (e.g. with `setfrompath`), you'll see surprising behavior. Mutate in place to interop cleanly.

### onEditCallback is called repeatedly during drag

Don't do expensive work synchronously inside `onEditCallback`. During mover drag, it fires twice per frame (once for `"x"`, once for `"y"`). Plater calls `designer.UpdateAllNameplates()` in its callback — that's expensive but Plater is comfortable with the cost. If you're triggering anything heavier (network, file I/O), throttle it.

### Anchor key fields must share a parent

Repeated for emphasis: `profileKeyMap.anchor`, `profileKeyMap.anchoroffsetx`, `profileKeyMap.anchoroffsety` must resolve to fields on the same parent table for `SetAnchor` to read them as a unit. If they don't, the visual update path silently breaks.

### `attributeList` for unrecognized object types

If you `RegisterObject` an `EditBox`, `ScrollFrame`, `CheckButton`, `Cooldown`, or any other type that's not in the recognized set (`Frame`, `Button`, `StatusBar`, `FontString`, `Texture`), the editor falls back to an empty `attributeList`. The build-menu will show only your `extraOptions`. A one-time chat-window warning prints the first time each unrecognized type is encountered.

This is intentional — the consumer knows best what's editable on a custom widget type. Provide everything via `extraOptions` and your registration will work.

### `BuildMenuVolatile` fires set() during widget construction

This is the source of the "Undo button feels wonky" bug class. Each menu rebuild (every `EditObject` call) triggers each widget's set callback once during initialization with the widget's current value. Without the `snapshotsEqual` skip, this would push a no-op undo entry per widget on every edit, wiping the redo stack repeatedly. The current code handles this in the framework. If you build your own undo system on top of the editor, ensure you replicate the no-op skip.

### `UnregisterObject` while editing

The active object can be unregistered. The editor will tear down its editing state via `ClearEditing` to avoid dangling references. But: any open undo entries that reference the unregistered object are NOT removed from the stacks. They'll still replay — mutating the (now-orphaned) profile data. The object switch on Undo will silently no-op because `GetObjectById` returns nil. This is intentional (the data restore is still useful even if the UI can't switch to it) but worth knowing.

---

## Public method reference

| Method | Purpose |
|---|---|
| `RegisterObject(...)` | See "RegisterObject — the central API" above. |
| `UnregisterObject(object)` | Remove a registration; tear down active edit if it was this one. |
| `EditObject(registeredObjectInfo)` | Switch to editing this object. Refreshes the left selector. |
| `EditObjectById(id)` | Same, by string id. |
| `EditObjectByIndex(i)` | Same, by 1-based registration order. |
| `Refresh()` | Re-edit the currently-edited object (use after profile imports). |
| `ClearEditing()` | Tear down active edit + UI state. |
| `GetEditingObject()` | Returns the active uiobject or nil. |
| `GetEditingObjectIndex()` | Returns the 1-based registration index of the active object. |
| `GetEditingRegisteredObject()` | Returns the active `df_editor_objectinfo`. |
| `GetEditingProfile()` | Returns `profileTable, profileMap` for the active object. |
| `GetEditingOptions()` | Returns the per-object `df_editobjectoptions`. |
| `GetExtraOptions()` | Returns the active object's extraOptions array. |
| `GetOnEditCallback()` | Returns the active object's callback. |
| `GetAllRegisteredObjects()` | Returns the full array. |
| `GetObjectById(id)` / `GetObjectByRef(uiobject)` / `GetObjectByIndex(n)` / `GetObjectByObjectInfo(info)` | Resolution helpers. |
| `UpdateProfileTable(identifier, profileTable)` | Re-points a registration at a different profile table. |
| `UpdateProfileSubTablePath(identifier, subTablePath)` | Re-points to a different sub-path within the existing profile table. |
| `UpdateProfileTableOnAllRegisteredObjects(profileTable)` | Bulk re-point. |
| `Undo()` / `Redo()` | Undo/Redo manager. |
| `AddToUndoHistory(state)` | Push a custom undo state (with coalescing). |
| `AddMoverUndoState(registered, anchorTable, oldX, oldY, newX, newY)` | Push a mover-style undo state (used by the mover's OnMouseUp internally; expose if you build custom drag logic). |
| `RefreshUndoButtons()` | Re-evaluate toolbar button enable/disable. |
| `StartObjectMovement(anchorSettings)` / `StopObjectMovement()` | Mover lifecycle (called automatically). |
| `ShowSelectedTextures(object)` / `HideSelectedTextures()` | Four corner highlights. |
| `GetCanvasScrollBox()` / `GetObjectSelector()` / `GetOptionsFrame()` / `GetOverTheTopFrame()` / `GetMoverObject()` | Internal frame accessors — useful if you need to overlay custom UI. |

---

## End-to-end example (synthetic)

```lua
local DF = _G["DetailsFramework"]

-- 1. Profile shape
MyAddonDB = MyAddonDB or {
    titleText = {
        text  = "Welcome",
        size  = 18,
        font  = "Friz Quadrata TT",
        color = {1, 0.85, 0.2, 1},
        anchor_data = { side = 5, x = 0, y = 0 },  -- side 5 = "center"
    },
}

-- 2. The live UI being edited
local title = MyFrame:CreateFontString("$parentTitle", "overlay", "GameFontNormal")
title:SetText(MyAddonDB.titleText.text)
title:SetFont(LSM:Fetch("font", MyAddonDB.titleText.font), MyAddonDB.titleText.size)
title:SetTextColor(unpack(MyAddonDB.titleText.color))
DF:SetAnchor(title, MyAddonDB.titleText.anchor_data, MyFrame)

-- 3. The map: editor attribute key → profile path
local titleMap = {
    text          = "text",
    size          = "size",
    font          = "font",
    color         = "color",
    anchor        = "anchor_data.side",
    anchoroffsetx = "anchor_data.x",
    anchoroffsety = "anchor_data.y",
}

-- 4. Optional extras (additional widgets the built-in FontString set doesn't cover)
local titleExtras = {
    {type = "blank"},
    {
        key = "underline",
        label = "Show Underline",
        widget = "toggle",
        setter = function(widget, value)
            -- toggle a hidden line texture
        end,
    },
}

-- 5. Single shared callback
local onChanged = function(uiObject, key, newValue, profileTable, profileKey)
    -- Everything important already happened via option.setter; this is mostly for side effects.
    print(("changed %s -> %s"):format(tostring(key), tostring(newValue)))
end

-- 6. Build the editor and register the object
local editor = DF:CreateEditor(MyEditorWindow, "MyAddon_TitleEditor")
editor:SetPoint("topleft", MyEditorWindow, "topleft", 5, -5)

editor:RegisterObject(
    title,                 -- 1: object
    "Title Text",          -- 2: label
    "TITLE",               -- 3: id
    MyAddonDB.titleText,   -- 4: profile (this object's settings live directly under it)
    "",                    -- 5: subTablePath (none, settings are at the root of the passed table)
    titleMap,              -- 6: profileKeyMap
    titleExtras,           -- 7: extraOptions
    onChanged,             -- 8: callback
    { use_colon = true, can_move = true }, -- 9: per-object options
    MyFrame                -- 10: refFrame (parent context for anchor sliders)
)
```

User-visible result: the editor window shows "Title Text" in the left panel; clicking it (or clicking the live title text in the preview) populates the center panel with Text/Size/Font/Color/Alpha/.../Anchor/Underline; dragging the title repositions it; `Undo` and `Redo` buttons at the bottom-right reverse the history.

---

## Notes for AI readers

When generating code that uses this editor, check the following:

1. **`profileKeyMap` paths must be valid in the profile.** If the path doesn't resolve (`getfrompath` returns nil), the editor silently drops the widget. There's no warning. If the user reports "the X slider isn't appearing", verify the profile has a non-nil value at the mapped key.

2. **Anchor keys must group on the same parent table.** Repeated above; flagged here because it's the most common subtle bug.

3. **Color values are 4-element tables `{r, g, b, alpha}`** with all components in `[0, 1]`. The editor mutates in place — don't replace the table reference.

4. **Don't write your own Undo on top of the editor's.** Use `AddToUndoHistory` directly. The closure-based shape lets you record any state-changing operation by writing two closures (undo, redo) and providing a `coalesceKey`.

5. **Setters run during widget construction** (a BuildMenuVolatile artifact). If your `setter` has external side effects (network, sound, file I/O), guard it against being called with the current value at construction time, or accept that it'll fire once on every `EditObject`.

6. **`onEditCallback` runs many times per drag.** Idempotency required. Don't accumulate.

7. **The `id` parameter to `RegisterObject` is used by the undo system to relocate the affected object on Undo replay.** Make it stable across registrations of the same logical widget — if you re-register with a different id every session, undo entries from the previous session can't find their object.

8. **`refFrame` (10th argument) influences anchor slider min/max.** `editor:PrepareObjectForEditing` reads `refFrame:GetWidth()` / `GetHeight()` for `anchoroffsetx`/`anchoroffsety` slider ranges. If you pass a refFrame that's much smaller than what you actually want to allow, your sliders will clip the user's range.

9. **The editor doesn't render the right-panel preview**. That's the consumer's job. The editor only adds the four corner-bracket textures (`SelectedTextures`) and the mover. If your preview is missing, that's a consumer-side concern, not an editor bug.

10. **Lookups in the public API are polymorphic**. `EditObjectById` / `EditObjectByIndex` / `GetObjectByRef` exist as discrete functions, but `getRegisteredObject` (file-local) sniffs the input type and dispatches. The mixin field declarations document the per-type expectation.

---

## See also

- `buildmenu.md` — the build-menu generator the editor delegates form rendering to.
- `anchorsystem.md` — the anchor-frame system used for the nine-point anchor selector.
- `scrollbox.md` — the left-panel object selector is a `df_scrollbox`.
- `Plater_Designer.lua` and `Plater_Designer_Objects.lua` — production reference consumer.
