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;
};
HookCalled
WokenA revision it watches moved, it asked to be woken, or a timer fired; before components run
SizedLayout changed its size. Anything it does in response is laid out in the same update
LaidLayout finished, for a widget that asked with AfterLayout
ValuedIts value changed (value widgets)
Key, Text, WheelThe event reached it, after the handlers its content declared
FocusIt gained or lost keyboard focus
Publish, ScrollBar, ScrollBarToA snapshot is being written; its scroll bar was dragged
LanguageThe string table changed
destructorThe 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 = true adds the value service (Value, BeginEdit, ChangeEdit, CommitEdit, CancelEdit, and the same in the value's own type). TakesText = true adds 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 WidgetDef per kind, generated from the struct. A test fails if UIContext.h names a widget.
  • Built-in widgets use only what an outside widget could use. ui.Widget<T> and WidgetScope<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:

  1. by sheet: the built-in sheet, then others in the order their names were first set, then code (Token, Rule, Set);
  2. 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.
  • GGStyleScopes holds 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; ExplainStyle says where each property came from, scopes included.

Properties

GroupProperties
Boxfill (a colour, or a two-stop gradient with an angle), border, border-width, radius, outline, outline-width, shadow (colour, offset, blur)
Text, inheritedcolor, font, font-size
Text over the scenetext-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
Widgetaccent, knob, trail (a progress bar's trailing fill; unset, the accent at half strength)
Spacingpadding, gap, min-width, min-height
Layoutwidth, height, align, justify
Changetransition

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: fill to 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-out by 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::SetReducedMotion makes every change immediate. The engine sets it from the OS preference at start (Windows only so far); the console's ui.reduced_motion changes 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 GGSheetError gives the line and reason ("no property is called colour").
  • Setting a name again replaces that sheet and keeps its layer position; RemoveSheet removes 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

QuestionDecisionWhy
Sheet formatA small CSS-like text formatSheets are hand-written long before a tool writes them, and the grammar is small because selectors are
Property namesCSS's words where the meaning matches, the engine's where it doesn't (fill, accent, knob)
Partsclass::partReplaces suffix-named classes, each of which the table had to know
Spacing in stylesApplied where the builder set noneA theme has to be able to set density, with one obvious place for each value
Selectors by ancestryNeverA rule's cost and reach must be readable from the rule
What inheritsText properties only
Default looksA built-in sheet compiled into the library