LibAceGUIWidgets-v0.1.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
OnMouseDownhandlers 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'sstatustable, which a plain frame does not have.onResizeis 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 hookOnSizeChanged" 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])--ApplyMinResizewith a maximum. Same modern-SetResizeBounds/ Classic-SetMinResizefork, and it only touchesSetMaxResizewhen a maximum was actually given, since a bound of 0 is not the same as no bound.ApplyMinResizeis unchanged and still works.W:GetResizeHandle(frameOrWidget)-- the handle a previousMakeResizableattached, so a consumer can re-point callbacks or readIsResizing()without holding the constructor's return.
Changed -- MINOR 26
ClearFrame builds its grips through the framework rather than beside it. Identical geometry, identical
StartSizingpoints, identical persistence -- the difference is that there is now one implementation instead of two that happen to agree. ItsSetResizablecall and itsSetResizeBounds/SetMinResizefork 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,GroupFrameandTLabel. ClearFrame's frame structure genuinely changed (an extra child frame -- the throttle driver); the other two move with it becauseTests/widgetversion_spec.luarequires 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 ondocs/AUDIT.mdsince 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' ownCLAUDE.md("put a reusable widget in the library") and the fleet rule ("consumers adopt, never author") could not both be satisfied.Tests/HARNESS_CONTRACT.mdis the outbound counterpart and now exists too, carrying two contracts this work raised against the test harness.
Fixed -- MINOR 26
W:AttachTooltipno longer raisesbad 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, andGameTooltip:SetTextis a C function that then raises -- inside anOnEnterhandler, 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. HookScriptAPPENDS 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 an11xerror 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])), plus10 OnEnter handlers were installed by 10 calls. Nine new specs; suite 160 passed / 0 failed.- The guard was
The suite asserts geometry for the first time (peer review finding 8):
Tests/resize_spec.luacomputes grip rects from realGetLeft/GetRight/GetBottomand 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.
_buildHeaderhas 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 beforeSetDataand 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.
_getSortedDatareturnsself.dataverbatim when there is nosortKey, and the only writer ofsortKeywas the header'sOnClick. 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.
descis 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 withif 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) … }, orOpenMenu/ToggleMenuwithopts.searchplusopts.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
agishould 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:
- The box is built once per pooled menu frame and reused, and
_onSearchis 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. OnTextChangedgates on theuserflag.OpenMenuclears 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
searchanditemsFor, the focus call requiredsearchalone. Menu frames are pooled and the box outlives the open that created it, so a later call passingsearchwithoutitemsForhid the box and then focused it — keyboard focus on an invisible frame, withOnEscapePressedbound to something that would not receive it. There is now a singlehasSearch(opts)used by both. The value is not the branch; it is that the predicate stops being two spellings that can drift apart.- The box is built once per pooled menu frame and reused, and
Fixed — MINOR 25
RowListhas geometry tests for the first time. Three assertions computed from realGetLeft/GetRightrects: 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 withgap 0.0. A geometry assertion that has never been seen to fail may not be able to.Three stale interface versions corrected: Wrath
30403to30405, Cata40400to40402, MoP50503to50504. 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.
LibAceGUIWidgetsis a hard## Dependenciesof 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:
30405by DBM, Details, BugSack, BasicMinimap and LibDualSpec;40402by DBM, Details, AddonUsage, autograts and BugSack;50504by DBM, Details, Bagnon, BasicMinimap and BugSack. Independent agreement across unrelated maintainers is the evidence a shared list could not provide on its own.GroupFrameandTLabelregister 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 isif 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.ClearFramewas 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 aTLabel. 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
RegisterWidgetTypereturns 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.luais 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 threeLibStub("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, andtable.sortis 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
RowListcolumn is now a construction-time error instead of a column that silently is not there.autoIdxis the FIRST column with nowidthand both placement chains stop there, then guard oncol.widthwith noelse. So any later width-less column got no cell, no header and no anchor, and the populate loop swallowed the miss withif cell then: no error, no log line, and a column the consumer had declared simply absent from the widget.Newnow 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 = nilas 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
RowListconstruction 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'sactionsarray no longer crashes the draw.opts.actionswas taken verbatim and every read of it used#, which is undefined on a holed table in Lua 5.1. A consumer writingcond and action or nilanywhere 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 publicSetData/Refresh, mid-draw, into the consumer's own call site, since nothing in the file protects the path.Newnow compacts the array once, withtable.maxnrather thanipairs, becauseipairsstops at the first hole and would drop exactly the entries at risk. Every later#self.actionsis 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#, toipairsand totable.maxnalike. 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 niltextureboth exist to say "this action may not apply", so reaching forcond and action or nilin the constructor follows the grain of the API. The docstring now says holes are fine, and points atshowfor 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
RowListcall 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 withattempt 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 endreturns 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.
nilsLastis 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 andEnable— 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 GetAddOnMetadatawas then called unguarded, which is a hard error at file scope rather than a missing version on a client with neither spelling; and theor "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.OpenMenuno longer writes into the caller's options table. It resolved a missingwidthby 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 throughCreateDropdownBox, which builds a fresh table per click — butOpenMenuis public and documented.
Changed — MINOR 25
RowListcolumnformatnow receives(value, entry)instead of(value). Additive and backwards-compatible: every existingfunction(v)formatter ignores the second argument and behaves identically.Why it was needed.
RowListsorts 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|rand 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:Newis declared asRowList.New(_, parent, opts). No call-site change (RowList:New(...)desugars to exactly that); it removes theselfshadowing 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), andW:BindCooldownButton(button, name, opts). Ported from FastGuildInvite's scan cooldown (fn.startScanCooldowninfunctions.lua, plus the button treatment inGUI/Tabs/Scan.lua'sScanTab.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:
- 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.
- 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 wholeUIDropDownMenusurface — 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:- The band anchored to a string. Blizzard's
PanelTemplates_SetDisabledTabStateassignstab.text = tab:GetText(), and AceGUI runs that on the selected tab (it's how the active tab is greyed).BrandTabGroupresolved its anchor astab.text or tab.Text or tab, so it picked up a string;SetPointthen 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 throughlib.TabLabelRegion(tab), which takes AceGUI's real font string (tab.Text) and rejects anything without aSetPoint, so a future field of the wrong type can't reintroduce it. - Wrong draw layer. The band was created in
BACKGROUND, but AceGUI builds each tab's ownLeft/Middle/Rightgraphic inBORDER— which draws overBACKGROUND. Even correctly anchored, the band sat behind an opaque tab. NowARTWORK: 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 stringtextignored, both-fields present, an unanchorableTextrejected, nil-safe). The draw layer is rendering, so it's verified in-game per the suite's "don't unit-test rendering" rule.
- The band anchored to a string. Blizzard's
Added
RowListactionshow(MINOR 23) — an entry inactionsmay setshow = function(entry) -> booleanto control whether that icon is present on that row. This is the action-side counterpart of the icon column's "return aniltexture 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 thetexturefunction 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 aniltexture is worse than it looks: the button keeps itsButtonHilight-Squarehighlight and itsOnClick, 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: absentshow= always visible, so every existing consumer is unaffected — no call sites change.showis re-evaluated on each populate, so a consumer whose gating data arrives late (a sibling addon still initializing its tables) only has to callRefresh. +9 unit tests driving the real_renderRowsagainst recording stub buttons: gating true/false, absentshowunchanged, thetexturecallback 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 consultingshow, and a missing button slot not erroring.
[v0.1.6] — RowList icon columns & branded tab glow
Added
RowListicon columns (MINOR 22) — a column may seticon = function(entry) -> texturePath, desaturatedto 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 aniltexture 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.iconSizesets the glyph size (default 14). The column still sorts byentry[col.key], so the consumer supplies a sort value (e.g.1present /0absent) and the header sorts marker-first/-last like any column (pairs withnilsLastif 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 seticon. 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 itsentry[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.0TabGroupwith 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 andDisable()s the active tab, which is hard to spot; this makes the current tab obvious in the brand accent. Call once, right afterAceGUI:Create("TabGroup")— it's idempotent and pure styling: it hooks the widget's ownBuildTabs(where tabs are created lazily and recycled) to decorate each tab, andSelectTabto 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
RowListcolumnnilsLastsort option (MINOR 20) — a column may setnilsLast = trueso rows whose cell for that column isnil/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 existingcol.formatdisplay 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.RowListaction textures may be afunction(entry)(MINOR 19) — a row's right-edge action icon can now reflect that row's STATE, not just a fixed icon. Passaction.textureas 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.ymlstays the repo's only workflow. Run the whole suite from the library root withlua Tests/wowapi/run.lua, which needs only a Lua 5.1 interpreter.bustedis not used and must not be installed (the.bustedshim is vestigial config, not the entry point)..pkgmetaignoresTests, so none of it reaches the released zip. Atests.ymlworkflow was created during the adoption, following the harness README's then-current Step 5, and has been deleted; the README now says the opposite andrelease.ymlis 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.luafrom the repo root), forbids using or installingbustedand 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 (seedocs/widget-testing-design.mdin the harness repo).The
Tests/wowapisubmodule pin was moved to the harness commit carrying the file-scope-hook fix. Before the bump the suite could not pass —widgets_spec.luadied on load withattempt to index field '?' (a nil value)(20 passed, 1 failed), because its file-scopebefore_eachcrashed 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(modernSetResizeBounds→ legacySetMinResize→ neither, plus AceGUI.frameunwrapping and zero defaults) andAnchorTooltip's auto-flip (consumertooltipOwnershort-circuit, above vs below by room on screen, thetooltipHeightbudget, 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,preserveScrollclamping, 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,tostringcoercion, nil → empty);ClassColoracross 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 leadingv, plain version untouched, the rawLibAceGUIWidgets-v0.1.9dev token preserved,?when metadata is missing or empty, and theC_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'snotCheckablepreset; and the optionalEnableVersionCheck/TriggerVersionCheckwiring (string name wrapped into a host withGetName, explicit raw version attached, a host table passed through with its existingVersionnever 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, butif av == nil then return not desc endreturns 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 setsnilsLast = 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 usestonumberon 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 TOCVersion(viaC_AddOns.GetAddOnMetadata, bare-global fallback) and strips the BigWigs git-tag prefix (Dibs-v0.1.7→0.1.7, and a leadingv). It is the addon version, not the game client version: an unpackaged dev checkout shows the rawLibAceGUIWidgets-v0.1.9token (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 setsSetWordWrap(false)+SetMaxLines(1)— a long label clips cleanly on one line instead.Auto-flipping tooltip placement (MINOR 17) —
lib:AnchorTooltip(frame[, tooltipHeight])(and everyAttachTooltip) no longer centers the tooltip over the control with a fixedANCHOR_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 consumertooltipOwnerstill overrides.lib:EnableVersionCheck(nameOrHost[, version])+lib:TriggerVersionCheck()(MINOR 16) — one-call VersionCheck-1.0 integration, now an optional dependency of the library (moved fromDependenciestoOptionalDepsin 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 sameLibStub("VersionCheck-1.0", true)lookup +:Enable(host)wiring (FGI, Dibs, …).EnableVersionCheckdoes 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 (theLibAceGUIWidgets-v0.1.9sentinel in a dev checkout), notResolveVersion'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/OpenMenunow support building a menu where each row'scheckedmay be a function (evaluated live), and withopts.keepOpen = truea 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.pointonOpenMenu(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 withopts.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'sTrade_Engineeringicon, same TexCoord crop +-2hit-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.handlerfires on click; the tooltip showstipTitle+ wrappedtipBody; 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
onClicknow 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/ToggleMenurows started only 6px from the frame edge, butFrameBackdrop's border inset is 8px — so the first and last row's text sat under the border. Rows now use a dedicatedMENU_VPAD(12px) top/bottom inset (kept separate from the horizontalMENU_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.
OpenMenuregisteredGLOBAL_MOUSE_DOWNvia anOnShowscript, butrenderMenuhad 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 registersGLOBAL_MOUSE_DOWNdirectly at open time (idempotent), withOnHidestill 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
UIParentatTOOLTIPstrata (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.OpenMenunow hooks the anchor'sOnHide(once per anchor) and closes the stack when the anchor hides (a window close / tab switch firesOnHideon 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 onCreateScrollFrame; header and child rows are pooled and reused acrossSetDatacalls, and per-group expansion persists (keyed bykey, defaulting tolabel) 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 carrychildren = { …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 withoutchildrenbehave 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 aToggleMenuon click. Factors the picker pattern every consuming addon was hand-rolling (loadout / character / event / enchant / spec selectors).opts.itemsis a function returning the menu rows, evaluated fresh on each open so dynamic lists stay current;opts.width/height/menuWidth/keepOpensize it,opts.tipTitle/tipBodywire anAttachTooltip. Set the shown text viabox.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 (BlizzardSearchBoxTemplate: magnifier icon, "Search" placeholder, clear-X) as a raw frame for manual layouts, with anonChanged(text)callback and optionalplaceholder/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 ofqueryappears 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_DIALOGstrata; 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 theTOOLTIPstrata — above the window entirely — so no window content can overdraw it, at any level. (An opaque dark backdrop colour is still set, asClearFramedoes 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; sharedFrameBackdrop/PaneBackdrop. - Addon-agnostic config API (
Configure,SetAccent/GetAccent,SetTooltipOwner,ClassColor— falling back to WoWRAID_CLASS_COLORS— plus optionalclassDisplay/refontHookhooks). 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 genericentry.classFile. - Composes with LibLocaleOverride for tab/dropdown/release helpers rather than duplicating them.
- Widget types (
- ClearFrame persistence (widget v27, lib MINOR 2) —
SetStatusTablebinds a saved table; the mover/sizer writes top/left/width/height into it and a newApplyStatusrestores 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 orfunction(GameTooltip)), all lifted above the resize strips so they don't clip (theLiftAboveSizerstechnique). 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 (viaGLOBAL_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)andonRowLeave(...)opts, symmetric withonRowClick, 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, thewow-version-replication.ps1dev-sync watcher, and a CurseForge description underdocs/.
All Relations
- All Relations
- Embedded Library
- Optional Dependency
- Required Dependency
- Tool
- Incompatible
- Include

