promotional bannermobile promotional banner

LibAceGUIWidgets

LibAceGUIWidgets is a shared AceGUI-3.0 widget and styling library. It registers reusable, drop-in AceGUI widget types and helpers so addons share one consistent look instead of each rebuilding the same frames.
Back to Files

LibAceGUIWidgets-v0.1.9

File nameLibAceGUIWidgets-LibAceGUIWidgets-v0.1.9.zip
Uploader
PmptastyPmptasty
Uploaded
Aug 22, 2026
Downloads
85
Size
74.5 KB
Flavors
RetailMoP ClassicClassic TBCClassic
File ID
8704807
Type
R
Release
Supported game versions
  • 12.0.7
  • 12.0.5
  • 11.2.7
  • 5.5.4
  • 4.4.2
  • 3.4.5
  • 2.5.6
  • 1.15.9

What's new

Changelog

[v0.1.9] -- any window can be resized now, not just a ClearFrame, and a tooltip that spammed errors on hover

Added -- MINOR 26

  • W:MakeResizable(frameOrWidget, opts) -- the drag grips, without having to be a ClearFrame. Attaches the same three sizer strips ClearFrame has to any frame (or any AceGUI widget's .frame), with resize bounds, live callbacks and size persistence. Returns a handle:

    local h = W:MakeResizable(myWindow, {
        minW = 400, minH = 200, maxW = 1200, maxH = 900,
        grips  = "SE S E",              -- a SET, not one corner; `false` for none
        status = MyDB.window,           -- width/height/left/top persisted on drag end
        onResize     = function(f, w, ht) list:Refresh() end,   -- LIVE, once per draw
        onResizeStop = function(f, w, ht) rebuildTheExpensiveThing() end,
    })
    h:SetEnabled(false)                 -- a locked window
    h:IsResizing()
    

    What it replaces is thirty lines of copy-paste. Resizing was a property of being a ClearFrame: three anonymous sizer frames and four local OnMouseDown handlers inside that widget's constructor, reachable only by constructing one. A consumer with its own window had to reproduce them, and three things it could not get at all -- no live callback (ClearFrame persists on mouse-up only, so a window whose contents must re-lay-out while the user drags has nothing to hook), no maximum size, and persistence welded to AceGUI's status table, which a plain frame does not have.

    onResize is throttled to at most one call per frame draw, coalesced through a hidden OnUpdate driver that is shown only while a size change is pending. That is not a nicety: an un-throttled live callback means a full re-render per mouse pixel, which is why "just hook OnSizeChanged" is the wrong answer and why the live callback is safe to offer at all.

    Consumers on an older copy feature-detect with if W.MakeResizable then.

  • W:ApplyResizeBounds(frameOrWidget, minW, minH[, maxW, maxH]) -- ApplyMinResize with a maximum. Same modern-SetResizeBounds / Classic-SetMinResize fork, and it only touches SetMaxResize when a maximum was actually given, since a bound of 0 is not the same as no bound. ApplyMinResize is unchanged and still works.

  • W:GetResizeHandle(frameOrWidget) -- the handle a previous MakeResizable attached, so a consumer can re-point callbacks or read IsResizing() without holding the constructor's return.

Changed -- MINOR 26

  • ClearFrame builds its grips through the framework rather than beside it. Identical geometry, identical StartSizing points, identical persistence -- the difference is that there is now one implementation instead of two that happen to agree. Its SetResizable call and its SetResizeBounds/SetMinResize fork are gone from the constructor too; the framework is the only place in this library that has to know the two spellings.

    GroupFrame did not gain grips. It has never had any, and adding them would change shipped behaviour for consumers of a widget that only ever asked to be resizable programmatically.

  • Widget registration Versions 28 -> 29 for ClearFrame, GroupFrame and TLabel. ClearFrame's frame structure genuinely changed (an extra child frame -- the throttle driver); the other two move with it because Tests/widgetversion_spec.lua requires the three to stay equal, and the last time one was raised alone the other two sat tied with FastGuildInvite's vendored fork for an unknown length of time with nothing saying so.

  • docs/LIBRARY_CONTRACTS.md -- a new inbound board where consuming addons raise work against this library, answered here in place. It settles the process point that has been open on docs/AUDIT.md since round 3: consumers request, this library authors. A shared library changed from inside whichever consumer happened to be open is a change to ~20 addons decided by accident, and until now Dibs' own CLAUDE.md ("put a reusable widget in the library") and the fleet rule ("consumers adopt, never author") could not both be satisfied. Tests/HARNESS_CONTRACT.md is the outbound counterpart and now exists too, carrying two contracts this work raised against the test harness.

Fixed -- MINOR 26

  • W:AttachTooltip no longer raises bad argument #1 to 'SetText' on hover, and no longer stacks a handler per call. Reported in game against v0.1.9; the defect is older than that release and is mine either way. Two separate faults in one helper:

    • The guard was if title and title ~= "", which is not a type check. A table, a boolean or a function passes both halves, and GameTooltip:SetText is a C function that then raises -- inside an OnEnter handler, so the consumer gets a red error every time the cursor crosses the control and nothing in the traceback points at their own call site. Text is now taken only when it is a string or a number; anything else draws no line instead of raising.
    • HookScript APPENDS a handler and never replaces one, so every re-attach installed another closure holding its own copy of the text. A consumer refreshing a tooltip whose title reports a value the same control changes ended up with one handler per refresh -- which is where an 11x error count comes from, and it meant the first call's stale text was still being drawn on the way past. The text now lives on the frame and the hooks install once, so a later call updates what is shown. The refresh use case gets better rather than worse.

    Both driven RED first, and the reverted guard reproduces the reported message verbatim: bad argument #1 to 'SetText' (Usage: self:SetText(text [, color, alpha, wrap])), plus 10 OnEnter handlers were installed by 10 calls. Nine new specs; suite 160 passed / 0 failed.

  • The suite asserts geometry for the first time (peer review finding 8): Tests/resize_spec.lua computes grip rects from real GetLeft/GetRight/GetBottom and pins that the bottom strip and the corner grip share no pixels -- a band where the two overlap is a drag decided by hit-test order rather than by which grip the user aimed at. Driven RED first, by moving the S strip's offset.

  • RowList's header is pinned to its rows, closing the oldest half of finding 8. _buildHeader has its own placement chain, structurally parallel to _buildRow's and not shared -- the same rule written out twice in two functions, with nothing asserting they agree. Five new specs assert from real rects that the bar spans the same extent as a row, that every header column sits over its own cells to the pixel, that both reserve the same action-icon strip and move by the same amount when actions are added, and that the bar shares no pixels with row 1.

    The chains are deliberately not merged: one function serving a Button-with-sort-arrow and a FontString-or-CheckButton is a bigger change than the defect justifies. Pinning the agreement is what makes the duplication safe -- an edit to one chain now has to touch the other or go red. Driven RED first: 2 px added to the header's left offset gives column 'name': header starts at x 8.0, its cells at x 6.0.

    Suite 152 passed / 0 failed (was 122), on harness pin 1ffc8b4.

[v0.1.8] -- a peer-review round closed, an initial sort for RowList, a searchable dropdown, and named cooldowns

Added — MINOR 25

  • RowList:SetSort(key[, desc]) -- a list can finally open in a useful order. Sets the active sort column without a header click; call it before SetData and the first render is already sorted. A nil key clears the sort and restores the model's own order.

    What it replaces is nothing at all. _getSortedData returns self.data verbatim when there is no sortKey, and the only writer of sortKey was the header's OnClick. So the sole mechanism a consumer had for a sorted list was asking the user to click a header, and every list opened in whatever order its model handed over. Dibs' Loot Log opened in the raw append order of a network-merged store while its own module asserted in writing that the tab sorted on time -- and since peers receive in different orders, two players looking at the same data saw different lists.

    It writes the same two fields the header handler does, so the two compose: a later click on the sorted column toggles direction rather than resetting. desc is normalised to a real boolean for exactly that reason -- a nil left in place would make the first click produce descending when the caller had asked for ascending. Consumers on an older copy feature-detect with if rl.SetSort then.

    Peer review finding 14, remedy 1, confirmed from the consumer side and still wanted after Dibs fixed its own model: "a consumer sorting its model to work around a widget gap is a workaround, not a design." Remedy 2 (a total comparator via a tiebreakKey) is deliberately not in this entry -- Dibs withdrew the pressure behind it, because two awards can share a whole-second stamp and differ in nothing the row carries, so only the consumer can supply a genuinely unique key.

  • A dropdown menu can carry a SEARCH box. CreateDropdownBox{ search = true, items = function(query) … }, or OpenMenu/ToggleMenu with opts.search plus opts.itemsFor(query). The box sits above the rows in the root menu only, takes focus on open, and re-runs the items function on every keystroke. Escape closes the stack.

    The library owns the box; the consumer owns what a query means. That split is the whole design: the library cannot know whether agi should match a name, a stat, an item level or a class token, so it never tries — it hands the typed text back and renders whatever comes out. Which also means "top 5 by score" and "…and 30 more" are the consumer's rows, not a feature here.

    Two details that are load-bearing rather than incidental:

    1. The box is built once per pooled menu frame and reused, and _onSearch is re-pointed on every render. Without the indirection a keystroke would re-run the items function captured at the open that first created the box, which for a per-category dropdown is a different category's list.
    2. OnTextChanged gates on the user flag. OpenMenu clears the box on every open, and that programmatic change fires the handler too — reacting to it would render every menu's list twice, once with the query the caller already passed and once with the same empty string.

    Submenus deliberately never get a box: a submenu is the result of a selection in the root, so filtering one would be filtering a filter.

    First consumer is Dibs' Planner > Buffs picker, where a single group is several hundred consumables and the previous answer — cascading sub-menus by primary stat — could not answer "which food gives me agility?" because the item names don't say.

    Peer review finding 5, fixed before release. "The search box is on" was spelled two different ways and they disagreed: the render guard required search and itemsFor, the focus call required search alone. Menu frames are pooled and the box outlives the open that created it, so a later call passing search without itemsFor hid the box and then focused it — keyboard focus on an invisible frame, with OnEscapePressed bound to something that would not receive it. There is now a single hasSearch(opts) used by both. The value is not the branch; it is that the predicate stops being two spellings that can drift apart.

Fixed — MINOR 25

  • RowList has geometry tests for the first time. Three assertions computed from real GetLeft/GetRight rects: the reserved icon strip is sized from the actions surviving construction; no action button overlaps the rightmost column; and a row's cells are separated by a real gap. This library's product is positions and pixels and its suite asserted none, so a layout defect could ship green. Peer review finding 8. No shipped behaviour changed.

    Each was driven RED by mutating the code it guards, and that caught a defect in one of the tests. All three describe properties that are correct by construction, so none could be proven by a real bug. Written first as cellRight <= nextCellLeft, the third stayed green when the inter-column gap was deleted -- losing the gap makes adjacent cells touch rather than overlap, and <= accepts touching. It is a strict < now, and goes red on that mutation with gap 0.0. A geometry assertion that has never been seen to fail may not be able to.

  • Three stale interface versions corrected: Wrath 30403 to 30405, Cata 40400 to 40402, MoP 50503 to 50504. The other five values in the list were already current.

    This matters more on a library than on an addon, and more than "out of date" usually implies. LibAceGUIWidgets is a hard ## Dependencies of Dibs, not an ## OptionalDeps. A hard dependency that the client refuses to load takes the consumer down with it -- so on a Cata client, for a user who has not ticked "Load out of date AddOns", the symptom is not a widget that looks wrong. It is Dibs missing from the AddOn list with nothing saying why.

    Peer review findings 1 and 6. Finding 1 was declined first, correctly: "copying an unverified snapshot into a TOC that five addons share is exactly how the stale value got there. Needs a source, not a transcription." Finding 6 supplied a source -- the suite's own per-flavour TOCs -- and noted its own weakness, that a fleet agreeing with itself could be stale together.

    That weakness is now closed, by a source neither finding used. Every one of these three values is independently shipped by third-party addons on this machine, maintained by people with no connection to this suite: 30405 by DBM, Details, BugSack, BasicMinimap and LibDualSpec; 40402 by DBM, Details, AddonUsage, autograts and BugSack; 50504 by DBM, Details, Bagnon, BasicMinimap and BugSack. Independent agreement across unrelated maintainers is the evidence a shared list could not provide on its own.

  • GroupFrame and TLabel register at widget Version 28, so a declared dependency on this library actually delivers this library's widget. Both sat at 26 -- the same version FastGuildInvite's vendored fork registers them at. AceGUI's guard is if oldVersion and oldVersion >= Version then return end, so equal versions resolve by load order, and load order between two addons with no dependency relationship is something neither can control. A consumer could declare this library, have the loader honour it, and then be handed the fork's widget by the registry. ClearFrame was never affected: it was already 28 and won on the number.

    The bump is to break a tie, not because the frame structure changed -- which is the usual reason to move a widget Version here, so it is called out. All three now sit at 28 together.

    Nobody can currently observe this, and that is stated rather than dressed up: FGI is the only consumer of those two types and it owns the competing copy, so whichever wins is method-identical, and Dibs -- the one addon that declares this library properly -- uses only ClearFrame. It matters the moment a second addon declares this library and creates a TLabel. A prefix was the other option and was rejected: this library's purpose is to BE the shared implementation, and renaming its types would strand the consumers already using them by name.

    Peer review finding 12. Each of the three declarations now carries a comment naming the other registrant and the version rule, because RegisterWidgetType returns nothing and logs nothing -- neither side can detect which registration took effect, so a comment is the only thing that makes the next divergence visible.

    Tests/widgetversion_spec.lua is new and pins it: all three types register, each STRICTLY above the competing copy's 26 (a tie is not good enough -- it resolves by load order, so "equal" and "loses" are the same outcome from a consumer's seat), and all three stay equal to each other so one cannot silently fall behind again. It is the first spec here to load real AceGUI-3.0, because the library's three LibStub("AceGUI-3.0", true) lookups are silent and skip registration entirely without it -- which is why no earlier spec had ever observed a widget Version.

  • The word "stable" is gone from the sort comment and from a spec title, because it was a category error. RowList's comparator was described as having "a stable case-sensitive tiebreak", and a spec was titled "...so the order is stable". A tiebreak is not stability. A tiebreak gives a total order over values that DIFFER; stability preserves input order for values that are EQUAL, and table.sort is quicksort in Lua 5.1 and reorders freely. On exactly equal cells the comparator returns false in both directions and those rows land wherever the sort leaves them -- and two equal numbers return at the numeric branch and never reach the tiebreak at all.

    Why the wrong word is worth a changelog line. A consumer read "stable" as a promise that a pre-sorted order would survive the widget's re-sort. It does not. The comment now says what actually holds, and says that a consumer needing determinism has to supply a genuinely unique key -- the widget cannot invent one, because uniqueness lives in the consumer's entries.

    Peer review finding 11, whose own open question this closes. It asked whether any spec asserted stability, noting that if one did it was "either wrong or testing the tiebreak and named misleadingly". It was the second: the spec is correct and its title was not. Renamed, and a second spec now pins the exactly-equal case so nobody re-reads "tiebreak" as "stable" later.

    The released v0.1.4 entry further down this file still contains the phrase and is LEFT ALONE -- released entries are not edited here. This entry is the correction.

  • A second width-less RowList column is now a construction-time error instead of a column that silently is not there. autoIdx is the FIRST column with no width and both placement chains stop there, then guard on col.width with no else. So any later width-less column got no cell, no header and no anchor, and the populate loop swallowed the miss with if cell then: no error, no log line, and a column the consumer had declared simply absent from the widget. New now raises, naming every offender.

    Raising was chosen over quietly defaulting the extra to zero width, which would draw a column the caller cannot see -- that reads as "no data" rather than as a bug, and sends the next person looking at their data source instead of their column list. The docstring states the rule now as well, since it previously taught width = nil as an ordinary per-column option and never mentioned a limit.

    Checked before shipping a hard error, because turning silence into a raise can break a consumer that was quietly getting away with it: all twelve RowList construction sites in Dibs were read, and every one declares exactly one width-less column. Nothing shipped is affected.

    Peer review finding 10. Driven red first -- construction succeeded with two width-less columns before the change, which is the defect stated as a test.

  • A hole in RowList's actions array no longer crashes the draw. opts.actions was taken verbatim and every read of it used #, which is undefined on a holed table in Lua 5.1. A consumer writing cond and action or nil anywhere but the last slot produced a table whose # reported the full length; the draw loop then handed nil to the icon builder and raised -- out of the public SetData/Refresh, mid-draw, into the consumer's own call site, since nothing in the file protects the path. New now compacts the array once, with table.maxn rather than ipairs, because ipairs stops at the first hole and would drop exactly the entries at risk. Every later #self.actions is honest as a result, the reserved icon strip included.

    A TRAILING nil is not part of this and never was, which corrects how the defect was first described. Assigning nil in a table constructor creates no key, so {a, nil} is indistinguishable from {a} -- to #, to ipairs and to table.maxn alike. A consumer whose last entry is conditional simply passes one action and gets one action. The whole real population is a leading or interior hole.

    This is a documented capability, not a consumer error to be scolded for. show = function(entry) and a nil texture both exist to say "this action may not apply", so reaching for cond and action or nil in the constructor follows the grain of the API. The docstring now says holes are fine, and points at show for per-row visibility, since omitting an action removes it from every row and shrinks the strip.

    Peer review finding 7, raised from the consumer side. It had already shipped at two of Dibs' four RowList call sites, while two others had independently discovered the safe idiom (if x then table.insert(...) end) -- the usual sign that an API's easy path is the wrong one. Fixing it in Dibs protected Dibs; this protects every other consumer. Driven red first: the three hole-exercising specs failed with attempt to index local 'action' (a nil value) before the change.

  • Blank cells no longer lead an ascending sort. RowList's nil ordering was the exact inverse of the two comments beside it: if av == nil then return not desc end returns true ascending, so a nil-valued row sorted before a valued one. The header handler sets ascending whenever you click a new column, so ascending is what a user gets first — meaning the first click of every sortable column floated every empty cell to the top, above all the real data. On an EP-delta column, that is a screen of "n/a" rows.

    The two returns are swapped back. nilsLast is unaffected: a column that opts in still keeps blanks at the bottom in both directions.

    The specs are the part worth reading. Two of them pinned the defect and their titles said so out loud — "currently sorts nils FIRST ascending (comment says last)". That is more honest than silently encoding it, and it was still a decision nobody made, sitting green for weeks: code, comments and tests in three-way disagreement, each reading as corroboration for whichever you looked at first. They now assert the intent, and carry a note saying they used to assert the opposite.

  • The bootstrap uses this library's own EnableVersionCheck. It was hand-rolling the LibStub lookup and Enable — the exact pattern the helper exists to remove, in the addon that ships the helper. Two edges went with the hand-roll: (C_AddOns and C_AddOns.GetAddOnMetadata) or GetAddOnMetadata was then called unguarded, which is a hard error at file scope rather than a missing version on a client with neither spelling; and the or "dev" fallback sorts below every real version while not tripping the dev-build suppression, so such a host would nag against every peer it heard from. The bootstrap now makes no metadata call at all.

  • OpenMenu no longer writes into the caller's options table. It resolved a missing width by assigning it back into the table it was passed, so a consumer holding a module-level options constant and reusing it across anchors of different widths got the first anchor's width baked in permanently. It shallow-copies now. Not reachable through CreateDropdownBox, which builds a fresh table per click — but OpenMenu is public and documented.

Changed — MINOR 25

  • RowList column format now receives (value, entry) instead of (value). Additive and backwards-compatible: every existing function(v) formatter ignores the second argument and behaves identically.

    Why it was needed. RowList sorts on the raw field (entry[col.key]), which is right — it is what makes a numeric column sort 3 before 12 rather than lexically. But the common item cell has to display |Ticon|t |cff…|Hitem:…|h[Name]|h|r and sort on the plain name, and a one-argument formatter cannot reach the icon or the link to build that. So consumers put the display string in the sorted field instead, and the header then sorted by the icon path at the front of it — a sort control that visibly does nothing when clicked.

    Found in Dibs' Loots tab, where the Item column had never sorted. The fix on the consumer side is now { key = "name", format = function(_, e) return e.display end } — the field stays sortable, the cell still renders the icon and the link.

  • RowList:New is declared as RowList.New(_, parent, opts). No call-site change (RowList:New(...) desugars to exactly that); it removes the self shadowing luacheck flags as W412.

Added -- MINOR 24 (named cooldowns: the rate-limited button that counts itself down)

  • Named cooldowns. W:StartCooldown(name, seconds), W:CooldownRemaining(name), W:OnCooldown(name), W:OnCooldownTick(name, fn), W:StopCooldown(name), and W:BindCooldownButton(button, name, opts). Ported from FastGuildInvite's scan cooldown (fn.startScanCooldown in functions.lua, plus the button treatment in GUI/Tabs/Scan.lua's ScanTab.SetCooldown), which is the UX reproduced: while the cooldown runs the caption becomes the seconds remaining, the text dims, the highlight is hidden, and the click is refused at the UI layer rather than fired and bounced somewhere deeper.

    Two things are deliberately not copied from FGI, both because they were bugs there:

    1. FGI decrements a counter per tick, so the number displayed is only as good as the ticker firing — a loading screen or a dropped frame leaves it wrong, or stuck above zero for good. Here the deadline is stamped once and every reader computes the remainder from the clock, so the ticker only repaints. A missed tick shows a stale number for one second instead of permanently.
    2. FGI fans each tick to three named views by hand (setCompactCooldown / setMainScanCooldown / setLegacyCooldown), so adding a fourth view meant editing the driver. A cooldown here is keyed by name and carries a listener list, so a view registers itself and the driver never learns about it.

    FGI's re-entrancy rule is kept: starting a named cooldown that is already running cancels the in-flight ticker first, so two starts never leave two tickers driving one display.

    First consumer is Dibs' loot-roll Resend button, whose rate limit is an officer setting.

    +21 unit tests, including that the remainder is correct when no tick has fired at all.

Changed -- MINOR 24

  • Added a .luacheckrc. Without one, luacheck ran on bare Lua 5.1 and every WoW global — LibStub, CreateFrame, GameTooltip, the font objects, the whole UIDropDownMenu surface — read as an undefined variable: 97 warnings across two files, none of them real. A checker whose output is entirely noise is one nobody reads, which is how a genuine warning gets to hide in it. Both files are now clean.

[v0.1.7] — RowList per-row action visibility & the tab glow actually shows

Fixed

  • W:BrandTabGroup's selected-tab glow never appeared — on any client or flavour. Two independent faults, either of which alone was enough to hide it:
    1. The band anchored to a string. Blizzard's PanelTemplates_SetDisabledTabState assigns tab.text = tab:GetText(), and AceGUI runs that on the selected tab (it's how the active tab is greyed). BrandTabGroup resolved its anchor as tab.text or tab.Text or tab, so it picked up a string; SetPoint then treats that as a global frame name, which resolves to nothing. It failed on exactly the one tab that shows the glow, which is why the feature looked like it did nothing at all rather than looking half-broken. Now resolved through lib.TabLabelRegion(tab), which takes AceGUI's real font string (tab.Text) and rejects anything without a SetPoint, so a future field of the wrong type can't reintroduce it.
    2. Wrong draw layer. The band was created in BACKGROUND, but AceGUI builds each tab's own Left/Middle/Right graphic in BORDER — which draws over BACKGROUND. Even correctly anchored, the band sat behind an opaque tab. Now ARTWORK: above the tab graphic, still below the button's own font string. The mouse-over highlight shared the same anchor and is fixed by the same change. +5 unit tests on the anchor resolver (real font string preferred, a string text ignored, both-fields present, an unanchorable Text rejected, nil-safe). The draw layer is rendering, so it's verified in-game per the suite's "don't unit-test rendering" rule.

Added

  • RowList action show (MINOR 23) — an entry in actions may set show = function(entry) -> boolean to control whether that icon is present on that row. This is the action-side counterpart of the icon column's "return a nil texture to hide the cell" (MINOR 22); actions were the only per-row element with no way to be absent. Why: an action that applies to only a few rows had no good rendering. Greying it via the texture function leaves an icon on every row, which reads as noise when the action is irrelevant to almost all of them (unlike a wishlist coin, where gold/grey is meaningful on each row). Returning a nil texture is worse than it looks: the button keeps its ButtonHilight-Square highlight and its OnClick, so the row shows an invisible-but-hoverable, invisible-but-clickable hitbox. The motivating consumer is Dibs' [Bank] request icon, which is meaningful only on the handful of gear rows a guild banker actually holds. Compatibility: absent show = always visible, so every existing consumer is unaffected — no call sites change. show is re-evaluated on each populate, so a consumer whose gating data arrives late (a sibling addon still initializing its tables) only has to call Refresh. +9 unit tests driving the real _renderRows against recording stub buttons: gating true/false, absent show unchanged, the texture callback skipped entirely on a hidden row, function textures + desaturation still applied when shown, string textures gated too, per-action independence when a row has several, a pooled row re-showing a previously hidden button when repopulated (the bug class that would otherwise leave icons permanently missing after a scroll), rows beyond the data never consulting show, and a missing button slot not erroring.

[v0.1.6] — RowList icon columns & branded tab glow

Added

  • RowList icon columns (MINOR 22) — a column may set icon = function(entry) -> texturePath, desaturated to render a small texture in its cell (a fixed-size, desaturatable texture in a per-cell holder frame) instead of text, resolved per-row from the row's state. Return a nil texture to hide the cell; a truthy second return renders the icon greyed (an "off" state) — so a marker can show gold-when-on / grey-when-off, like the Loots wishlist coin. col.iconSize sets the glyph size (default 14). The column still sorts by entry[col.key], so the consumer supplies a sort value (e.g. 1 present / 0 absent) and the header sorts marker-first/-last like any column (pairs with nilsLast if wanted). Reusable for a wishlist coin, a "won" / standby / priority marker in the DKP/EPGP loot lists, and similar row-state glyphs, so consumers stop hand-rolling per-cell textures. Default behaviour is unchanged for columns that don't set icon. The texture drawing itself is frame rendering, so verified in-game (per the suite's "don't unit-test rendering" rule), but the sortability contract is unit-tested (+1 test: an icon column sorts by its entry[col.key] value — the 1/0 grouping that pulls marked rows together — and the icon renderer is never invoked during a sort).

  • W:BrandTabGroup(tabGroup) (MINOR 21) — brand an AceGUI-3.0 TabGroup with the suite tab look: a soft accent highlight band behind the SELECTED tab's label (bright centre fading to the ends, like a selected menu row) plus a fainter accent mouse-over band on the others. The stock AceGUI selected state only greys and Disable()s the active tab, which is hard to spot; this makes the current tab obvious in the brand accent. Call once, right after AceGUI:Create("TabGroup") — it's idempotent and pure styling: it hooks the widget's own BuildTabs (where tabs are created lazily and recycled) to decorate each tab, and SelectTab to move the glow, never changing which tab is selected or any callback. A no-op on a non-TabGroup table, and feature-detectable (if W.BrandTabGroup then …) so a consumer degrades gracefully on an older library. Frame styling, so it's verified in-game rather than unit-tested (per the suite's "don't unit-test rendering" rule); the accent→RGB parse it uses is exercised by the config tests.

[v0.1.5] — stateful action icons, nils-last sort & an offline test suite

Added

  • RowList column nilsLast sort option (MINOR 20) — a column may set nilsLast = true so rows whose cell for that column is nil/absent always sort to the BOTTOM, in both ascending and descending directions, instead of the default behaviour (nils flip top↔bottom with the sort arrow). For a numeric column that mixes real values with "n/a" — e.g. an EP-delta column where some rows have no EP score — this keeps the no-value rows out of the way regardless of direction while the real values sort numerically. Pairs naturally with the existing col.format display hook: store the raw number in the cell (so the sort is numeric) and format it for display. Default behaviour is unchanged for columns that don't opt in. +2 unit tests.

  • RowList action textures may be a function(entry) (MINOR 19) — a row's right-edge action icon can now reflect that row's STATE, not just a fixed icon. Pass action.texture as a function and it's resolved for each entry on every render (pooled rows), so e.g. a wishlist coin can render gold when the item is listed and grey when it isn't. The function may return (texture, desaturated) — a second truthy value greys the SAME icon, so an on/off state needs only one texture. A plain string texture behaves exactly as before (set once at creation).

  • The shared WoWAPITesting harness, as a submodule at Tests/wowapi — the same offline test environment the rest of the suite uses, so the library's pure logic is verified without a game client. Scaffolding only; no library code changed and no MINOR bump (the shipped API is untouched). Tests are runnerless and local only — no CI test workflow, and none may be added; release.yml stays the repo's only workflow. Run the whole suite from the library root with lua Tests/wowapi/run.lua, which needs only a Lua 5.1 interpreter. busted is not used and must not be installed (the .busted shim is vestigial config, not the entry point). .pkgmeta ignores Tests, so none of it reaches the released zip. A tests.yml workflow was created during the adoption, following the harness README's then-current Step 5, and has been deleted; the README now says the opposite and release.yml is again this repo's only workflow.

  • CLAUDE.md — a "Testing: runnerless, local only" section. The repo had no testing guidance at all. It pins the entry point (lua Tests/wowapi/run.lua from the repo root), forbids using or installing busted and forbids adding any CI test job, requires the whole suite to be run with its real output reported (never a pass count carried forward from another session), and records what is and isn't testable here — including that widget registration against real AceGUI-3.0 does work offline, while construction and rendering don't (see docs/widget-testing-design.md in the harness repo).

  • The Tests/wowapi submodule pin was moved to the harness commit carrying the file-scope-hook fix. Before the bump the suite could not pass — widgets_spec.lua died on load with attempt to index field '?' (a nil value) (20 passed, 1 failed), because its file-scope before_each crashed the older runner. After the bump: 59 passed, 0 failed, observed 2026-07-31.

  • Tests/widgets_spec.lua + Tests/rowlist_spec.lua — 59 specs over every frame-free code path. Beyond the core helpers below, ApplyMinResize (modern SetResizeBounds → legacy SetMinResize → neither, plus AceGUI .frame unwrapping and zero defaults) and AnchorTooltip's auto-flip (consumer tooltipOwner short-circuit, above vs below by room on screen, the tooltipHeight budget, unknown frame top). RowList's frame-free methods are covered too: _getSortedData (numeric strings by value — the v0.1.4 lexical-sort bug — real numbers, number-vs-string, case-insensitive text with a case-sensitive tiebreak, nil handling, descending, and the sorted cache), SetData (offset reset, preserveScroll clamping, nil data, cache invalidation), ClampOffset, and _recomputeVisibleRows (row arithmetic and pool growth).

  • Tests/widgets_spec.lua — the core helpers. Configure / accent get-set (including that a non-table argument is ignored and untouched config keys survive a partial merge); Brand (accent wrapping, tostring coercion, nil → empty); ClassColor across all four resolution paths (consumer override → RAID_CLASS_COLORS → white, plus an override that lacks the class falling through correctly); ResolveVersion (git-tag prefix strip, bare leading v, plain version untouched, the raw LibAceGUIWidgets-v0.1.9 dev token preserved, ? when metadata is missing or empty, and the C_AddOns → bare-global fallback); SearchMatch (empty/nil query, case-insensitivity, every-token-must-match in any order, nil fields skipped and numbers stringified, whitespace runs collapsed, and that the query is matched as literal text — a . or (x86) in the query must not act as a Lua pattern); CreateMenuInfo's notCheckable preset; and the optional EnableVersionCheck / TriggerVersionCheck wiring (string name wrapped into a host with GetName, explicit raw version attached, a host table passed through with its existing Version never overwritten).

  • The widget types themselves (ClearFrame / GroupFrame / TLabel) and RowList's construction and render paths are deliberately not covered: they build real frames and register with AceGUI, so they need the game client. The harness tests logic, not UI.

Known issue (documented; opt-out available via nilsLast)

  • RowList's DEFAULT nil-cell sort order is inverted relative to its own comments. _getSortedData's comparator says -- nils last in ASC / -- nils first in DESC, but if av == nil then return not desc end returns true ascending, so a row with a nil cell sorts before valued rows — nils lead ascending and trail descending, the opposite of the stated intent. The specs pin the shipped default so the suite is truthful, and it's left as the default because swapping the two returns would change visible list ordering for every existing consumer. A column that wants the usual "blanks at the bottom regardless of direction" now sets nilsLast = true (MINOR 20, above) rather than relying on a default change.

[v0.1.4] — version resolver, VersionCheck integration & sort/tooltip fixes

Fixed

  • RowList sorts numeric columns numerically, not lexically (MINOR 18) — the header-click sort only compared numerically when both cells were already type == "number"; a consumer that stored pre-formatted display strings ("3", "12") got a lexical sort, so "3" landed after "12" and an EP / iLvl column came out "all over the place." The comparator now uses tonumber on both sides and orders by value whenever both parse as numbers — so any numeric column sorts right regardless of whether the consumer stored numbers or strings, with no per-column flag to remember. Text columns are unchanged (still case-insensitive lexical with a stable tiebreak); nil still sorts to the end.

Added

  • lib:ResolveVersion(addonName) (MINOR 15) — the addon's DISPLAY version for a status bar / about line, shared across the suite — the SAME thing FastGuildInvite shows: reads the TOC Version (via C_AddOns.GetAddOnMetadata, bare-global fallback) and strips the BigWigs git-tag prefix (Dibs-v0.1.70.1.7, and a leading v). It is the addon version, not the game client version: an unpackaged dev checkout shows the raw LibAceGUIWidgets-v0.1.9 token (the packager replaces it for players). No game-version fallback, no "dev" substitution.

  • Dropdown-box labels no longer wrap out of the box (MINOR 17) — CreateDropdownBox's label FontString is width-constrained between the left edge and the arrow, but never had word-wrap disabled, so a long label ("Import / Export") wrapped onto a second line and spilled out of the 22px box. It now sets SetWordWrap(false) + SetMaxLines(1) — a long label clips cleanly on one line instead.

  • Auto-flipping tooltip placement (MINOR 17) — lib:AnchorTooltip(frame[, tooltipHeight]) (and every AttachTooltip) no longer centers the tooltip over the control with a fixed ANCHOR_TOP (which overlapped the thing you were hovering). It now uses FGI's auto-flip: anchor above (ANCHOR_TOPRIGHT) when there's room, else below (ANCHOR_BOTTOMLEFT) when the frame sits near the top of the screen — corner anchors, so the tooltip sits beside/below the control, never on top of it. A consumer tooltipOwner still overrides.

  • lib:EnableVersionCheck(nameOrHost[, version]) + lib:TriggerVersionCheck() (MINOR 16) — one-call VersionCheck-1.0 integration, now an optional dependency of the library (moved from Dependencies to OptionalDeps in the TOC). VersionCheck is the suite's "a guildmate is running a newer build" awareness (a one-time update nudge); every consuming addon was hand-rolling the same LibStub("VersionCheck-1.0", true) lookup + :Enable(host) wiring (FGI, Dibs, …). EnableVersionCheck does that lookup once and registers the host — accepting either an addon name string or a host object { GetName, Version } — and returns the VC handle (or nil if VC isn't loaded). It is a soft dep: with VersionCheck absent both calls are no-ops, so an addon can ship without it and simply have no update reminder. Pass the raw TOC version (the LibAceGUIWidgets-v0.1.9 sentinel in a dev checkout), not ResolveVersion's display value — VC needs the sentinel to recognise a dev build and suppress the popup.

[v0.1.3] — multi-select menus, settings gear, placement & menu fixes

Added

  • Multi-select cascading menus (MINOR 12). ToggleMenu / OpenMenu now support building a menu where each row's checked may be a function (evaluated live), and with opts.keepOpen = true a leaf click no longer closes the stack and updates that row's checkmark in place — so a menu of toggleable options (class/spec filters, tag pickers, …) works as a true multi-select instead of closing after every pick.
  • opts.point on OpenMenu (MINOR 12) — a placement array { menuPoint, relFrame, relPoint, x, y } overriding where the root opens (default is below the anchor's bottom-left). Lets a consumer drop the menu from a specific corner (e.g. { "TOPRIGHT", frame, "BOTTOMRIGHT", 0, -2 }) instead of directly under a full-width anchor. Combined with opts.width, a menu can be narrow and positioned independently of its anchor.
  • ClearFrame:SetSettingsButton(handler, tipTitle, tipBody) (MINOR 13) — a native settings gear in the window's bottom bar, just left of the info "i" icon (FastGuildInvite's Trade_Engineering icon, same TexCoord crop + -2 hit-rect slop so the whole 20×20 box is clickable). The status box shrinks to make room, so it lines up like FGI's icon row. handler fires on click; the tooltip shows tipTitle + wrapped tipBody; a nil handler hides it. The gear is lifted above the resize strips like the other bottom-row controls (the sizer/border-overlap fix), so consumers get a managed, always-clickable settings button instead of hand-rolling one per addon.
  • RowList action onClick now receives the clicked button as a 4th arg — onClick(entry, idx, rl, btn) (MINOR 14). Backward-compatible (existing handlers ignore it); lets a consumer anchor a menu/popup to the row action it came from (e.g. drop a per-row edit menu from that row's gear icon) instead of a fixed corner.

Fixed

  • Menu rows crowded the top/bottom border (MINOR 14). OpenMenu/ToggleMenu rows started only 6px from the frame edge, but FrameBackdrop's border inset is 8px — so the first and last row's text sat under the border. Rows now use a dedicated MENU_VPAD (12px) top/bottom inset (kept separate from the horizontal MENU_PAD), clearing the border with breathing room; the menu's total height and the cascading-submenu anchor offset track it, so a submenu's first row still lines up with its parent row.
  • Click-outside didn't close the menu on its first open. OpenMenu registered GLOBAL_MOUSE_DOWN via an OnShow script, but renderMenu had already :Show()n the frame before that script was assigned — so the event was never registered on the first open and a click outside couldn't dismiss the menu (later opens happened to work, making it look flaky). The root now registers GLOBAL_MOUSE_DOWN directly at open time (idempotent), with OnHide still unregistering it — so clicking anywhere outside the menu/anchor closes the whole stack from the very first open.
  • Open menu outlived its window / tab. The menu frames live on UIParent at TOOLTIP strata (so they never clip inside the window), which meant they didn't hide when the anchor's window closed or its tab was released — the stack kept floating and reappeared over the next tab you opened. OpenMenu now hooks the anchor's OnHide (once per anchor) and closes the stack when the anchor hides (a window close / tab switch fires OnHide on the anchor as a descendant), only if that anchor still owns the open menu.

[v0.1.2] — expandable list (collapsible datasheet tree)

Added

  • CreateExpandableList(parent, opts) (MINOR 11) — a scrolling datasheet of collapsible groups. Each group is a header row (a +/ toggle glyph + a left label + a right-aligned value) that expands to indented child rows (left label + right value); list:SetData(groups) (re)fills it, where a group is { key?, label, valueText?, children?, defaultExpanded? } and a child is { label, valueText?, color? }. Built on CreateScrollFrame; header and child rows are pooled and reused across SetData calls, and per-group expansion persists (keyed by key, defaulting to label) so a refresh keeps what the user opened/closed. Re-lays-out on resize; list:SetAllExpanded(open) opens/closes everything at once. Factors the EP-breakdown/statsheet tree so any addon in the suite gets a reusable expand/collapse datasheet.

[v0.1.1] — form helpers (dropdown box, dialog, search, cascading menus)

Added

  • Cascading submenus in OpenMenu/ToggleMenu (MINOR 10) — a menu row may now carry children = { …rows… } (a list, or a function returning one, evaluated on hover). Such a row shows a ▶ arrow and opens the next-level submenu anchored to its right; hovering a sibling collapses deeper levels, and selecting a leaf closes the whole stack. Menu frames are pooled per depth, so arbitrarily deep trees reuse a fixed handful of frames. Lets consumers replace a giant flat picker (e.g. every raid/dungeon/zone in one list) with grouped categories. Backward-compatible: rows without children behave exactly as before. The submenu ▶ arrow shows only on the hovered row (inset so it never sits over the menu border), rather than permanently on every parent row.
  • CreateDropdownBox(parent, opts) — a labelled dropdown box: a bordered button with a down-arrow and a text label that opens a ToggleMenu on click. Factors the picker pattern every consuming addon was hand-rolling (loadout / character / event / enchant / spec selectors). opts.items is a function returning the menu rows, evaluated fresh on each open so dynamic lists stay current; opts.width/height/menuWidth/keepOpen size it, opts.tipTitle/tipBody wire an AttachTooltip. Set the shown text via box.label:SetText(...).
  • ShowDialog(parent, opts) — a small prompt/confirm dialog parented into (and raised above) the given frame, so it never hides behind a high-strata window the way Blizzard StaticPopups do. Optional text input (opts.hasEdit), opts.onAccept(value) callback, okText/cancelText / default / prompt. Factors the naming/confirm dialog the consuming addons hand-rolled.
  • CreateSearchBox(parent, opts) — a TSM-style search box (Blizzard SearchBoxTemplate: magnifier icon, "Search" placeholder, clear-X) as a raw frame for manual layouts, with an onChanged(text) callback and optional placeholder/tooltip. (Ported from TOGProfessionMaster's search field, decoupled from its internals.)
  • SearchMatch(query, ...) — the matcher: tokenised, case-insensitive substring search returning true when every whitespace-separated token of query appears in the combined haystack strings (name, source, zone, stat names, …). Empty query matches all; nil fields are skipped. Pure.
  • These register at LibStub MINOR 9 (the dropdown box + dialog at MINOR 7 / 8).

Fixed

  • Dropdown menu appeared "see-through" over window content — the real cause was DRAW ORDER, not backdrop alpha. The shared menu re-parented to its anchor and rendered at the anchor's frame level within the window's FULLSCREEN_DIALOG strata; a RowList's cells in that same window sit at a higher effective level, so their text drew on top of the menu's (opaque) backdrop. The menu now renders at the TOOLTIP strata — above the window entirely — so no window content can overdraw it, at any level. (An opaque dark backdrop colour is still set, as ClearFrame does for itself.)

[v0.1.0] - Initial library

Added

  • LibAceGUIWidgets-1.0 — a shared, embeddable AceGUI-3.0 widget library for the TOG suite, extracted and decoupled from FastGuildInvite's GUI code.
    • Widget types (AceGUI:Create): ClearFrame (movable/resizable window with a DialogBox title bar), GroupFrame (borderless container), TLabel (text + optional icon with multi-line tooltip). Registered with per-type version guards so each installs independently.
    • RowList (W.RowList:New) — virtual-scrolling data list: sortable headers, alternating row banding, class-coloured columns, right-edge action icons, and a hand-built Blizzard-style scrollbar.
    • Helpers: Brand, AttachTooltip, AnchorTooltip, ApplyMinResize, CreateMenuInfo, MakeLabel; shared FrameBackdrop / PaneBackdrop.
    • Addon-agnostic config API (Configure, SetAccent/GetAccent, SetTooltipOwner, ClassColor — falling back to WoW RAID_CLASS_COLORS — plus optional classDisplay / refontHook hooks). All FGI state (FGI.Tooltip.Owner, addon.BrandColor, addon.color, addon.ClassDisplay, addon.FontStringByText, entry.NoLocaleClass) was routed through this API; the class field is the generic entry.classFile.
    • Composes with LibLocaleOverride for tab/dropdown/release helpers rather than duplicating them.
  • ClearFrame persistence (widget v27, lib MINOR 2) — SetStatusTable binds a saved table; the mover/sizer writes top/left/width/height into it and a new ApplyStatus restores them (default centered 700×500). Consumer windows now persist size/position across sessions, and ClearFrame self-positions on open (previously the consumer had to place it or it wouldn't render).
  • ClearFrame bottom status bar (widget v28, lib MINOR 3) — a PaneBackdrop bar with status text (SetStatusText), a Close button, and an info "i" icon (SetInfoTooltip; string or function(GameTooltip)), all lifted above the resize strips so they don't clip (the LiftAboveSizers technique). Replaces addons' border-clipping top-corner close buttons.
  • Scroll frame helper + slimmer RowList scrollbar (lib MINOR 6) — lib:CreateScrollFrame(parent, opts) returns a vertically-scrolling box with a slim thumb scrollbar ({ scroll, content, scrollbar, SetContentHeight }) for arbitrary content that overflows. RowList's own scrollbar was slimmed (12px track, 24px buttons, narrower gutter) to hand a little more width back to the rows.
  • Dropdown menu helper (lib MINOR 5) — lib:OpenMenu(anchor, items, opts) / ToggleMenu / CloseMenu: a reusable anchored menu with check-marked rows (radio-style selection), click-outside / anchor-toggle to close (via GLOBAL_MOUSE_DOWN), rendered at the anchor's window strata so it never hides behind the window. Replaces consumers' hand-rolled dropdowns.
  • RowList row-hover callbacks (lib MINOR 4) — onRowEnter(entry, idx, rl, rowFrame) and onRowLeave(...) opts, symmetric with onRowClick, so a consumer can show a tooltip (e.g. a game item tooltip) while the pointer is over a row. Rows enable mouse when any of click/enter/leave is supplied.
  • Standalone scaffolding — TOC (multi-flavour interface list), vendored LibStub, a VersionCheck-1.0 bootstrap, .pkgmeta, GitHub release workflow, MIT license, markdownlint/luarc config, the wow-version-replication.ps1 dev-sync watcher, and a CurseForge description under docs/.