# 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`), fills the widget's bounds with a translucent magenta rectangle (`ObjectBackgroundTexture`, recolorable via `SetSelectedBackgroundColor`), 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: a UIObject OR an array of UIObjects (see "Multiple members" below)
    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
)
```

### Multiple members — one registration, many selectable widgets

Param #1 accepts either a single UIObject (the classic shape) or an array of UIObjects that
share the **same logical editor entry**:

```lua
editor:RegisterObject({tex1, tex2, tex3, tex4}, "Border", "BORDER", profile, "", borderMap, ...)
```

When an array is passed:

- **Every member** gets its own invisible click-to-select overlay in the live preview. Clicking
  any of them selects the registration for editing and makes that specific member the focus
  of the selection brackets / mover / `ObjectBackgroundTexture`.
- **One row** appears in the left panel (registration is the unit). Clicking the row
  re-selects whichever member was last clicked in the preview, or `members[1]` on first edit.
- **All members must share the same object type** (`:GetObjectType()`). The same-type
  assertion is enforced at registration — mixed types are rejected, because the built-in
  attribute list and setters are object-type-specific.
- **Setters fan out**: when the user changes any value, the option's `setter(widget, value)`
  is invoked once per member, in array order. The 8th-arg `onEditCallback` still fires once
  per edit with the active member as its `object` argument.
- **Mover drags fan out**: dragging the active member updates the shared anchor data and
  calls `SetAnchor` on every member in the array, so siblings keep visual lockstep with the
  dragged member.
- **Brackets / mover do NOT fan out**: they attach only to the active member. This is
  deliberate — the user expects the selection visuals to follow what they clicked.

The active member is remembered across closing and re-opening the editor (via
`activeMemberIndex` on the registration), so OnShow restores the same member the user was
last looking at.

**Backward compatibility**: passing a single widget (the classic shape) is unchanged. The
registration is internally a 1-member array; `objectRegistered.object` and
`objectRegistered.selectButton` remain valid as aliases for `objects[1]` / `selectButtons[1]`.

**Idempotency caveat**: because setters fan out, any non-idempotent work inside a setter is
now multiplied by member count per edit. Keep setters purely visual (`SetX(value)`); put
side effects (telemetry, network, debounced persistence) in the per-registration callback,
which still fires only once per edit.

**Different parents**: members may live under different parents — the mover calls
`SetAnchor(member, anchorSettings, member:GetParent())` per member, so the same anchor data
resolves to each member's own parent.

**Unregister semantics**: `editor:UnregisterObject(anyMember)` drops the whole registration.
Registration is the unit; partial-member removal is not supported.

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).
- **`setter` is optional but recommended.** Both `option.setter` call sites in `applyValue` are nil-guarded (`if (option.setter) then ... end`), so you may omit it on an extra entry and let the 8th-arg callback do the visual update. There's no built-in fallback for `extraOptions`, so something must do the work — either the setter, the callback, or you accept that this widget edits the profile but doesn't visually update. See "Setter vs callback — order, roles, and when each is required" below for the trade-off, and "Anchor edits and the setter" in Pitfalls for the one case where a callback-only approach silently breaks.
- **`{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.
- **`{type = "label", text = "...", text_template = template}`** inserts a static text row — handy as a header above a logical group of options. Forwarded fields: `text`, `name`, `get`, `text_template`, `color`, `namePhraseId` (same shape BuildMenu's label widget consumes). No `key`/`setter` needed; the editor doesn't read/write profile state for it.
- **`{type = "execute"` / `"button"`, label = "...", func = function() ... end}`** inserts an action button — fires `func` on click, with no profile read/write. Forwarded fields: `func`, `param1`, `param2`, `icontexture`, `icontexcoords`, `text_template`, `width`, `height` (same shape BuildMenu's execute widget consumes). `key` is used only as the widget id. Bypasses the `bHasValue` check, so unlike value-bearing widgets the entry renders even without a corresponding profile key.

### 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. Nil-guarded in `applyValue`, so omitting it is legal. Effectively required for the `"anchor"` key (the editor zeros offsets *between* callback and setter, see Pitfalls) and recommended elsewhere unless the 8th-arg callback covers the visual update. For anchor keys the editor passes `parentTable` instead of `value`. |
| `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. Entry uses `text` / `name` / `get` for content and `text_template` / `color` / `namePhraseId` for styling — no `key`/`setter`. |
| `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 runs three ordered steps:
   - **(a) Mutate the profile.** 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)`.
   - **(b) Fire the 8th-arg callback FIRST.** `onEditCallback(object, option.key, value, profileTable, profileKey)` runs if set. This is the **first** visual-update hook — the profile already holds the new value, but no widget method has been invoked yet.
   - **(c) Fire `option.setter` SECOND.** `option.setter(object, value)` for non-anchor keys; `option.setter(object, parentTable)` for anchor keys (so `SetAnchor` can read `.side`, `.x`, `.y` from one table). **Nil-guarded** — if `option.setter` is nil the call is skipped silently, leaving step (b) as the only visual hook.
   - For anchor keys, the sequence around step (c) is: stop the mover → (if `option.key == "anchor"`) zero `.x`/`.y` on the parent table → call setter → restart the mover (or hide it if `can_move = false`). The x/y zeroing happens **between callback and setter**, which is the subtle reason a callback-only consumer can't fully replace the setter for the `"anchor"` key — see "Anchor edits and the setter" in Pitfalls.
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 — **before** `option.setter` runs. By the time your callback executes, the profile is already mutated but no widget method has been called.
- 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.
- During `BuildMenuVolatile` widget construction (one set() per widget per `EditObject`). The construction-time fire goes through the same `applyValue`, so the callback runs at init too.

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

Because the callback runs *before* `option.setter`, and both setter call sites are nil-guarded, you can use the callback as the **sole** visual-update hook (omitting `option.setter` from your `extraOptions` entries entirely). The next subsection lays out when this is safe and when it's not.

### Setter vs callback — order, roles, and when each is required

Both hooks fire on every edit, in a strict order: **callback first, setter second** (with a profile-mutation step before them and the mover refresh after them, for anchor keys). They are not redundant — they have different signatures and different jobs:

| | 8th-arg callback (`onEditCallback`) | `option.setter` |
|---|---|---|
| **Fires** | First (1278) | Second (1293 / 1302) |
| **Signature** | `(object, optionKey, newValue, profileTable, profileKey)` | `(widget, value)` — or `(widget, parentTable)` for anchor keys |
| **Scope** | Per-registration (one callback covers all attributes of the object) | Per-attribute (one setter per option) |
| **Value shape for anchors** | Scalar (just the changed field) | Full anchor parent table (`{side, x, y}`) — needed for `SetAnchor` |
| **Built-in for Texture/FontString/Frame** | No — consumer supplies | Yes — shipped in `attributes` table at the top of `editor.lua` |
| **Required for `extraOptions`** | No (but typical) | No (nil-guarded) — but see "when required" below |
| **Idempotency required** | Yes (fires on every drag tick and on init) | Yes (same reasons) |

**Best uses**

- **Callback** — side effects: re-render preview, mirror writes to other profile keys, telemetry, cross-widget bookkeeping, debounced persistence. Anything that's "the same response regardless of which attribute changed."
- **Setter** — the single line that pushes this value into this widget (`SetText`, `SetWidth`, `SetVertexColor`, `SetAnchor`, `SetAlpha`, …). Dispatch-by-attribute is implicit; you don't write a switch on `option.key`.

**When the setter is effectively required (even though nil-guarded)**

1. **`option.key == "anchor"` (the side dropdown).** The editor zeros `.x`/`.y` on the parent table *after* the callback fires and *before* the setter runs (see [editor.lua:1287-1289](editor.lua#L1287-L1289)). A callback that re-anchors using the parent table reads the pre-zero offsets; only the setter reads the post-zero offsets. Without a setter, the profile says `(newSide, 0, 0)` but the widget stays at `(newSide, oldX, oldY)` until the user nudges something. The two offset keys (`anchoroffsetx`, `anchoroffsety`) don't have this problem.
2. **Built-in attributes for `Texture`/`FontString`/`Frame`/`Button`/`StatusBar`.** The setters at [editor.lua:140-373](editor.lua#L140-L373) already exist; you can't "omit" them — they're inside the library. Live with the two-hook design here.

**When you can safely omit the setter (extraOptions only)**

- The callback does the visual application directly, AND
- The attribute is **not** `"anchor"`, AND
- You're comfortable that the construction-time `set()` call (one per widget per `EditObject`) routes through your callback — idempotency must hold.

**Practical recommendation**

Provide a setter on every extra option that has a visual effect. The setter is the cheapest, narrowest, most-locally-scoped place to put `SetX(value)`. Use the callback for cross-cutting concerns (re-render the preview, persist, broadcast). The mental model "setter = this widget; callback = everything else" maps cleanly to the firing order and signature shapes.

---

## 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)`. For multi-member registrations, no specific member is passed, so the editor falls back to the registration's `activeMemberIndex` (the last in-place click) or to `members[1]` if no member has been clicked yet.
2. **Click the live widget itself in the preview area.** Each member of the registration gets its own invisible `selectButton`, parented to that member's parent, sized to cover the member (`SetAllPoints(member)`), with frame level set to `widgetLevel + 1` so it sits on top. Its `OnClick` calls `EditObject(registration, clickedMember)`. For single-widget registrations there is exactly one such button — same as before.

Both paths go through the same `EditObject`. Mutually compatible. `EditObject` writes `activeMemberIndex` back onto the registration so subsequent left-list clicks land on the same member.

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.

### Setters fan out across all members of a registration

For multi-member registrations, `option.setter(widget, value)` runs once per member on every edit (same call site, looped). Built-in setters are pure visual writes (`SetText`, `SetWidth`, `SetVertexColor`, …) and tolerate repetition trivially.

`extraOptions` setters need to stay idempotent for the same reason — running them N times must produce the same end state as running them once. Anything that's *not* idempotent (incrementing a counter, posting to a network, emitting telemetry) must move to the per-registration 8th-arg callback, which still fires exactly once per edit regardless of member count.

Single-widget registrations are unaffected: the fan-out loop iterates once.

### 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.

### Anchor edits and the setter — callback-only is subtly wrong

`option.setter` is nil-guarded, so it's tempting to omit it on every extra option and let the 8th-arg callback handle visuals. **For the `"anchor"` key (the side dropdown), this is wrong.** Sequence inside `applyValue` when the user picks a new side:

1. Write the new `side` into the profile (`setfrompath`).
2. **Callback fires** with `(object, "anchor", newSide, profileTable, profileKey)`. If the callback reads the parent anchor table (`profileTable.anchor_data` or wherever) and calls `SetAnchor`, it does so with the *old* `.x` / `.y` still in place.
3. The editor zeros `.x` and `.y` on the parent table — this is the "offsets reset to origin when side changes" convention.
4. Setter would fire here with the now-zeroed parent table. With the nil-guard, this step is skipped if no setter exists.

Net effect: profile is `(newSide, 0, 0)`, widget is anchored at `(newSide, oldX, oldY)`. The mismatch survives until the next anchor-related edit re-applies. The two offset keys (`anchoroffsetx`, `anchoroffsety`) don't have this problem because no zeroing happens for them.

**Fix:** always provide a setter on the `"anchor"` key — even a one-liner that calls `detailsFramework:SetAnchor(widget, value, widget:GetParent())`. Or if you absolutely refuse a setter, fire your re-anchor logic on the next frame so it sees the zeroed offsets. The setter is cheaper.

### `attributeList` for unrecognized object types

If you `RegisterObject` an `EditBox`, `ScrollFrame`, `CheckButton`, `Cooldown`, `Slider`, 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`. No warning is emitted — this is a supported pattern (custom widget driven entirely by extras).

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.

### Preview widgets that hard-code strata fight the editor's parent strata

The editor itself doesn't set its own frame strata — it inherits from its parent. Same for its `SelectedTextures` corner brackets. But when the consumer's right-panel preview is constructed by a factory that internally calls `SetFrameStrata(...)` — typical for profile-driven windows that apply `profile.Strata` on every refresh — the preview gets pinned to whatever strata the factory chose, ignoring where the editor lives.

**Symptom**: the editor renders correctly inside a high-strata container (e.g. an options panel at `"HIGH"`), but the preview disappears behind the panel because the factory's profile sets it to `"LOW"`. Child elements may leak through partially due to draw-order coincidence between sibling layers — that's not the preview surfacing, it's z-fight artifacts.

**Mechanism**: if the factory uses `Mixin` to attach a wrapper method (e.g. `SetFramestrata` with lowercase `s`) that writes `profile.Strata` and forwards to `SetFrameStrata` (uppercase `S`), the wrapper lives in the frame's lua table and is overridable. The C-level `SetFrameStrata` is on the frame userdata and isn't overridable by table assignment.

**Fix**: replace the wrapper on the preview instance with a no-op so subsequent refresh paths can't reset the strata.

```lua
previewFrame.SetFramestrata = function() end  -- blocks the mixin's strata writes
-- No explicit SetFrameStrata("HIGH") needed; the preview now inherits its parent's strata.
```

Don't try to "force" the strata up by calling `SetFrameStrata("HIGH")` explicitly — without the no-op, the next refresh tick will drag it back down. The override is load-bearing; the explicit set is redundant noise.

### Editor doesn't fit "one set of properties governs many identical widgets"

The built-in model is **one registration = one UI widget = one set of properties**. Plater's nameplate fits cleanly: one health bar, one cast bar, one unit name FontString. Each `RegisterObject` binds a specific widget instance to its profile keys.

When the consumer's data model is "this property applies to every row in a scrollbox" or "this toggle applies to every line in a list", there's no canonical widget. The corner brackets have to land on something visible, but whichever instance you pick mis-communicates scope: brackets around row 1 imply "row 1 is being edited" when the change in fact applies to all rows.

**Workarounds, in order of preference**:

1. **Don't use the editor for collection-wide settings.** Keep those in a regular `BuildMenu` options panel. The editor is for per-widget visual properties (this FontString's color, that StatusBar's anchor), not global toggles.
2. **Register the container** (the scrollbox itself, or the wrapping frame) and use `extraOptions` exclusively for the collection-wide knobs. The brackets land on the whole region, which is honest about scope.
3. **Register the first instance as a stand-in**, label it generically ("Lines" not "Line 1"), and accept the small visual lie. Cheapest; worst UX.

**Subtle trap if you take option 3**: many scrollbox APIs lazily initialise. `df_scrollbox:GetLine(index)` increments `self._LinesInUse`, which is `nil` until the first `Refresh()`. Calling `GetLine` during editor setup (before data flows) errors with `attempt to perform arithmetic on field '_LinesInUse' (a nil value)`. Read `scrollbox.Frames[index]` directly instead — populated at line-creation time, not Refresh time.

### Editor anchored inside a tab body needs Y offset to clear the tab buttons

If the editor's parent is a `df_tabcontainerframe` (the tab body created by `CreateTabContainer`), the tab buttons are **siblings of the tab body, not children**. The tab body uses `SetAllPoints()` to cover the full container area; the buttons render at a higher frame level (`220` vs `210`) in the upper portion of that same area.

Anchoring the editor at the tab body's `TOPLEFT` with a small negative Y (`-10`, `-20`) places the editor's first row of options inside the button row's screen rectangle. The buttons win on frame level and draw on top, but the editor's content peeks through in zones where the button's textures don't cover — producing scrambled text like "WindoFwnt" (editor's "Font Size" label bleeding into "Window" button text).

**Fix**: anchor below the button band. For the framework defaults (`button_y = 30`, `button_height = 22`), the band ends at y ≈ 52; use `-60` from `TOPLEFT` to clear it with breathing room. If `button_y` is customised or buttons wrap to multiple rows, recompute. See `tabcontainer.md` for the full layering story.

```lua
local TAB_TOP_OFFSET = -60  -- below the default tab button band
editor:SetPoint("TOPLEFT", tabBodyFrame, "TOPLEFT", 10, TAB_TOP_OFFSET)
```

---

## 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. |
| `SetSelectedBackgroundColor(r, g, b, a)` | Recolor the translucent fill drawn under the currently-edited widget (`ObjectBackgroundTexture`). Defaults to magenta `(1, 0, 1, 0.25)`. Pass values in `[0, 1]`. Effect is immediate — no `Refresh` needed. Useful when magenta clashes with the consumer's preview palette. |
| `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`), the translucent magenta fill behind the widget (`ObjectBackgroundTexture` — recolor with `SetSelectedBackgroundColor` if it clashes with the preview palette), 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.

11. **`option.setter` is nil-guarded; the 8th-arg callback fires before the setter.** Order on every edit is: profile-mutation → 8th-arg callback (1278) → `option.setter` (1293 for anchor keys, 1302 otherwise) → mover refresh (anchor branch only). Both `option.setter` call sites are wrapped `if (option.setter) then ... end`, so `extraOptions` entries may omit `setter` and rely on the callback for visual updates — *except* for the `"anchor"` key, where the editor zeros `.x`/`.y` between callback and setter (see Pitfalls → "Anchor edits and the setter"). Mental model: setter = "apply this value to this widget" (per-attribute, fine-grained, `(widget, value)`); callback = "something changed, react globally" (per-registration, coarse, `(object, key, value, profileTable, profileKey)`). For anchor keys the setter receives the parent anchor table, not the scalar — so `SetAnchor` can read `.side/.x/.y` together. Built-in setters for `Texture`/`FontString`/`Frame`/`Button`/`StatusBar` ship inside `editor.lua` and you do not supply or omit those; the nil-guard only matters for consumer-supplied `extraOptions`.

12. **Don't put expensive work in the callback — it fires on construction too.** `BuildMenuVolatile` fires each widget's `set()` once at construction time with the current value, which routes through `applyValue` → callback. So on every `EditObject` call, your callback fires N times (one per widget) with no-op values. The `snapshotsEqual` guard prevents undo-stack pollution, but it does NOT prevent the callback from running. Idempotency and cheapness both required.

---

## 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.
