Immediate mode GUI library in Zig

TODO

Jan 15, 2023

Previous two articles were leading to this moment: writing a UI library from scratch. Why would I do this?

There are a couple reasons:

UIs are extremely challeging and time consuming. It can be often seen in indie (but not only!) games that user interface might look blocky, quickly hacked together and unfriendly. A good illustration of how hard it is to get a UI right is that Battlefield 1 used React and MobX for UI.

Even though UIs are hard, they are possible to create. Best example is a library called Dear ImGui. This is making me both feel huge respect and inspires me to try it on my own, in some very simplified version.

This article is something between a tutorial, a diary and documentation. My goal is to provide people in situation like the one in which I was half year ago, with as much useful context as possible to create their own UI solution.

Architecture

I looked around available immediate mode GUI libraries (Dear Imgui, egui, Nuklear, VUI) and found a common pattern in what I didn't like about them: there was not much customizability, or it was done in a way that didn't spark joy. When writing my own solution I quickly discovered why did it happen: it's hard to give user options for customizing without bloating the API. It's impossible to strike the perfect balance between simple and verbose API.

What I did is I divided code into multiple layers.

  1. Render – rectangles, rounded rectangles, rectangles with background, text.
  2. Layout – where the algorithm from the previous article kicks in and places objects on the screen. This is where layout properties are specified (and styling which is passed on to the render layer).
  3. Controls – (or widgets, or components) UI components like button, text input. Here they already have their styling specified, I only manage their state.

The idea is that each of the layers, especially layout and controls, is equally available to the end user of the code, and it's possible to create new controls using the same building blocks as the library itself.

Rendering

Sometimes it's blended into the rest of the code, but every immediate mode GUI library has a rendering backend. In my case, the backend is in OpenGL and renders a tree of rectangles or text elements. It receives code from the layout function.

Text

I am using SDF rendering.

z-index

As mentioned in the previous article, z-index is not a joke. It breaks a lot of assumption in how the UI is declared and I had a lot of trouble figuring out how to include it without having to rethink the whole library from the beginning.

I tried to come up with some use of defer keyword and clever use of code blocks ({, }) but nothing useful came out of it.

I ended up adding two properties to Frame:

  • z_index: i32 = 0
  • skip_from_layout: bool = false.

The first one is used in the rendering backend. The tree is is traversed in the level order and elements are appended to an array which is then sorted by the z-index. The other one skips the element from being included into children counting such as in dividing space between elements in SPACE_BETWEEN etc.

Built-in controls

The controls level of the library architecture assumes that there's a set of UI controls that allow user of the library to define complex UI with ease. If needed at any point, custom controls can be written using the same API as in the built-in ones, with access to the same API.

Below are the controls and example code how to use them.

Button

An if block which immediately reacts to a click.

if (try controls.button("Click here")) {
    std.log.info("Clicked!", .{});
}

Select

Opens a dropdown with options that closes on click.

// State
var selected: i32 = -1;
var open: bool = false;
const options = [_][]const u8{ "Apple", "Banana", "Cherry" };

// Usage
try controls.select("Fruit", &selected, &open, &options);

Text input

User can type in text, control selection by keyboard or mouse, remove or replace text.

  • SHIFT – selection.
  • CMD/CTRL – jump/select to the other end in specified direction.
  • BACKSPACE – remove to the left (or selection if len > 0).
  • DELETE – remove to the right (or selection if len > 0).
// State
var buffer: [512]u8 = [_]u8{0} ** 512;
var cursor: i32 = 0;
var mark: i32 = 0;

// Usage
try controls.input("Why title tho", buffer[0..], &cursor, &mark);

Checkbox

A simple true/false component.

// State
var state: bool = false;

// Usage
if (try controls.checkbox("Checkbox", state)) {
    state = !state;
}

Radio

Select one of many.

// State
const options = [_][]const u8{ "Apple", "Banana", "Cherry" };
var selected: i32 = -1;

// Usage
for (options) |option, i| {
    if (try controls.radio(option, selected == i)) {
        selected = @intCast(i32, i);
    }
}

Result

const f = try ui.nextFrame();
const layout = f.layout;
var controls = f.controls;

const container = Frame{
    .width = width,
    .height = height,
    .background = colors.GRAY_800,
};

const column = Frame{
    .direction = Direction.COLUMN,
    .padding = Padding.all(8),
    .horizontal_resizing = Resizing.HUG_CONTENT,
    .vertical_resizing = Resizing.HUG_CONTENT,
    .gap = 8,
};

const row = Frame{
    .direction = Direction.ROW,
    .horizontal_resizing = Resizing.HUG_CONTENT,
    .vertical_resizing = Resizing.HUG_CONTENT,
    .alignment = Alignment.CENTER_LEFT,
    .gap = 8,
};

try layout.frame(container, "container");
try layout.frame(column, "column");

if (try controls.button("Hmmm")) {
    std.log.info("Clicked!", .{});
}

for (options) |option, i| {
    try layout.frame(row, "row");
    if (try controls.radio(option, radio_state == i)) {
        radio_state = @intCast(i32, i);
    }
    try layout.text(option, colors.WHITE, 12);
    layout.end();
}

try controls.select("Pick your favorite fruit", &select_state, &open, options[0..]);

if (try controls.checkbox("checkbox", checkbox_state)) {
    checkbox_state = !checkbox_state;
}

try controls.input("Why title tho", input_buffer[0..], &cursor, &mark);

layout.end();
layout.end();

try ui.draw();

Rough edges

Text rendering is not yet there. Character set is limited to a font atlas image and it requires a lot of postprocessing to expand it.

Text selection is working quite decently, but I found some cases where I was able to break it.

Conclusions

It was fun

Newsletter

Sometimes I write blogposts. It doesn’t happen very often or in regular intervals, so subscribing to a newsletter might come in handy.

I promise I will use this one only for sending blogposts and nothing else. If I ever want to organize any broader newsletter, there will be another form.

At the moment there are ... people subscribing.

<- Go to homepage
© Tomasz Czajęcki 2018 – 2022. All Rights Reserved.