In this post I describe how I designed the styling system for my UI toolkit Milepost. It is a rule-based system, which seems unusual among immediate mode UIs. Those tend to use style stacks, which comes with some limitations on the ability to build and compose reusable and stylable widgets. After giving a bit of context on immediate mode UIs, I discuss those limitations, and then present Milepost’s rule-based styling system design and implementation.
Milepost’s UI lands in the vague category of “immediate-Mode UI” or IMGUI, like DearImgui, microui or nuklear. I say vague because the term “Immediate-Mode UI” is a bit overloaded and seems to denote very different properties to different people, which often results in not-so-interesting definitional quarrels.
To preemptively clear some potential confusion without indulging in those arguments, let’s just say that my personal usage of the expression IMGUI only refers to the design of the API. It means that the UI building code is called each time a new frame is needed, to procedurally define the shape and style of the UI and perform its logic. It does not imply, for instance, that no state is ever retained or that everything is done in one pass.
A number of IMGUI libraries provide a pre-defined set of widgets, like buttons, checkboxes, sliders, scrolling panels, drop-down menus, and so on. This generally means that either you find the widget that suits your needs in that catalog, or you have to build one from the ground up. Moreover, you then have to fit it into the library’s internal systems, that were probably designed only with the limited set of pre-defined widgets in mind.
I’m instead adopting an approach based on a hierarchy of boxes, that can be composed to form complex widgets, and are uniformly processed by the library’s internal systems for input, layout, and drawing. Parts of that processing can be turned on or off based on per-box flags, and parameterized by simple properties attached to the boxes. For example, a box can individually turn on or off the rendering paths for its background, its border, or its text. On top of those, the library can of course still provide helper functions for common widgets. But now extending the repertoire of widgets, and combining existing widgets into more complex ones in a way that still plays well with the library’s systems, is much easier.
I’ve first been made aware of this approach by Ryan Fleury, who mentionned it a number of times in Handmade Network’s Discord server, and has now proceeded to write an entire series on his way of doing UI. Since he’s doing a tremendous job explaining this, I encourage you to check it out and won’t rehash everything here.
Instead, I’ll just describe how style attributes are used in the layout and drawing passes of Milepost’s UI, and then proceed to discuss style stacks as a way of specifying styles in the UI building code. I’ll finally describe how I’m handling styling (and re-styling) in my UI code.
As I said above, the box hierarchy is explicitely re-built each time
a new frame is needed. That could be each time a new input event is
processed, or when a value that’s displayed by the UI changed, or at
regular intervals if the UI needs to animate. The box tree is built
inside a ui_frame()
block.
ui_frame(defaultStyle)
{
// build the UI tree here
ui_box_make("Hello, world", UI_FLAG_DRAW_TEXT);
}
The boxes layout is computed at the end of the frame, when the whole
box tree have been specified. When convenient, the user can then trigger
a drawing pass by calling ui_draw()
.
Milepost draws its UI using a GPU-rendered vector graphics drawing layer, which I wrote about here. Boxes flags control a number of predefined drawing paths, such as filling the box’s background, drawing its border, or rendering its text. In addition, for boxes that have custom rendering needs, the user can still register a callback in which they can use the canvas API to draw the box.
Both the layout and drawing passes are parameterized by per-box style attributes:
typedef struct ui_style
{
ui_box_size size;
ui_layout layout;
ui_box_floating floating;
vec2 floatTarget;
mg_color color;
mg_color bgColor;
mg_color borderColor;
mg_font font;
f32 fontSize;
f32 borderSize;
f32 roundness;
f32 animationTime;
ui_style_mask animationMask;
} ui_style;
The size
attribute specifies symbolic constraints on the
size for both axis. Size constraints can be expressed as the size of the
box’s text, as a number of pixels, as the size of the children, as a
ratio of the parent’s size, or as the parent’s size minus some
pixels:
typedef enum
{
UI_AXIS_X,
UI_AXIS_Y,
UI_AXIS_COUNT
} ui_axis;
typedef enum ui_size_kind
{
UI_SIZE_TEXT,
UI_SIZE_PIXELS,
UI_SIZE_CHILDREN,
UI_SIZE_PARENT,
UI_SIZE_PARENT_MINUS_PIXELS,
} ui_size_kind;
typedef struct ui_size
{
ui_size_kind kind;
f32 value;
f32 relax;
} ui_size;
typedef union ui_box_size
{
struct
{
ui_size width;
ui_size height;
};
ui_size c[UI_AXIS_COUNT];
} ui_box_size;
Using the union trick above, the size for each axis can be accessed
either as .size.width
and .size.height
, or by
indexing into the .size.c
component array, using the
ui_axis
enum.
The layout
attribute parameterizes the layout of
children boxes. This includes the layout axis (vertical or horizontal),
the spacing of boxes along the layout axis, the alignment of boxes along
each axis, and the margins between children boxes and the parent’s
border.
typedef enum
{
UI_ALIGN_START,
UI_ALIGN_END,
UI_ALIGN_CENTER,
} ui_align;
typedef union ui_layout_align
{
struct
{
ui_align x;
ui_align y;
};
ui_align c[UI_AXIS_COUNT];
} ui_layout_align;
typedef struct ui_layout
{
ui_axis axis;
f32 spacing;
union
{
struct
{
f32 x;
f32 y;
};
f32 c[UI_AXIS_COUNT];
} margin;
ui_layout_align align;
} ui_layout;
The floating
attributes determines if the box is
floating (i.e., not taken into account in the normal layout flow) along
each axis. If the box is floating on a given axis, its position is given
by the floatTarget
attribute.
The animationTime
and animationMask
attributes control the animation of other style attributes. When a style
is set for a box, it doesn’t immediately overwrite the current style (as
computed in the last frame) of that box. Instead, the style will be
recomputing by animating between the current style and the new target
style. The animation equation used is a classic RC circuit exponential
discharge, where animationTime
is the 95% fall time. The
set of attributes that are animated in this way is defined by the
combination of flags in animationMask
. Other attributes are
set instantly to their new value.
Styling and layout are decomposed in a number of conceptual passes, some of which can be fused in the implementation:
UI_SIZE_TEXT
or UI_SIZE_PIXELS
constraints.
This pass can be fused with the styling pass.UI_SIZE_CHILDREN
constraints).UI_SIZE_PARENT
or UI_SIZE_PARENT_MINUS_PIXELS
constraints).The size
attribute for each axis specifies a
relax
value, which is the ratio of its own size the box is
willing to “give up” to constraint resolution. The product of the box
size and the relax value can be thought of as the “slack” of the box’s
size. The effective size removed from a child is computed as
follows:
We’ve seen that the layout and drawing passes are parameterized by per-box style attributes. But how are styles specified in the UI building code?
It would be impractical to explicitly specify all style attributes for every box in the tree. IMGUI libraries that have a well-defined set of widgets can declare a huge struct of styles attributes to apply to each specific widget kind, but we can’t do that. We could of course have some default style applied to every box, but since style attributes are so generic and boxes are combined to create all kinds of widgets, this wouldn’t be any help.
A number of IMGUI libraries reduce the burden of individually styling boxes by providing style stacks. A style stack allows temporarily pushing a style to apply to several boxes: When a box is created, its style is initialized with whatever style is currently on the top of the stack. A style stack can also be fragmented into multiple style stacks (e.g. one by attribute) to allow modifying the currently applied style in a more granular fashion.
This way of specifying box styles implicitly along with the building code is very simple (just push and pop attributes), and quite effective, in the sense that it greatly reduces the number of styles to manually specify. It works well because elements specified together or at the same level generally have a lot of common style attributes. You can thus apply a common style to a whole set of downstream boxes, and get selectively more specific as you descend towards specific widgets and sub-elements.
A problem with style stacks is that when you push something into a
style attribute’s stack, you prevent downstream boxes to have that
attribute changed from upstream. You also can’t style one box without
also applying that style to its downstream boxes if they’re not
themselves surrounded by push
/pop
calls.
Of course in a scenario where you’re able (and willing) to control
the specification of every downstream box, this is not an issue: you can
always add the push
/pop
calls where needed.
This becomes more of a problem when you want to write reusable widget
functions. For example, let’s say I’m making a slider()
widget, that’s composed of an outer box for the track, and an inner box
for the thumb, i.e. something arranged as the following:
ui_slider(char* label, f32* sliderValue)
{
ui_container("label") // track
{
ui_box_make("thumb"); // thumb
/* slider's logic here */
}
}
Now, in a stack-based styling system there’s no way to pass a
different style to the track and the thumb from the caller of
ui_slider()
. They will peek at whatever style is on the top
of the stack when the widget function is called.
Since I probably want to at least differentiate the slider and the thumb’s color, I would have to do something like this:
ui_slider(char* label, f32* sliderValue)
{
ui_container("label") // track
{
ui_style_push(/* push the thumb style */);
ui_box_make("thumb"); // thumb
ui_style_pop();
/* slider's logic here */
}
}
Now the track and the thumb can have different styles, but I can’t change the style of the thumb from the outside anymore. It will always get the distinctive style defined inside the widget function. Now you could explicitly pass the thumb’s style as an argument to the slider function, but you can see that when building more complex or composite widgets you would have an explosion of styles to pass downstream.
You can mitigate this limitation, for example by associating flags to each component of a widget, and qualifying each style attributes you push on a stack with a combination of the flags it applies to, e.g
ui_style_push_color(&colorStack, UI_ANY, trackColor);
ui_style_push_color(&colorStack, UI_SLIDER_THUMB, thumbColor);
ui_slider("My slider", &value);
Widgets would then not only peek at the top of the stack, but descend down the stack until they find an attribute that matches their own flags.
This has the drawback of needing to define a (limited) number of flags beforehands. As you compose more complex widgets you would need to add more and more specific flags to allow styling specific sub-components of those widgets.
This also does not fundamentally solve the stack problem, but merely kicks the can down the road. If you compose simple widgets to form more complex widgets, you can’t define default styles for the inner widgets without preventing them from being re-styled from outside the outer widget.
Ideally, we need some system that allows us to define default styles locally for a whole sub-tree, but also allows us to “reach inside” a subtree to select specific inner elements and overwrite their default style. For example, we want to style all buttons with the text “OK” that are children of boxes named “dialog”. Those re-styling actions should themselves be over-writable from upward boxes, and so on.
Since we want to select specific boxs to style in a subtree, and ignore others, we need to specify some kind of “pattern” along with the style to apply. The styling system will then traverse the subtree and apply the style only to boxs that match that pattern. This looks like a rule-based system, but one that is specified procedurally along with the UI builder code.
The stack model can be thought of as applying styling rules defined for a subtree before rules defined in downstream boxs. But we also need to apply some rules on a subtree after all downstream rules have been applied. For each box we can keep two lists of rules, one for rules to be applied before the downstream rules, and one for rules to be applied after downstream rules.
I find it generally practical to specify all styling rules for a box just before making that box, regardless if the rules are to be applied before or after its children. The box to which a rule is added is thus implicit (it is the next box to be built), and the API has one function for each set of rules:
void ui_style_match_before(ui_pattern pattern,
ui_style* style,
ui_style_mask);
void ui_style_match_after(ui_pattern pattern,
ui_style* style,
ui_style_mask);
Instead of having different versions of the functions for each style
attribute type, you pass a pointer to a ui_style
structure
containing all style attributes, and a style mask that indicates which
attributes are to be applied. Using C compound literates, you can set
only the attributes you want to modify, in a pretty convenient way:
ui_style_match_before(pattern,
&(ui_style){.size.width = {UI_SIZE_TEXT},
.size.height = {UI_SIZE_PARENT, 1},
.color = {1, 1, 1, 1},
.bgColor = {0.2, 0.2, 0.2, 1}},
UI_STYLE_SIZE
|UI_STYLE_COLOR
|UI_STYLE_BG_COLOR);
I also found that creating “before” rules that either match the whole next subtree, or only the next box (and not its desendants), was sufficiently common to call for helper functions:
void ui_style_next(ui_style* style, ui_style_mask mask);
void ui_style_next_box(ui_style* style, ui_style_mask mask);
A pattern is the part of a rule that allows selecting boxes to style in a subtree. A pattern is a linked list of selectors, which specifies properties that need to be matched at different levels of the subtree. Those properties can be the text of the box, its unique key identifier, its hovered/active states, etc.
typedef struct ui_pattern
{
list_info list;
} ui_pattern;
typedef struct ui_selector
{
list_elt listElt;
ui_selector_kind kind;
ui_selector_op op;
union
{
str8 string;
ui_tag tag;
ui_key key;
ui_status status;
};
} ui_selector;
The kind of a selector can be one of the following:
UI_SEL_ANY
: matches any box.UI_SEL_OWNER
: matches only the box for which the rule
was defined.UI_SEL_KEY
: matches a box if its unique key is equal to
key
.UI_SEL_TEXT
: matches a box if its text is equal to
string
.UI_SEL_STATUS
: matches a box if its status
(i.e. “hovered”, “active”, “dragging”, etc.) matches the
status
flags.UI_SEL_TAG
: matches a box if it has the tag
tag
. Each box has a list of 64-bit hashes derived from tag
strings, that are passed by the builder code using the
ui_tag_next()
function. I found it quite useful to be able
to tag boxes to define classes of UI elements and match box based on
their belonging to a set of classes.The operator defines how the selector combines with previous
selectors in the pattern. If the operator is
UI_SEL_DESCENDANT
, the selector tries to match a descendant
of the box matched by the previous selector. If the operator is
UI_SEL_AND
, the selector combines with the previous
selector by adding a condition to be matched by the same box.
Patterns can be constructed by pushing selectors to them, using an arena allocator:
ui_pattern pattern = {0};
ui_pattern_push(arena, &pattern,
(ui_selector){.kind = UI_SEL_TEXT,
.string = STR8("foo")});
ui_pattern_push(arena, &pattern,
(ui_selector){.op = UI_SEL_STATUS,
.kind = UI_SEL_TEXT,
.status = UI_HOVER});
ui_pattern_push(arena, &pattern,
(ui_selector){.kind = UI_TAG,
.tag = ui_tag_make("button")});
This pattern will match boxes that have the tag button
and are descendants of a box that is both hovered and has the text
foo
.
The sets of rules that apply to a box are the following:
A simple way to implement the pattern matching mechanism can be derived from the following realization: a pattern is matched by a box if the leading combined selectors are matched by an ancestor, and the rest of the pattern is matched by the box.
This means that at every level of the tree, if the box matches the first selector and all following combined selectors from a rule’s pattern, and there are still selectors left, we can create a new rule with those remaining selectors and pass it downstream. If there are no remaining selectors, the current box matches the pattern. Those derived rules (from the Brzozowski derivative) are inserted in the list just after the rule they are derived from.
Upon returning from a box, rules that were added in that box (either rules defined in that box or derived rules) are removed from the lists of rules.
The algorithm outline is as follows:
check_pattern(box, rule, runList, tmpList)
if head of rule matches box
if rest of rule is empty
apply rule.style to box
else
d = derivation of rule
insert d in runList after rule
push d in tmpList
ui_apply_rules(box, beforeList, afterList)
list tmpBefore = {0}
append box.beforeRules to beforeList and tmpBefore
for rule in beforeList
check_pattern(box, rule, beforeList, tmpBefore)
list tmpAfter = {0}
prepend box.afterRules to afterList and tmpAfter
for rule in afterList
check_pattern(box, rule, afterList, tmpAfter)
for child in box
ui_apply_rules(child, beforeList, afterList)
remove rules in tmpBefore from beforeList
remove rules in tmpAfter from afterList
Style stack are a simple and effective way to specify styles along UI building code in an immediate-mode UI. They are flexible enough if you’re specifying the whole UI with pre-determined styles, but make building re-stylable widgets harder unless you’re willing to pass style parameters to widget functions (or inline and manually re-style them).
In contrast, the rule-based approach I presented allows reaching into a widget’s subtree to selectively re-style its components, while still allowing stack-like semantics when desired. This means one can write helper functions for reusable widgets, while retaining the ability to overwrite the style of deeply-nested components of those widgets from the caller.
Arguably, that approach has a higher complexity budget than a style stack. In fact, uppon reading the words “rule-based styling system”, a few of you might have had a surge of bad feelings. Maybe the letters CSS even crossed you minds. But I do hope I convinced you that a small yet decently expressive rule-based styling system doesn’t have to be that kind of monstrosity. Its API can be expressed as a couple of simple functions instead of a whole declarative language. And its implementation can in fact be quite simple! An arena allocator and a few linked list go a long way.