UI, Part 5: Visual Content
Looking into how we might specify visual information—both styling and spacing—in a builder codepath.
In this post, I’ll fill in some blanks I’ve skipped, relating to specifying visual content in builder codepaths. The first topic I’ll cover is how we can use the tools we’ve put together to encode spacing in a builder codepath. The second topic I’ll cover will be about how a builder codepath can specify styling information.
Spacing
Spacing is important—it communicates grouping, intent, and can help improve readability and clarity. You should now know how to express interactable or visual portions of a user interface design. How do we, then, express spacing, which is neither visual nor interactable?
In the last part of this series, I reformulated the data structure we use for constructing user interface designs and caching certain aspects of them across multiple frames. Instead of considering that data structure as encoding the very high-level concept of “widgets”, I suggested it’s better to do away with this idea, and instead we should think of each node in the hierarchy as a compositional building block (several of which can be composed to produce the effects of what might be considered a single “widget”).
It doesn’t matter, in some sense, what we call the nodes—these building blocks—in the user interface hierarchy data structure—but it instead matters what they do for us. What actually matters about these nodes is their hierarchical capabilities, their type and how it allows us to encode certain information, and how certain parts of them are cached across frames. So, I switched to calling these nodes as “boxes”, to throw a short, but mostly-arbitrary label on them.
This theme continues with the discussion of spacing.
The first thing I’d like to explain is that spacing often has similar layout characteristics to widgets. In a builder codepath, for any given “space”, you might want to explicitly specify an exact size, or you might want it to fill all of an available area, or to surround a widget (to center it, for example). You also might want it to grow or shrink, depending on the available space for the interface.
If you’ll recall, the simple autolayout algorithm I described earlier in the series is capable of supporting all of these features, but I introduced it originally as being useful for interactable widgets. Now, I’d like to drop the “interactable widgets” part, and suggest that this same feature is usable for spacing as well.
Spacers
As I’ve already gone over, every node in the hierarchy has flags that allow builder codepaths to opt-in to features on a per-node granularity. These flags act as a “codepath mask”.
Because of this structure, we can easily not opt-in to features as well, which is precisely what we can use to express spacing—we can simply construct nodes with no features, other than being a part of the layout pass to introduce spacing. For lack of a better word, let’s call these “spacers”.
To build a spacer, the following is all that is required:
UI_Box *spacer = UI_BoxMakeF(0, "");
And that’s all there is to it. Whatever sizing parameterizations have been specified by the builder codepath will be attached to spacer
, in the same way they would be if you were instead constructing a button, or any other kind of widget.
Note: To make this work properly, you must ensure that an empty string will map to some kind of “null ID”, to prevent the system from attempting to cache anything for spacers (and other identificationless widgets) across frames. This would simply not work, because the system must allow for multiple spacers with the same (empty) ID. Those different spacers must be embedded at different points in the hierarchy. Thus, a single node cannot be used to encode all of those spacers. So, caching should only happen when a node’s ID is non-null.
Importantly, what this means is that you cannot rely on spacer
for any caching behavior. You cannot rely on its rectangle from the previous frame’s layout pass. One alternative approach might be to systematically generate spacer IDs, but this has the familiar issues of uniquely identifying one particular call in a frame across multiple frames, and so it will more-or-less always be an incomplete solution. Instead, I prefer to just generate a spacer with an ID if I need to rely on it. The common case will not be this one—most of the time, you’ll not care to rely on caching behavior for spacers, so leaving them as identificationless is preferable.
This same technique can be used for simple parents in the box hierarchy—notably, for columns and rows that are only used to organize box nodes, and not to be interactable or anything.
Note also that spacers and other identificationless widgets can still have visual information attached—they can still be visual, just not interacted with (interaction requires the keying mechanism).
That leads me into the next topic I wanted to cover in this post—how are these “sizing parameterizations” even specified by a builder codepath?
Style Stacks
Clearly, in some sense, UI_BoxMake
would necessarily be parameterized by some sizing information, as well as some styling information. But, you’ll notice I don’t explicitly pass that information down.
This is for the same reason why I don’t explicitly pass down which parent I’d like the constructed box node to be a child of. If you’ll recall, earlier in the series, the “parent box” was used as a replacement for the idea of a “layout”. In that part of the series, I explained my reasoning for making the “selected layout” an implicit, contextual detail about the codepath. I later extended the idea of a “selected layout” to a layout stack. This later became the parent stack, once the concept of a “layout” had been replaced by just another node in the hierarchy. This is because, most notably, in local regions of a codepath, the parent is usually the same, so it is usually redundant (and annoying for the writer of a builder codepath) to explicitly specify it.
I use this same argument for sizing and styling information. Usually, widgets near each other will be sized similarly or identically. They will also generally have the same text color, background color, border color, and other visual characteristics. So, I prefer having “style stacks”, which allow builder codepaths to control colors and sizing by managing those stacks. These stacks are then “implicit parameterizations” of UI_BoxMake
.
To make this explicit, here’s how that might look.
// red text!
UI_PushTextColor(V4(1, 0, 0, 1));
UI_ButtonF("Red Button A");
UI_ButtonF("Red Button B");
UI_ButtonF("Red Button C");
UI_PopTextColor();
// green background!
UI_PushBackgroundColor(V4(0, 1, 0, 1));
UI_ButtonF("Green Button A");
UI_ButtonF("Green Button B");
UI_ButtonF("Green Button C");
UI_PopBackgroundColor();
// big buttons!
UI_PushPrefWidth(UI_Pct(1.f, 1.f));
UI_ButtonF("Big Button A");
UI_ButtonF("Big Button B");
UI_PopPrefWidth();
A quick aside for those writing a system like this in C: Managing stacks like this in C can be annoying when these stack operations can correspond with a scope of code (sometimes they cannot). For that reason, along with the push and pop APIs (also a “top” API, which peeks whatever the top of the stack is), I use what I call a “defer loop”:
#define UI_DeferLoop(begin, end) for(int _i_ = ((begin), 0); !_i_; _i_ += 1, (end))
This macro uses a hidden for
loop to run begin
before a scope, and end
after a scope, allowing both to be specified in the same location. This can be used to construct something akin to “scope tags” for managing these various stacks:
// helper for managing text color stack
#define UI_TextColor(v) UI_DeferLoop(UI_PushTextColor(v), UI_PopTextColor())
// helper for managing background color stack
#define UI_BackgroundColor(v) UI_DeferLoop(UI_PushBackgroundColor(v), UI_PopBackgroundColor())
With these tools, the example above—with the colored and sized buttons—turns into this:
// red text!
UI_TextColor(V4(1, 0, 0, 1))
{
UI_ButtonF("Red Button A");
UI_ButtonF("Red Button B");
UI_ButtonF("Red Button C");
}
// green background!
UI_BackgroundColor(V4(0, 1, 0, 1))
{
UI_ButtonF("Green Button A");
UI_ButtonF("Green Button B");
UI_ButtonF("Green Button C");
}
// big buttons!
UI_PrefWidth(UI_Pct(1.f, 1.f))
{
UI_ButtonF("Big Button A");
UI_ButtonF("Big Button B");
}
Note: These hidden for
loops are the only exception to my personal “always use braces after a loop” rule. This makes them useful not only to “tag scopes”, but also “tag individual widgets” (e.g. specify a text color on one specific button).
I know, I know—it seems a little weird, and I’m sure there are a number of readers that are incredibly angry with me for advocating for something they might call a “nasty macro trick”. But, in this case, it dramatically improves the experience of writing builder code, and in my opinion it improves readability and maintainability of them.
For those advocating for a C-macro-free-world for various reasons: I’m sorry, but that ship has sailed, and we need to make use of the tools we have.
Application of Spacers: Windowing
One non-obvious application of spacers that I wanted to mention in this post is using them for “windowing”.
“Windowing” is necessary when you have a very large set of widgets that are possibly accessible—for example, a list of 1,000,000 elements—but you don’t want to run code for all 1,000,000 list items on every frame. In this scenario, the user might be able to scroll up or down, eventually viewing all 1,000,000 list items, while only being able to see 100 on the screen at once on any given frame.
There may be other codepaths that need sizing information for the whole list. For example, if the list were also accompanied by a scrollbar, then to calculate the scroller size and position, you’d need to account for any of the 1,000,000 list items that the user has above whatever “window” they’re viewing.
Windowing, in this case, is very easy if you can assume a fixed and well-known size for each list item (in the direction that is scrolled). The math is trivial to calculate the range of viewable list indices:
// Rng1F32 is a 1-D F32 range
// Rng1U64 is a 1-D U64 range
F32 list_item_height = ...; // in pixels
Rng1F32 pixel_range = /* given */;
Rng1U64 index_range =
{
pixel_range.min / list_item_height,
pixel_range.max / list_item_height + 1,
};
This can then be used to build spacers both for the region before the windowed region, and the region after the windowed region:
F32 space_before = index_range.min * list_item_height;
F32 space_after = index_range.max * list_item_height;
UI_Spacer(UI_Pixels(space_before, 1));
for(U64 list_item_idx = index_range.min;
list_item_idx <= index_range.max;
list_item_idx += 1)
{
// make a list item
}
UI_Spacer(UI_Pixels(space_after, 1));
Conclusion
That will conclude this post. At this point, I’m hoping I’ve covered a significant portion of the tools you need for builder codepaths, and how to build those tools at a moderate-enough level-of-detail. Feel free to ask questions about certain topics I’ve missed, or to ask for a higher-level-of-detail explanation about something.
Thanks for reading! More to come soon.