GG-UI — Styling and Widgets
← GG-UI Design | UI Core | Rendering | Interaction | Home
Tokens, rules, style sheets, transitions, and the widget mechanism the built-in widgets are written with. The sheet format itself is specified at the top of UIStyle.h.
Widget Definitions
A widget is a struct. Its members are per-node state; its methods are the hooks it wants. Hooks are detected at compile time, the way a Construct gets a tick by having the method: a missing hook costs nothing, and one with the right name and the wrong signature is a compile error that states the expected signature.
struct LogView { static constexpr std::string_view Name = "LogView"; GGLineSource Source; // state: plain typed members uint64_t Top = 0; using Host = WidgetHost<LogView>; void Woken(Host& host); void Sized(Host& host); bool Key(Host& host, const InputEvent& input); // true: handled bool Wheel(Host& host, float notches); void Publish(const Host& host, GGSnapshot& out) const; bool ScrollBar(const Host& host, float& scrollY, float& contentHeight) const; };
| Hook | Called |
|---|---|
Woken | A revision it watches moved, it asked to be woken, or a timer fired; before components run |
Sized | Layout changed its size. Anything it does in response is laid out in the same update |
Laid | Layout finished, for a widget that asked with AfterLayout |
Valued | Its value changed (value widgets) |
Key, Text, Wheel | The event reached it, after the handlers its content declared |
Focus | It gained or lost keyboard focus |
Publish, ScrollBar, ScrollBarTo | A snapshot is being written; its scroll bar was dragged |
Language | The string table changed |
| destructor | The node was destroyed or became another kind |
WidgetHost<T> is what a hook is handed: the widget's node plus the part of the document a widget may use. It is deliberately not the UIContext.
- Its node: rect, layout inputs, class, state bits;
InvalidatePaint,InvalidateLayout,Rerun(component). Watch(revision),WakeNextUpdate(),WakeAfter(seconds): the only ways it runs without an event. A widget nobody woke does no work, so an idle document is idle whatever widgets it holds.- Text measurement, setting text and classes on nodes it built, the clipboard,
Report(run the author's handler for a reaction),Find<T>(node). - Opt-in services.
EditsValue = trueadds the value service (Value,BeginEdit,ChangeEdit,CommitEdit,CancelEdit, and the same in the value's own type).TakesText = trueadds the text service (SetTextFocus,TextCaret,AskForPaste). On any other widget's host those calls don't compile.
if (auto box = ui.Widget<LogView>(where)) // created on the first build, found again after { box.Layout({ .Width = props.Width, .Height = props.Height, .Overflow = Overflow::Clip }); box.OnPress([](LogView& view, LogView::Host& host, const PointerEvent& event) { view.Pressed(host, event); }); box->Configure(box.Host(), lines, props); }
- The core knows no widget by name. It holds one type-erased
WidgetDefper kind, generated from the struct. A test fails ifUIContext.hnames a widget. - Built-in widgets use only what an outside widget could use.
ui.Widget<T>andWidgetScope<T>are public, as are the part helpers (WidgetPart,WidgetTextPart,WidgetValue,WidgetAccessName). - State is pooled per kind per document, 32 to a chunk, and never moves.
- What stays in the core: popups (child documents), the value-edit phases (a service), focus, scrolling boxes, hover and press.
Not built: a Measure(host, width) → height hook asked inside layout's height pass. Nothing needs it yet; widgets whose content depends on their size are told (Sized) and laid out again in the same update. It is the right tool for a widget whose own height depends on its width with no child to carry it.
Known gap: a widget that moves because something above it moved isn't told, so the IME caret position can be stale until the next edit.
Tokens
A token is a name with a value: a colour, a length, a number.
- Styles refer to tokens rather than repeat values: a button's fill is
surface-raised, not#3B4252. - A theme is a set of token values. Changing them repaints; nothing re-runs.
- A token can alias another.
styles.Token(name, value)in code; an@tokens { }block in a sheet.- Tokens can be set for part of a document:
ui.Tokens({ { "bar-fill", colour } })in an element's content gives them to that element and everything inside it (ScopesScopes
).
Not yet: tokens derived by arithmetic (a colour lightened from another); a font as a token; a theme as a named object to switch between.
Rules
A rule is a selector and the properties it sets.
button every element of class "button" button primary ...that also has class "primary" button:hover ...while hovered button:hover:checked ...in both states slider::knob the knob part of a slider textfield::placeholder the placeholder part of a text field
Selectors
One or more classes, zero or more states (hover, pressed / active, focus, checked, disabled), and at most one part. Nothing else.
- No selecting by ancestry or position. What an element looks like depends on the element, not where it was put. Matching is a table lookup, a rule's cost is the elements carrying its class, and a rule can't reach into a widget it wasn't written for. Inheritance (text), parts, and variant classes cover what ancestry is used for elsewhere.
- Several classes:
.Class = "button primary". One says what it is, the others which kind. - Parts are named by the widget:
textfield::text,::placeholder; a command line's::completions,::completion; a log view's::text,::selected; a menu item's::shortcut,::disabled; a dropdown's::mark. A widget given another class has its parts under that class.
Layering
An element's style is every matching rule applied in order, later over earlier:
- by sheet: the built-in sheet, then others in the order their names were first set, then code (
Token,Rule,Set); - within a sheet: more classes, then higher-priority states, then file order.
A state rule sets only what differs: button:hover { fill: surface-hover } keeps the border and radius. A sheet that sets a button's fill therefore also covers the built-in sheet's hovered fill: it sets its own states, or changes the token and leaves the rule alone.
A style is resolved once per (classes, part, state) and cached until the table changes. Equal styles are one object, so a state no rule mentions is known to change nothing and repaints nothing.
Scopes
A scope is a set of rules and tokens that reaches only the elements built inside it: a widget asset's styles (Widget Assets), the tokens a use gives an instance, and ui.Tokens. An element in a scope carries the scope's class (~StatBar), added by the builder, so its style is still resolved once per class key and cached.
- An element's rules are the table's, then each of its scopes', lowest rank first. A token is the highest scope's that defines it, else the table's; an alias is looked up from the top again, so a scope can change what a token it doesn't define refers to. Inspector overrides still come first.
- Ranks: an asset is above every asset it uses, so what an outer asset says wins; code is above every asset.
GGStyleScopesholds a set: the widget library's (StyleScopes()) and each document's own. A document's look cache (GGLooks::UseScopes) resolves with both, and its revision moves when either changes.UIContext::StyleOf(node)is a node's look as resolved now;ExplainStylesays where each property came from, scopes included.
Properties
| Group | Properties |
|---|---|
| Box | fill (a colour, or a two-stop gradient with an angle), border, border-width, radius, outline, outline-width, shadow (colour, offset, blur) |
| Text, inherited | color, font, font-size |
| Text over the scene | text-outline (width, colour), text-shadow (offset, an optional blur, colour: 1 2 3 #00000099). Each is one more primitive per glyph: the shader grows or blurs it, with more samples only when the outline or blur is wide on screen |
| Widget | accent, knob, trail (a progress bar's trailing fill; unset, the accent at half strength) |
| Spacing | padding, gap, min-width, min-height |
| Layout | width, height, align, justify |
| Change | transition |
background, border-radius, and box-shadow are accepted as aliases. Where a word is CSS's it means what CSS means: padding: 4 10 is vertical then horizontal; 180deg and to bottom run downward.
Spacing and layout apply only where the builder set none. ui.Row({ .Gap = 8 }) is 8 whatever a sheet says; ui.Row({}) in class toolbar takes the sheet's gap. "Set none" is the prop's default value, so a builder can't ask for exactly zero against a class that says otherwise: give it a different class. Spacing follows class, never state. What a style applied is tracked per element (four bits), so a rebuild with the same props isn't a change, and a changed token lays out without building anything.
A button's padding is round its label, which stays centred.
Built-in widgets' own spacing (a text field's padding, a menu row's) is a soft default (LayoutInput::Soft): a rule overrides it, and it comes back when the rule goes. The values stay in the widgets so a document with no sheet still lays out sensibly.
Inheritance. Colour, font, and font size come from the nearest ancestor that sets them. Nothing else inherits.
- Colour is resolved at publish, on the way down the tree, so it follows state: a hovered or disabled row colours the text inside it.
- Font and size are resolved when text is measured, by class and not state: state never changes how text is measured.
Not yet: font-weight and line-height; four-value radius; opacity and cursor as style properties.
In Code
styles.Rule("button:hover", { .Fill = Token("surface-hover") }); // sets only what it names styles.Token("accent", GGColor::Hex(0x88C0D0FF)); styles.Set("demo-glass", { .Fill = ..., .Radius = 12.0f }); // a whole style: every paint property
A selector that can't be parsed sets nothing and reports it.
Box Rendering
- Gradient:
fillto a second colour at an angle, mixed in linear light. The primitive stayed 48 bytes: a solid box has no use for its texture region, so the second colour and direction live there. - Outline: one more box primitive outside the node's own. Nothing is laid out differently for it.
- Shadow: a primitive kind under the box: the same rounded rect, offset, with its edge fading over the blur. Not a Gaussian; cheap, single pass, and close enough at UI sizes.
The fragment shader and the reference rasterizer do the same arithmetic, and the GPU-against-reference test covers all three.
Not yet: more than two gradient stops; radial gradients; several or inset shadows. A node's paint bounds don't include its shadow or outline, so one that extends past its clip is cut off. Popups have no shadow for the same reason: a popup's document is the size of its box.
Transitions
button { transition: fill 80ms, border 80ms, shadow 200ms ease-in-out; }
- Per property group (fill, border, text, accent, knob, radius, outline, shadow): a duration and an easing (
ease-outby default,ease-in-out,linear). The duration used is the target style's. - Paint properties only. A node mid-transition repaints each frame; nothing is built or laid out. When the last one arrives the document is idle again.
- Only nodes whose style names a duration are tracked. Reversed halfway, a transition continues from where it had got to.
- The snapshot carries the interpolated style (
GGSnapshot::Looks). - Time is the document's clock (
AdvanceTime). A sim-bound document advanced by simulation steps transitions identically every time. - Not animated: an element's first appearance, and the styles themselves changing (a theme applies at once).
- A colour fading from or to transparent keeps its hue, so nothing passes through black.
- Reduced motion:
UIHost::makes every change immediate. The engine sets it from the OS preference at start (Windows only so far); the console'sSetReducedMotion ui.reduced_motionchanges it.
Not yet: opacity, spacing, and per-property delays.
Style Sheets
StyleTable::SetSheet(name, text, &error) parses a sheet: an @tokens { } block and rules.
- All or nothing. A sheet is parsed completely before any of it is applied. One that fails changes nothing, and
GGSheetErrorgives the line and reason ("no property is called colour"). - Setting a name again replaces that sheet and keeps its layer position;
RemoveSheetremoves it. - A token's value is the topmost sheet's, so a theme is a sheet of tokens.
The Built-In Sheet
StyleTable::AddDefaults adds BuiltInSheet() as the lowest layer: the built-in widgets' looks as tokens and rules. Each state rule sets every property a lower-priority state might have changed, so an element in two states looks like the higher-priority one.
Files and Hot Reload
UIHost::AddSheetFile(table, name, path) registers a file as a sheet. It is read before the next UI update. While watching (on unless ui.watch_sheets is off), the host checks four times a second for saved files and re-reads them. UISheetFiles does the work and can be used without a host.
- A missing or unparsable file is logged as
path(line): why, the table keeps what it had, and the file is retried on the next save. A half-written file fails the same way and is read again when the save finishes. - Only what changed is redone. Each document caches resolved styles (
GGLooks) and knows when each last became different. A sheet saved unchanged repaints nothing; one colour repaints that class; a token repaints the classes that use it; spacing lays out that class's elements. This holds for every table change, from code or a sheet. - A table is only modified while no document using it is updating. Code that changes one while the engine runs goes through
UIHost::.ChangeStyles
Not yet: file checks run on the UI update thread (they move to a job with the asset system); watching isn't disabled by build type.
The Engine Theme
Trinyx/assets/ui/trinyx.ggss: the engine's colours as tokens (surfaces, edges, text in six strengths, the accent in five, status colours, storage-tier and thread colours), plus what the built-in sheet leaves out (widget transitions, the heading and mono fonts) and the styles of the engine's own tools.
GGUI::StyleTable styles; UITheme::Apply(styles); // the built-in sheet, with the theme over it styles.SetSheet("game", ...); // the game's own, over both UITheme::Watch(host, styles); // development: hot reload the theme's file
The text is compiled in at configure time, so it works without the file. A game layers its own sheet over the theme, or leaves the theme out.
Not yet: the editor still draws with its own UI library; light and high-contrast themes aren't written.
Inspector
The panel under the inspector's tree has three tabs:
- Node: source line, rect, size rules (editable live), state, reactions, bindings, accessible name.
- Style: every property a rule sets for the selected node in its current state, as a sheet would write it, with the token, the rule, and the sheet:
fill #4C566A surface-hover · button:hover · built-in(StyleTable::Explain). - Tokens: every token, editable. A typed value overrides the token in every document using those styles (
StyleTable::OverrideToken). The table itself isn't changed, nothing is saved, and Reset or closing the inspector undoes it.
Overlays: repaints, layout, focus, pointer blocking, and transitions.
Not yet: a colour picker for tokens; writing changes back to the sheet's file; inherited text properties aren't listed under an element; the hint list.
Decisions
| Question | Decision | Why |
|---|---|---|
| Sheet format | A small CSS-like text format | Sheets are hand-written long before a tool writes them, and the grammar is small because selectors are |
| Property names | CSS's words where the meaning matches, the engine's where it doesn't (fill, accent, knob) | |
| Parts | class::part | Replaces suffix-named classes, each of which the table had to know |
| Spacing in styles | Applied where the builder set none | A theme has to be able to set density, with one obvious place for each value |
| Selectors by ancestry | Never | A rule's cost and reach must be readable from the rule |
| What inherits | Text properties only | |
| Default looks | A built-in sheet compiled into the library |