UI, Part 7: Where IMGUI Ends
Exploring strategies for high-level interface instantiation entities (windows, tabs, panels). Making the case for why their state should not be managed by core UI code.
In any application with a user interface, the user manages higher level entities that correspond with a single cohesive user interface design. For example, in your web browser, you directly manage tabs. Tabs are the entities that allow you to spawn and despawn instances of interfaces. You, as the user, will create tabs, and you’ll also close them. You control both the beginning and the end of their lifetime. You’re like the God of Tabs.
Similarly, in common operating system window managers (e.g. those on Windows or MacOS), you manage windows. They are also entities—you spawn them (or, really, you spawn applications that may spawn them. Some applications give you direct control over windows as well), and you close them.
These entities have unique requirements—particularly with regards to state, and their lifetimes—that do not fit patterns I’ve already addressed in previous parts. Let’s explore this new part of the problem, and see how it will look when projected onto the existing architecture of “core” and “builder” code.
IMGUI, Our Core, and State
In Part 2, I covered my reasons for preferring an immediate-mode style API design for a user interface core architecture, and how this style of API connects to the original goals I laid out in Part 1.
One property of an immediate-mode API is that it removes certain stateful concepts from usage code. In this case, builder code no longer needs to manage, individually, the lifetimes of stateful, roughly per-widget objects. With the immediate-mode API I’ve laid out for the core, the core sometimes internally manages state that persists across frames.
That being said, a great deal of complexity can leak into our core implementation if we are not judicious about which state we’re comfortable controlling in the core, and which state we should leave up to builder code. Prematurely stuffing state-management responsibilities into the core can make it unnecessarily more difficult to directly control certain state—which can be very important—from builder code.
This is to say that, at some level, trying to bury certain stateful concepts into the core implementation (which surfaces an immediate-mode API) will ultimately hurt both our core code and our builder code. Making everything immediate-mode is not a goal—an immediate-mode API is simply one design we can use as a tool to further advance towards our actual goal (the correct effects).
In some cases, whether you choose to bury state into the core implementation (as opposed to keeping it in builder code) is an API design decision. But in some cases, the rules of the system will suggest the correct choice.
For example, in this system, core-managed, cross-frame state is generally only considered to persist while the corresponding widget has not yet disappeared. Once a widget has disappeared, its state is considered to be available for other widgets to use, and so that state cannot be relied upon after a widget has disappeared and reappeared. So, provided the state-management mechanisms I’ve laid out thus far for the core, any state that must persist, irrespective of changes to the widget hierarchy, should not be managed by the core.
One example of such state has to do with the entities I’ve introduced.
The Problem With Core-Managed Window State
Windows are a popular entity style. They occupy a sub-rectangle of the screen. The user can drag them around to a custom position, open them, close them, and they may be able to resize them.
A straightforward API, you might think, that naturally follows from the previous posts would look something like:
// starting rect title
UI_WindowF(30, 30, 800, 600, "My Window")
{
UI_ButtonF("X");
UI_ButtonF("Y");
UI_ButtonF("Z");
}
The above API can allow for dragging this window around, and closing this window. The rectangle specified is just referring to the starting rectangle—the user could still reposition and resize it after the fact. This kind of API—with this hidden state management—is, indeed, one of the cleanest looking ones, if you are going to package up some demo code showing off an immediate-mode API, or if you quickly need to get a window on screen to display a developer user interface for debugging a feature in your game engine.
But I am not writing about making a good user interface building system for demo code or debug code, which are design spaces in their own right. This series, on the other hand, is about preparing for production quality, end-user applications. I find the above API inappropriate for this purpose, and I’ll make the argument for why it would be better to have stronger builder-code-control over the stateful aspects of this window.
Imagine the following steps that a user might perform in an application with windows:
User opens window. It begins at its default position and size.
User moves, and resizes window.
User closes window.
User re-opens window.
???
What should the newly-opened window’s rectangle be at step 5?
To answer this question appropriately, you first must intuit what the user will expect, and how the user perceives the “identity” of the window. When the user re-opens the window at step 4, are they opening with “the same window” that they did in steps 1-3? This may seem like a nonsensical question from a programming perspective, but it’s a crucial question for the purpose of interface design. This means, even though it’s not a real “programming question”, it’s a very real problem that we need to address in programming. Programming is not in its own bubble disconnected from humanity—you are not a computer, I am not a computer, your user is not a computer. If you make software like any of that is false, your software sucks!
First, let’s imagine a window in an application that corresponds directly with a “mode”—there can only be one instance of this window, because there is only one “mode” set within the application.
If you follow the principle of “respect user inputs”, then within this case—where there is one unique window for this interface—I’d argue that the window should remain in the location and with the size that the user last specified. In other words, the window’s rectangle should not be reset to the default. This is because the user has not informed us, explicitly, that we should reset the position and size to their original defaults. This is “the same window” to the user, even after it has been closed and re-opened.
In a case where the window does not correspond one-to-one with a “mode” (and the window is simply corresponding to one instance of an indeterminate number of instances), then the answer is less clear. In that case, the “identity” of a window after it has been closed is not necessarily obvious. That being said, in such a case, it may make sense for that window to recycle recently-used window rectangles (from windows that have been closed), instead of using the default repeatedly. That may also be preferable in the case that the user wants to spawn many instances of the same window at once, so that the spawned windows do not all have the same rectangle (and thus overlap entirely).
Furthermore, in any of these cases, it’s very common in an application to have an explicit control that resets window positions and sizes to their defaults, and also to have controls that save or load window layouts from disk (so that the user can reuse their preferences on multiple machines). That layout may also include which windows are open by default.
In all of these cases, you’ll notice that each preferred behavior is unique to the usage of windows, not to the concept of a “window” itself. Because the behavior is so coupled with rules controlled by builder code, in order to provide explicit controls that allow the builder code to program the rule as it sees fit while maintaining generality to all of these cases, the relevant state and operations on it must be provided.
But that additional state management API is not worth it. The builder code nearly always has a state-management strategy already (state for the actual application’s work, which is simply controlled by the user interface—the user interface is providing an interface for the user to control the application work). The state that builder code manages is also, very frequently, mapping one-to-one with important “interface instantiation entities”. So, there is ultimately no reason for this state to be controlled by the core. The builder code will know what is best, and it’ll be more frustrating for the writer of the builder code to try to communicate that to the core instead of just having full control over important state and entity-identity rules.
This approach also addresses the problem of ordering. A very complex solution is required to have, for instance, two immediate-mode-but-secretly-stateful windows specified one after another, yet simultaneously allow them to be reordered (e.g. with a z-index as an additional piece of state). Trying to push this into the core is just a road to destruction—it, similarly, fits perfectly inside of the builder code’s responsibilities.
Ultimately, this leaves our core in a simpler state—it remains a simple machine for “rendering” an interactive hierarchy for a given frame. It also leaves our builder code in a simpler state, because it can do what it needs in accordance with its rules, without needing to project those rules onto the core’s abstractions.
We can still, of course, provide APIs for easily creating the interfaces relating to windows. These APIs would just be another widget “fast-path”, just like UI_Button
. Then, we just leave the decisions regarding whether or not those APIs are called, and in which order, in a given frame, to builder code.
Our builder code, using some helper APIs from the core, can then look something like this:
struct Window
{
Window *next;
Window *prev;
Rect rect;
String8 title;
// ... extra info for which interface is active?
};
struct AppState
{
// doubly-linked list of opened windows
Window *first_window;
Window *last_window;
// singly-linked free-list of closed windows
Window *free_window;
};
Window *WindowOpen(AppState *app_state, Rect rect, String8 title);
void WindowClose(AppState *app_state, Window *window);
void WindowBuildUI(AppState *app_state, Window *window);
void WindowBuildUI(AppState *app_state, Window *window)
{
UI_Window(window->rect, window->title)
{
// build title bar
UI_TitleBar
{
UI_Label(window->title);
UI_Spacer(UI_Pct(1, 0));
if(UI_CloseButton(...).clicked)
{
WindowClose(app_state, window);
}
}
// build contents
{
// build the instantiated interface
}
}
}
Note: You’ll notice that I threw in a call to WindowClose
in the above codepath. Naïve implementations of this call will easily lead to bugs. For example, if WindowBuildUI
were called in a loop over all opened windows, and if WindowClose
immediately pushes a window onto the window free-list, then the frame will miss any windows opened after window
in the linked-list.
For this reason, I strongly recommend defining a single phase within your application frame that controls commands that cause important state mutations (like allocation or deallocation). In that case, WindowClose
would just queue up a command, instead of literally freeing the window immediately.
Another similar, but less generalizable, option would be to just have flags on each Window
, one of which would be set when a window is to be closed (e.g. WindowFlag_MarkedForClose
). Then, at a single place in the frame, look through all open windows and close those that are marked. This is very similar to the first “command buffer” approach I suggested, but the “command” data is just embedded into each window.
I personally recommend the command-buffer approach, because there are other problems that arise like this. It’s always a good idea to have a good story about when important stateful mutations occur.
Another Entity Example: Panels
Panels, sometimes called “tiles” or “panes”, are another paradigm for allowing a user to instantiate interfaces. They also subdivide the screen, like windows. Unlike windows, they cannot overlap, and so they cannot be freely dragged around and resized like windows. The method by which they subdivide the screen is through a hierarchy. At each node of the hierarchy, an n-way split occurs, where n is the number of children. When n is 0, the node is a leaf, in which case there is no split. n should never be 1.
Each node has a few pieces of state to encode this:
An axis, which determines whether or not the split is horizontal or vertical.
The percentage of the parent panel’s size occupied by this panel.
That leaves us with a structure that looks like this:
struct Panel
{
Panel *first;
Panel *last;
Panel *next;
Panel *prev;
Panel *parent;
Axis2 split_axis;
F32 pct_of_parent;
// (any other state to encode view info)
};
The user interface building phase—orchestrated by builder code—can then iterate a panel tree, meant for sub-dividing the available screen space, and build interfaces for each one. For each non-leaf panel, it can build draggable boundaries that allow adjusting the value of pct_of_parent
for children panels. For each leaf panel, it must then build whatever interface is instantiated by the panel in question. That ventures more-or-less strictly into application-specific territory.
Closing Thoughts
As you can see above, none of the above looks very immediate-mode. That is exactly correct for this problem—it’s not immediate-mode for the same reason that state for entities in your game is not immediate-mode. Application code needs to be closely involved with this state, so forcing it under an immediate-mode API will just result in disaster.
Windows and panels are just two simple examples of interface instantiation entities, but they certainly do not form an exhaustive list, and they’re not mutually exclusive either. I mentioned tabs in a browser, for example—those can be gracefully combined with both windows and panels. Windows and panels can simultaneously exist within the same system as well (and they often do). So, instead of thinking of windows and panels as “options”, think of them as vectors into the space of possibilities. As such, you can mix and match pieces of those vectors, and combine them in various ways.
I hope that this post helped clarify confusion regarding the core’s immediate-mode API and its interaction with stateful, application-specific behaviors. Everything I’ve written here is a collection of hard-learned lessons through various mistakes over the years, so in theory I’ve helped somebody out there avoid those same mistakes and get to the lessons more quickly. It all comes down to not becoming too attached to a certain API style (even when it doesn’t apply), keeping in mind what is truly important, and clearly thinking about the state management and data transform capabilities that each layer of your codebase is built to support.
If you enjoyed this post, please consider subscribing. Thanks for reading.
-Ryan