# mUI_framework — LLM Context Guide

Qt-inspired UI widget framework for World of Warcraft retail addons.
Wraps Blizzard's frame API in a class/signal/property system.

---

## Architecture at a Glance

```
mObject  (signals, properties, parent/child ownership)
  └─ mWidget  (wraps a Blizzard Frame, show/hide/resize)
       ├─ Primitive widgets: Label, TextInput, Checkbox, Slider, Spinner,
       │    PushButton, ToggleButton, StatusBar, GroupBox, SearchBar,
       │    IconButton
       ├─ Composite widgets: SliderControl, MultiSelectList, ListWidget,
       │    CheckboxGroup, TreeView, Dropdown, DataGrid
       ├─ Container: Window
       └─ Floating: ProgressDialog, RadialMenu
Layouts (HBoxLayout, VBoxLayout, GridLayout, ScrollLayout, TabLayout) — manage child widget positions
```

`lib` is the framework singleton, obtained via `LibStub("mUIFramework-1.0")`.

---

## Core Concepts

### Class system

```lua
local MyWidget = lib.Widget:extend()
function MyWidget:init(parent)
    MyWidget.super.init(self, parent)  -- always call super
    -- setup...
end
```

- `Class:extend()` creates a subclass.
- `obj:isA(Class)` checks type.
- `Class:new(...)` instantiates and calls `:init(...)`.
- `mUI.Class("Name", Parent)` is an accepted alias for `Parent:extend("Name")`.

### Properties

```lua
obj:defineProperty("value", 0)              -- define with default
obj:setProperty("value", 42)                -- set (emits "valueChanged" signal)
obj:setProperty("value", 42, {silent=true}) -- set without emitting
obj:getProperty("value")                    -- get
```

Property changes auto-emit a `<name>Changed` signal. `setProperty(name, value, {silent=true})`
updates without emitting — used internally to sync state another signal already covers.
**Always use `setProperty`/`getProperty` — never mutate `self._properties` directly**, including
reads (a value read via `self._properties.x` inside a closure still works today, but bypasses the
single choke point and has caused stale-read bugs before).

### Signals

```lua
-- emit
self:emit("clicked", arg1, arg2)

-- connect: function form
obj:connect("clicked", function(a, b) ... end)

-- connect: method form (preferred — target held by weak ref after GC fix)
obj:connect("clicked", otherObj, "methodName")

-- disconnect
local handle = obj:connect(...)
obj:disconnect(handle)
```

Every widget fires at minimum: `shown`, `hidden`, `resized`. `connect()` asserts if the signal
name was never declared via `addSignal`/`defineProperty` on that object — there is no silent
no-op for a typo'd signal name.

### Parent/Child ownership

```lua
local child = lib.Label:new(parentWidget)   -- pass parent to constructor
child:setParent(newParent)                  -- reparent
child:destroy()                             -- destroys self + all children
```

Destroying a widget automatically disconnects its signals and cleans up its Blizzard frame.

---

## Widget Reference

### Label
```lua
local lbl = lib.Label:new(parent, "Hello world")   -- bare string -> opts.text
-- or: lib.Label:new(parent, { text = "Hello", fontObject = "GameFontHighlight",
--                              align = "LEFT", valign = "MIDDLE", wordWrap = true,
--                              color = {1, 0.8, 0} })
lbl:setText("Hello")
lbl:text()
lbl:setColor(1, 0.8, 0)          -- r, g, b, a
lbl:setAlign("CENTER")           -- horizontal; optional 2nd arg sets vertical
lbl:setWordWrap(true)
lbl:setFont(path, size, flags)
```
Signals: none beyond base widget.

---

### TextInput
```lua
local ti = lib.TextInput:new(parent, { label = "Name:", text = "default" })
ti:setText("default")
ti:text()
ti:setMaxLength(32)              -- NOT setMaxLetters
ti:setReadOnly(true)
ti:setFocus() / ti:clearFocus() / ti:selectAll()
-- Signals:
ti:connect("textChanged",      function(text) end)
ti:connect("editingFinished",  function(text) end)
```
`textChanged` fires on every keypress and on `setText()`. The signal argument and
`getProperty("text")` always agree. Constructor opts: `label`, `text`, `readOnly`, `maxLength`.
There is no placeholder-text feature and no bare-string constructor form (a bare 2nd arg is
silently ignored) — always pass an opts table.

---

### Checkbox
```lua
local cb = lib.Checkbox:new(parent, "Enable feature")   -- bare string -> opts.text
cb:setChecked(true)
cb:isChecked()    -- returns bool
cb:setText("New label")
cb:text()
-- Signal:
cb:connect("checkedChanged", function(isOn) end)
-- NOTE: there is no "stateChanged" signal — connect("stateChanged", ...) asserts.
```

---

### Slider
```lua
local sl = lib.Slider:new(parent, { min=0, max=100, value=50, step=1, label="Volume", height=40 })
-- opts.height sets total widget height and self._fixedHeight (default 40).
sl:setValue(50)           -- emits valueChanged
sl:value()                -- NOT getValue()
sl:setMinMax(0, 200)
-- valueChanged fires on user drag AND on setValue():
sl:connect("valueChanged", function(v) end)
```

---

### Spinner
```lua
local sp = lib.Spinner:new(parent, { min=0, max=99, value=5, step=1 })
-- no bare-string constructor form; there is no opts.label at all.
sp:setMinMax(0, 99)       -- NOT setRange
sp:setValue(10)           -- emits valueChanged
sp:value()
sp:setStep(1)
sp:setButtonHeight(24)    -- optional: pin +/- buttons to a fixed height (nil reverts)
sp:connect("valueChanged", function(v) end)  -- user interaction AND setValue()
```

---

### SliderControl  (slider + dec/inc buttons)
```lua
local sc = lib.SliderControl:new(parent, {
    min = 0, max = 100, value = 50, step = 1,
    label      = "Volume",  -- optional title above the control
    height     = 52,        -- override fixed height (auto-computed when omitted)
    thumbWidth = 24,        -- width of the draggable indicator
})
-- no bare-string constructor form (a bare 2nd arg silently renders no title).
sc:setMinMax(0, 100)      -- NOT setRange
sc:setValue(50)           -- emits valueChanged
sc:value()
sc:setStep(5)
sc:connect("valueChanged", function(v) end)
```

---

### PushButton
```lua
local btn = lib.PushButton:new(parent, "Click Me")
btn:setEnabled(false)
btn:isEnabled()
btn:setText("New label") / btn:text()
btn:click()               -- programmatically fire OnClick
btn:connect("clicked",   function(mouseButton) end)
btn:connect("pressed",   function() end)
btn:connect("released",  function() end)
```

---

### ToggleButton
```lua
local tb = lib.ToggleButton:new(parent, "Toggle")
tb:setChecked(true)
tb:isChecked()
tb:setText("New label") / tb:text()
tb:connect("checkedChanged", function(isOn) end)
tb:connect("toggled",        function(isOn) end)  -- also fires on every click
```

---

### Dropdown
```lua
local dd = lib.Dropdown:new(parent, { placeholder = "Pick one" })  -- bare string -> opts.placeholder
dd:addItem("Option A", "a")     -- (text, value) — there is no setItems/bulk API besides opts.items
dd:addItem("Option B", "b")
-- or at construction: lib.Dropdown:new(parent, { items = { {text="Option A", value="a"} } })
dd:setSelected("a")             -- emits selectionChanged
dd:selectedValue()              -- NOT getSelected()
dd:selectedText()
dd:clearItems()
dd:connect("selectionChanged", function(value, text) end)
```
> **Known issue**: Only one dropdown popup can be open at a time (global singleton).

---

### StatusBar
```lua
local sb = lib.StatusBar:new(parent, { min=0, max=100, value=75 })
sb:setValue(75)            -- emits valueChanged
sb:value()
sb:setMinMax(0, 100)       -- NOT setRange
sb:setColor(0.2, 0.8, 0.4)
sb:setShowText(false)      -- NOT showLabel() — hide the percentage text
sb:connect("valueChanged", function(v) end)
```

---

### GroupBox
```lua
local gb = lib.GroupBox:new(parent, "Settings")   -- bare string -> opts.title
gb:setTitle("New Title")
gb:title()
-- gb:contentWidget() returns the inner mWidget — add children to this, NOT gb.content:
local lbl = lib.Label:new(gb:contentWidget())
```

---

### SearchBar
```lua
local sb = lib.SearchBar:new(parent)   -- takes no opts
sb:connect("textChanged", function(text) end)
sb:connect("cleared",     function() end)
sb:text()                 -- NOT getText()
sb:clear()
sb:setFocus()
```

---

### MultiSelectList
```lua
local ml = lib.MultiSelectList:new(parent, {
    items = {
        { text = "Mount A", value = "mountA", checked = true },
        { text = "Mount B", value = "mountB", checked = false },
    },
})
ml:addItem("Mount C", "mountC", false)   -- there is no setItems(); use opts.items or addItem
ml:connect("selectionChanged", function(selectedValues) end)  -- table of ALL checked values, not (value, isChecked)
ml:getSelected()    -- NOT getChecked() — returns table of checked values
ml:setAllChecked(true)
ml:setItemChecked("mountA", false)
ml:clearItems()
```

---

### ListWidget
```lua
local lw = lib.ListWidget:new(parent, {
    showSearch    = true,   -- default true
    showAddRemove = true,   -- default true; NOT separate showAdd/showRemove keys
})
lw:addItem("item1")
lw:removeSelected()
lw:removeItem(index)
lw:getItems()           -- returns list copy
lw:getSelected()        -- returns text, index (or nil)
lw:setSelected(index)
lw:clearItems()
lw:connect("selectionChanged", function(text, index) end)
lw:connect("itemAdded",        function(text, index) end)
lw:connect("itemRemoved",      function(text, index) end)
```

---

### CheckboxGroup  (radio-button controller)
```lua
local grp = lib.CheckboxGroup:new()   -- NOT a widget; no parent
grp:add(checkboxA)
grp:add(checkboxB)
grp:add(checkboxC)
-- Only one checkbox in the group can be checked at a time (grp:setExclusive(false) to allow any combo).
grp:setActive(checkboxB)
grp:active()          -- currently-active checkbox, or nil
grp:checkboxes()
grp:connect("selectionChanged", function(activeCheckboxOrNil) end)  -- single arg, NOT (newActive, oldActive)
```

---

### TreeView
```lua
local tv = lib.TreeView:new(parent)
local fruits = tv:addNode("Fruits")        -- NOT addRoot()
local apple  = fruits:addChild("Apple", someValue)
apple:addChild("Gala", "gala")
-- No removeChild API — clear and re-add to mutate tree.
tv:refresh()
tv:selected()          -- returns the selected node, or nil
tv:clearSelection()
tv:expandAll() / tv:collapseAll()
tv:connect("itemSelected",   function(node) end)  -- NOT "selectionChanged"
tv:connect("itemExpanded",   function(node) end)  -- NOT "expanded"
tv:connect("itemCollapsed",  function(node) end)  -- NOT "collapsed"
```
Node fields: `node.text`, `node.value`, `node.parent`, `node.children`, `node.expanded`, `node.depth`.
> **Known limitation**: There is no `removeChild()` or `clear()` API.
> Workaround: destroy the TreeView and create a new one if you need to remove nodes.

---

### IconButton
```lua
local ib = lib.IconButton:new(parent, {
    size       = 18,
    texture    = "Interface\\Icons\\INV_Misc_QuestionMark",
    badge      = "12",                          -- optional overlay text (count/ilvl)
    badgeColor = { 1, 1, 1 },
    badgeSize  = 11,
    onTooltip  = function(tip, self) tip:SetHyperlink(link) end,
})
ib:setTexture(path)
ib:setBadge(text, r, g, b, sizeOpt)
ib:connect("clicked", function(mouseButton) end)
```

---

### DataGrid
```lua
local dg = lib.DataGrid:new(parent, {
    rowHeight = 24,
    striped   = true,
    emptyText = "(No items)",
    columns = {
        { key="check", width=28,  render = function(cell, item) ... end },
        { key="name",  width=235, render = function(cell, item) ... end },
    },
})
dg:setItems({...})        -- triggers full redraw
dg:refresh()              -- redraw in place
dg:getItems()
dg:setColumns(cols)
dg:setRowHeight(h)
dg:setEmptyText(t)
dg:connect("rowClicked", function(item, index, mouseButton) end)
dg:connect("rowHovered", function(item, index) end)   -- nil item = mouse left
```
Each column's `render(cell, item, colW, rowH)` receives a fresh cell Frame
on every `setItems`/`refresh`. The grid handles striped backgrounds, scroll,
and an internal scrollbar via `lib.createScrollbar`.

---

### ProgressDialog
```lua
local pd = lib.ProgressDialog:new({
    title  = "Destroying Items",
    width  = 320,
    height = 90,
    total  = 50,
})
pd:setStatus("Confirming…")
pd:setRange(0, 50)
pd:setValue(12)
pd:setBarColor(0.8, 0.2, 0.8)
pd:show()
pd:hide()
pd:destroy()             -- always destroy when done; not auto-cleaned
```
Floating, draggable, TOOLTIP-strata modal-style dialog. No parent — call
`pd:show()` to display and `pd:destroy()` to remove. (Note: `ProgressDialog` genuinely has
`setRange` — unlike Slider/Spinner/SliderControl/StatusBar, which use `setMinMax`. The naming
is inconsistent across widgets; this doc reflects each widget's actual method.)

---

### RadialMenu
```lua
local rm = lib.RadialMenu:new(parent)        -- parent optional (defaults UIParent)
rm:addOption("keep", "Keep", iconOrNil, function(id) ... end)  -- outer ring
rm:addOption("sell", "Sell", nil, function(id) ... end,
    { size = 32, tooltip = "Tags this stack for selling" })    -- opts optional
rm:setCenter(iconPath, labelOrNil, onTooltip)  -- non-interactive center display
rm:open(anchorFrame)     -- anchorFrame optional; defaults to cursor position
rm:close()
rm:clearOptions()        -- drop all options + center (rebuild before re-open)
rm:isVisible()
-- Signals:
rm:connect("optionSelected", function(id) end)
rm:connect("centerSelected", function() end)   -- (unused: center is display-only)
rm:connect("closed",         function() end)
```
A circular pop-up: up to N outer option buttons around a ring plus a
non-interactive center. Option button colors are keyed by `id`, stripping a
trailing `_all` suffix first (so `"sell_all"` picks up the `"sell"` color) —
the addon's own `keep`/`sell`/`auction`/`destroy`/`lock`/`unlock` palette is
built in; unknown ids fall back to neutral. `addOption`'s optional 5th `opts`
arg takes `size` (override this button's pixel size — the ring's spacing
auto-adjusts to whatever the largest button present needs) and `tooltip`
(string, or `function(GameTooltip)` for full control — same shape as
`setCenter`'s `onTooltip`, shown on hover via `OnEnter`/`OnLeave`). Button
labels auto-shrink their font to fit on one line, falling back to wrapping
only if the label still doesn't fit even at the smallest readable size;
explicit `"\n"` line breaks in a label (e.g. `"Lock\nItem"`) are measured
per-line rather than as one string so they're never mistaken for overflow.

**Dismissal:** clicking an option selects + closes; clicking a gap, the
center, or off the menu closes without selecting; Esc closes (registered in
`UISpecialFrames`). Outside-click uses `GLOBAL_MOUSE_DOWN` (not a catcher
frame) so the same click can re-open the menu over a different item in one go.
Reuse one instance and `clearOptions()`/re-add per open; `destroy()` removes
its `UISpecialFrames` entry.

---

### Window
```lua
local win = lib.Window:new(parent, {
    width  = 400, height = 300,      -- default 400x300
    title  = "Window Title",
    icon   = nil,                    -- optional texture path
    statusText   = "",
    resizable    = true,
    minWidth = 220, minHeight = 120, -- defaults
    maxWidth = nil, maxHeight = nil, -- optional
    showMinBtn   = true,
    showMaxBtn   = true,
    alphaSlider  = true,             -- transparency slider in the title bar (20-100%)
    closeOnEscape = true,            -- registers with UISpecialFrames
})
-- There is NO (name, title, width, height) positional constructor form —
-- the 2nd arg is always an opts table, and there is no string coercion for it.
win:setResizable(true)
win:setMinSize(200, 150)
win:setMaxSize(w, h)
win:setTitle("New Title") / win:title()
win:setStatusText("Ready") / win:statusText()
win:setIcon(texturePath)
win:setAlpha(0.8)
win:show()
win:minimize()
win:maximize()
win:restore()
win:isMinimized() / win:isMaximized() / win:windowState()
-- win:contentWidget() is where you add children / set layouts — NOT win.content:
local vbox = lib.VBoxLayout:new(win:contentWidget())
-- Other accessors:
win:titleBar() / win:statusBar()
-- Signals:
win:connect("minimized", function() end)
win:connect("maximized", function() end)
win:connect("restored",  function() end)
win:connect("closeRequested", function() end)  -- fires before the default hide()
-- Persistence (call in ADDON_LOADED / SavedVariables flow):
local state = win:saveState()    -- returns table
win:loadState(state)
```
> **Known issue**: `saveState()` captures only the first anchor point from `GetPoint(1)`.
> Multi-point anchored windows may restore with incorrect dimensions.

---

## Layout System

Layouts manage the positions of child widgets inside a target mWidget.
They re-run automatically when the target is resized or shown. Global names are
`lib.HBoxLayout`, `lib.VBoxLayout`, `lib.GridLayout`, `lib.ScrollLayout`, `lib.TabLayout`
(NOT `lib.HBox`/`lib.VBox`/`lib.Grid`/`lib.Scroll`/`lib.Tab` — those don't exist).

```lua
local hbox = lib.HBoxLayout:new(container)     -- or VBoxLayout
hbox:addWidget(widgetA)           -- stretch defaults to 1
hbox:addWidget(widgetB, 2)        -- stretch is a raw number, NOT an options table —
                                   -- twice the proportional share of remaining space
hbox:setSpacing(6)
hbox:setMargins(8, 8, 8, 8)    -- left, top, right, bottom
```

### HBoxLayout / VBoxLayout
`addWidget(widget, stretch)` — `stretch` is a plain number (default 1), not `{stretch=1}`.
There is no `fixedWidth`/`fixedHeight` addWidget option — a widget opts out of stretch-sharing
by setting `widget._fixedWidth` (HBox) or `widget._fixedHeight` (VBox) on itself before being
added (e.g. `Slider`/`SliderControl` do this internally via `opts.height`).
`addStretch(stretch)` inserts a spacer item with no widget.

### GridLayout
```lua
local grid = lib.GridLayout:new(container)     -- takes ONLY the target; no rows/cols args
grid:addWidget(widget, row, col)                -- row, col are 1-based
grid:addWidget(widget, row, col, rowSpan, colSpan)  -- separate positional numbers, NOT a table
grid:setRowStretch(1, 1)         -- row 1 stretches
grid:setColumnStretch(2, 1)      -- NOT setColStretch — col 2 stretches
```
Grid dimensions grow automatically from the widgets you add; there's no upfront row/col count.

### ScrollLayout
```lua
local scroll = lib.ScrollLayout:new(container)   -- takes only the target
scroll:addWidget(bigWidget, height)              -- height defaults to 30
scroll:scrollFrame() / scroll:scrollContent()
```
Mouse-wheel scrolling is **always on** with a hard-coded 30px line height — there is no
`enableMouseWheel()` method to call, opt out of, or reconfigure the line height on.

### TabLayout
```lua
local tabs = lib.TabLayout:new(container)
tabs:addTab("General", generalWidget)
tabs:addTab("Advanced", advancedWidget)
tabs:setCurrentTab(1)             -- NOT setActiveTab("label") — takes a 1-based INDEX
tabs:currentTab() / tabs:tabCount() / tabs:tabBar() / tabs:tabPage()
tabs:connect("currentChanged", function(index, oldIndex) end)  -- NOT "tabChanged", NOT a label
```
> **Do not use `addWidget()` on a Tab layout** — use `addTab()` only; it raises an error.
> Hidden tab pages may not receive `OnSizeChanged` from WoW; the framework calls
> `Widget:_revalidateLayout()` when a tab becomes visible to work around this.

---

## Patterns & Best Practices

### Always set a layout before adding children
```lua
local vbox = lib.VBoxLayout:new(win:contentWidget())
vbox:setMargins(8,8,8,8)
vbox:setSpacing(4)
local lbl = lib.Label:new(win:contentWidget())
vbox:addWidget(lbl)
```

### Pushing saved/default state into widgets
Most widget setters (`setValue`, `setSelected`, `setChecked`, `setText`) now emit their
change signal. If you push *saved/default state* into widgets during a refresh routine,
guard the routine so change handlers don't write that state straight back to storage
(see mCleanup's `_syncing` / `_resetting` flag pattern). For genuinely silent state
sync, call `setProperty(name, value, {silent=true})` directly.

### Use destroy() not just Hide()
```lua
widget:destroy()    -- cleans up signals, frame, children
-- NOT: widget.frame:Hide()  -- leaves object alive, leaks memory
```

### SavedVariables integration
```lua
-- On ADDON_LOADED:
if MyAddonSV and MyAddonSV.windowState then
    win:loadState(MyAddonSV.windowState)
end

-- On PLAYER_LOGOUT:
MyAddonSV = MyAddonSV or {}
MyAddonSV.windowState = win:saveState()
```
`saveState()` serializes only primitive property values (string, number, bool).
Tables and functions in properties are silently dropped.

### Escape hatch: raw frame access
Every widget exposes `widget.frame` (the underlying Blizzard Frame).
Use this only when the framework API doesn't cover your need.
```lua
widget.frame:SetAlpha(0.5)     -- raw Blizzard call
```

---

## Known Limitations & Design Smells

These are areas of the framework that are not yet fixed. Work around them as documented.

| Issue | Impact | Workaround |
|---|---|---|
| `Window:saveState()` captures only first anchor | Multi-point anchored windows restore incorrectly | Avoid multi-point anchoring windows |
| `TreeView` has no `removeChild`/`clear` | Can't mutate tree after creation | Destroy and recreate the TreeView |
| `ScrollLayout` mouse wheel uses fixed 30px line height, always on | No way to opt out or use a different line height | None — `enableMouseWheel()` is not actually a public API despite older docs implying it |
| `TabLayout:addWidget()` is wrong API | Raises an error | Use `addTab(label, widget)` |
| No focus manager | Can't globally clear focus | Manage focus per-widget manually |
| No global tooltip system | Most widgets have no built-in hover text | `IconButton` has `onTooltip`; for others hook `OnEnter`/`OnLeave` on `widget.frame` |
| `DataGrid` re-creates all cell frames on every `refresh()` | High row counts cause GC churn | Acceptable up to a few hundred rows; for more, build a virtualized grid |
| Layout relayout is deferred one frame | First open may show a one-frame flash | Call `layout:relayoutNow()` if geometry must be correct synchronously |
| Inconsistent scrollbar implementations | `Dropdown`, `ListWidget`, and `DataGrid` use the shared `lib.createScrollbar` helper; `MultiSelectList`, `TreeView`, and `ScrollLayout` hand-roll their own via `UIPanelScrollFrameTemplate` instead, giving them a different look/feel and no drag-thumb theming | When adding a new scrollable widget, prefer `lib.createScrollbar` for visual consistency; treat the other three as known debt, not a pattern to copy |
| Method names aren't consistent across widgets | `setMinMax` (Slider/Spinner/SliderControl/StatusBar) vs `setRange` (ProgressDialog); `value()` (Slider/Spinner/SliderControl/StatusBar) vs no equivalent getter pattern elsewhere | Always check this doc for the exact widget's method name rather than assuming a common convention |

---

## File Map

| File | Purpose |
|---|---|
| `mUIFramework.lua` | LibStub registration, `lib` singleton |
| `mUIFramework.xml` | Load order for all Lua files |
| `libs/LibStub/LibStub.lua` | Standard WoW library versioning stub |
| `core/Class.lua` | Single-inheritance class system |
| `core/Object.lua` | mObject — signals, properties, ownership |
| `core/Signal.lua` | Signal/slot implementation |
| `widgets/Widget.lua` | mWidget base — Blizzard frame wrapper |
| `widgets/utils.lua` | Shared color palette (`lib._btnColors`), scrollbar width (`lib._scrollbarW`), `lib.createScrollbar`, `lib.styledButton` |
| `widgets/*.lua` | Individual widget implementations |
| `layouts/Layouts.lua` | HBoxLayout, VBoxLayout, GridLayout, ScrollLayout, TabLayout |
