hafen.ui: The Stylesheet
One sheet per addon says what the client looks like. A selector names a rule, and the rule’s properties are setters. Installing the sheet applies them live. To draw your own pixels, use custom and drawing. Nothing here is protected.
local body = hafen.asset():get("fonts/Inter.ttf"):derive():size(12)
local sheet = hafen.ui():sheet()
sheet:rule("*"):font(body) -- the global fallback
sheet:rule("window.title"):font(body:derive():size(14):bold(true))
sheet:rule("chat"):font(hafen.font():get("mono"):derive():size(13)):color{200, 210, 200}
sheet:rule("tooltip"):color{255, 150, 90} -- colour alone: the font stays stock
sheet:install()
Keys says which surfaces a rule reaches and what each honours. surfaces, chat and hud describe the client’s surfaces. text, chrome and geometry are the property pages.
The sheet (unprotected)
| Method |
Returns |
Permission |
Description |
hafen.ui():sheet() |
Sheet |
Unprotected |
Your addon’s one sheet, the same object every call. An argument raises. |
sheet:rule(selector) |
Rule |
Unprotected |
The rule for that key, minted on first use and the same object after. A malformed selector raises as session:ui():match does. |
sheet:load(rules) |
self |
Unprotected |
A whole sheet from data, replacing what the sheet said — below. |
sheet:install() |
self |
Unprotected |
Applies what the sheet says, replacing whatever this addon had installed, whole: a surface the sheet no longer names falls back at once. |
sheet:installed() |
boolean |
Unprotected |
Whether it is applied. |
sheet:release() |
self |
Unprotected |
Stops applying it. Every surface it styled falls back to another addon’s sheet, else to stock. Inert when nothing was installed. |
sheet:rules() |
Rule[] |
Unprotected |
The rules it names, in the order it named them. |
sheet:stock() |
table |
Unprotected |
The client’s own look as data — below. |
sheet:stock(key) |
table \| nil |
Unprotected |
One site’s own look. nil when this client has offered none. |
sheet:info() |
table |
Unprotected |
{installed = …, rules = {selector, …}}, in the order the tie-break uses. |
| Rule |
Detail |
| An edit to an installed sheet applies at once |
There is no re-apply verb: a sheet is in force or it is not. sheet:rule("chat"):release() gives one rule back on the spot. |
| Live and owned |
Existing text re-renders on the spot. The sheet is dropped on :reload and disable, so the stock client is always restorable. |
sheet:rule("chat"):color{200, 210, 200}
sheet:install() -- from here the sheet is the client's look
sheet:rule("tooltip"):color{255, 150, 90} -- lands on the spot
sheet:rule("chat"):release() -- so does giving one rule back
sheet:release() -- everything it styled falls back
A sheet from data
sheet:load(rules) takes a table of ["selector"] = {property = value}, the entry point for a look kept in a file.
local theme = hafen.json():parse(hafen.asset():get("theme.json"):text())
hafen.ui():sheet():load(theme.rules):install()
| Rule |
Detail |
| Every value has a file spelling |
A colour is a table of numbers. A border is a slice of four numbers or a colour and a width. A padding is one or four numbers. An anchor is a corner and an offset. A picture is a path or a resource. A face is {builtin = "mono"} or {asset = "fonts/Inter.ttf"} — text. No handle anywhere. |
| Rules are in selector order |
A keyed Lua table has no order, so a loaded sheet’s rules are sorted by selector. That is the order sheet:info() reports and the equal-specificity tie-break uses, the same on every run. Rules that must be ordered are written with sheet:rule(sel), applied in call order. |
| Property names |
The setter names: font, color, emboss, glow, bg, border, padding, margin, picture, caption, sizer, closeButton, position, anchor, size. An unknown one raises naming those that exist. So does a rule saying both position and anchor. The table is parsed whole before anything is committed, so a malformed rule leaves the sheet as it was. |
| Saving a layout |
Your addon’s: widget:position() reads a table of numbers and hafen.store persists tables. |
The client’s own look
sheet:stock() is what this client draws with when no rule says anything, keyed by site, in the shape sheet:load(rules) takes. Art is named by resource, faces by built-in, colours as the keyed table every reader hands back.
local look = hafen.ui():sheet():stock()
hafen.log():write(hafen.json():encode(look)) -- the whole catalogue, ready for a theme file
hafen.ui():sheet():load(look):install() -- and the client looks exactly as it did
| Rule |
Detail |
sheet:stock(key) |
One site. nil when the client offered none. A key naming no site raises, and so does a tree key: a widget has no look of its own to read back. |
| A site that has not drawn declares nothing |
A surface in a window nobody opened is absent until it is opened. |
| Only what it can say whole |
A surface made of things the vocabulary has no word for is left out. That is a fill inset inside its end caps, a chain of links spread down a bar, a corner following its caption. So what you load back paints the client you saw. |
| Art with no name |
A picture composed in code is left out. Where such a surface is also a flat colour and an outline (the inventory square, a tooltip’s box) it comes back as that. |
| One key, several builds |
A checkbox is built large or small with different art. The catalogue carries whichever drew last. |
| Walked colours |
chat.speaker and chat.urgent come back as the sequences they are — the chat. |
The table holds strings, numbers, booleans and tables only, so hafen.json encodes it and a var keeps it.
Properties
Each setter returns the rule. Each reads back with no argument.
| Method |
Value |
Permission |
Description |
rule:font(face) |
A font handle, or the face named: {builtin = …} / {asset = …} with size, bold, italic, aa |
Unprotected |
text. |
rule:color(c) |
A colour, {200, 210, 220} or {r=, g=, b=[, a=]}. On chat.speaker and chat.urgent the sequence instead |
Unprotected |
text. |
rule:emboss(v) |
false, or {texture = <art>} |
Unprotected |
Whether the client’s relief is cut through a surface’s letters, and with what. false lets color reach a caption — text. |
rule:glow(t) |
{color = {r,g,b[,a]}, radius = n} |
Unprotected |
The halo behind a carved surface’s letters. Radius 0 is none — text. |
rule:bg(t) |
One surface, or an array |
Unprotected |
What something is painted on — chrome. |
rule:border(t) |
{<art>, slice = {l, t, r, b}}, {box = "gfx/hud/wnd"}, or {color = …, width = n} |
Unprotected |
A 9-slice frame, one of the client’s, or a line — chrome. |
rule:padding(n), rule:padding(l, t, r, b) |
Design pixels, >= 0 |
Unprotected |
The room between a frame and its content — chrome. |
rule:margin(n), rule:margin(l, t, r, b) |
Design pixels, >= 0 |
Unprotected |
The room a column keeps around the matched widget. Tree keys and widget:rule() — geometry. |
rule:picture(t) |
One surface, a face per state |
Unprotected |
The whole plate a surface is — chrome. |
rule:caption(t) |
{at =, offset =} |
Unprotected |
Where a window’s title is drawn — ornaments. |
rule:sizer(t) |
A surface with at |
Unprotected |
The corner grip a resizable window draws — ornaments. |
rule:closeButton(t) |
A surface with hover, pressed, at, offset |
Unprotected |
The button that closes a window — ornaments. |
rule:position(x, y) |
Design pixels |
Unprotected |
Where the widget sits in its parent. Tree keys only — geometry. |
rule:anchor(t) |
{to =, at =, offset =} |
Unprotected |
The same place as a relationship — geometry. |
rule:size(w, h) |
Design pixels |
Unprotected |
How big. A window’s content box. Tree keys only — geometry. |
rule:selector() |
string \| nil |
Unprotected |
The key it was named by. nil on a widget’s own level. |
rule:sheet() |
Sheet \| nil |
Unprotected |
The sheet it belongs to. nil on a widget’s own level. |
rule:release() |
self |
Unprotected |
The level stops saying anything. The handle stays usable, and a setter says the level again. |
rule:info() |
table \| nil |
Unprotected |
Every property it sets plus its selector, or nil when it says nothing. The selector is not itself a load value: take the key out before sheet:load. |
| Rule |
Detail |
| Independent |
A rule may carry any one property alone. A colour-only rule keeps the surface’s font. A rule carrying none styles nothing. |
| Refusals |
An unknown property raises naming those that exist. position, anchor, size and margin raise on a key that names a render site: they are about a widget. |
| Method |
Returns |
Permission |
Description |
widget:rule() |
Rule |
Unprotected |
Your level on that widget and everything drawn inside it, the same Rule object a sheet’s selectors hand back. |
widget:rule():info() |
table \| nil |
Unprotected |
Your own level, as written. nil if you have none. |
widget:rule():release() |
Rule |
Unprotected |
Gives your level back. Another addon’s stays. |
widget:style() |
table \| nil |
Unprotected |
What the widget resolves to — { font = <handle>, color = {r=, g=, b=, a=} }, each field present where a level set it — folding your level over every tree rule matching it. nil when nothing names it. |
local hovered = hafen.ui():mouse():over()
hovered:rule():font(body):color{200, 180, 140} -- this widget and all inside it; siblings untouched
hovered:rule():info() -- { font = body, color = {r=200, g=180, b=140, a=255} }
hovered:rule():release()
| Rule |
Detail |
| The whole subtree |
Parents draw before children, so a style on a window reaches its caption, labels, button captions, list rows and widgets created inside it later. A child with a style of its own wins inside itself. |
| The top of the cascade |
It wins the properties it names, text drawn by the game’s own resource code included. |
:style() is the result |
A style inherited from an enclosing widget is applied at the draw, not resolved onto the child. A child inside a styled window reads nil. Site keys are not folded in either. |
| Layout is the verb |
widget:rule():position(…), :size(…) and :anchor(…) raise, naming widget:position(x, y). :margin(…) is legal, no verb spelling it. |
| Chrome |
On a window it dresses that window’s chrome. One level down, a panel’s box, a button’s face or a field’s. Inert on a widget wearing none, still readable through :style(). |
| Owned and weak |
Tagged with your addon, reverted on :reload and disable, held weakly against the widget. It goes when the window closes. A stashed rule then reads nil and writes as a no-op. |
| Scope |
session:ui():root():rule() covers one character’s tree. A sheet’s ["*"] covers the client in every session. An Inventory window has only its caption as text. |
The cascade
Resolution is most-specific first. widget:rule() → the matching tree rule → the matching site rule → the * rule → the widget’s own stock → the client’s stock.
| Rule |
Detail |
| Per property |
A level takes the properties it names and leaves the rest to the level beneath. widget:rule():color(…) on a window whose sheet says rule("*"):font(body) recolours it in body. |
| The bottom two levels |
The client’s surfaces have a stock look a rule beats. A widget an addon built may declare one the same way. That is why a default look is a stock and not a rule of the addon’s own. |
| Layout |
position, size and anchor resolve through the same fold, with the verb widget:position(x, y) as the level above every rule. :position(nil) lands on the rule beneath. Dropping the sheet lands on what the user had. |
| Two addons |
Each surface holds a stack of rules tagged by owner. The last applied wins. Disabling an addon pulls its entries and the surface falls back to the next owner, else stock. |
Where the skinning system ends
The sheet reaches any render site (site keys), any widget a selector names, and any single widget through widget:rule(). It says the text and the surfaces that paint. It says the room inside a frame and around a row, the plate a surface is, and a window’s ornaments. It says where a widget is and how big. Each is per property, live, owned, reversible and loadable from a file. What it does not reach:
| Not a rule |
Why, and what is |
| The inside of a client window |
Rows, columns, tabs and list order are computed by the window’s own code when built. Nothing re-runs that. Rearranging a window’s insides is replacing it. |
| A state in the selector |
No hover, pressed, focused or disabled key: a rule matches what a widget is. A state is part of the value — a bg or picture names a face per state. Your own widgets draw states in Draw. |
| Relationships beyond containment |
A key may be a chain (the space, descendant). There is no >, no pseudo-class, no sibling combinator, no comma list. A rule reaches the matched widget’s whole subtree through the draw pass. |
| Motion |
A rule is a state, not a transition. Installing a sheet moves things in one frame. |
| A decoration over a widget |
A badge, a count, a bar is widget:overlay(): a callback, keyed and clipped, where a rule is a value. |
| A configuration UI |
The client ships the mechanism (layout is data, widget:position() reads it, hafen.store keeps it). An addon ships the editor. |
| The 3D world |
hafen.virtual. |
Text baked at class-load, and $col[…] markup inside a string |
Structural — the key table. |
See Also
- Keys — which surfaces a key reaches, and what each honours.
- Surfaces, Chat, HUD — the client’s surfaces under a rule.
hafen.font — the handles a font property takes.
- Selectors — the grammar every key is written in.
hafen.asset — the fonts and images a rule points at.