Skip to content

Writing a component

A component is a function that takes a Ui& and an options struct. There is no base class, nothing to inherit and nothing to register.

Two shapes, depending on whether it has children:

cpp
NodeId    thing(Ui&, args…);   // a leaf: builds and returns
Ui::Scope thing(Ui&, args…);   // a container: the caller fills it

Same name either way. What separates them is the return type: a leaf hands back the node it made and is finished, a container hands back a scope and everything built until that scope dies is inside it.

A worked example

The changes list shows M, A, D or ? beside a path, coloured by what happened to the file.

One component is one header and one source file, named after it — the way Qt gives every widget a file of its own. A new component adds two files and one line to CMakeLists.txt; it does not grow an existing one.

include/gbui/widgets/statusPill.hpp — the file is named for the symbol it declares, in the same case.

cpp
enum class FileStatus { Modified, Added, Deleted, Untracked };

struct StatusPillOptions {
    float size = 18.0f;
};

/** The square status marker beside a path in the changes list. */
NodeId statusPill(Ui& ui, FileStatus status, const StatusPillOptions& options = {});

src/widgets/statusPill.cpp

cpp
namespace {

struct StatusLook {
    Token color;
    std::string_view letter;
};

StatusLook lookFor(FileStatus status) {
    switch (status) {
        case FileStatus::Added:     return {Token::Added, "A"};
        case FileStatus::Deleted:   return {Token::Removed, "D"};
        case FileStatus::Untracked: return {Token::TextMuted, "?"};
        case FileStatus::Modified:  break;
    }
    return {Token::Modified, "M"};
}

}  // namespace

NodeId statusPill(Ui& ui, FileStatus status, const StatusPillOptions& options) {
    const StatusLook look = lookFor(status);

    Style style;
    style.width = options.size;
    style.height = options.size;
    style.justify = Justify::Center;
    style.align = Align::Center;
    style.radius = 4.0f;
    // The letter on a wash of its own colour reads at a glance without needing
    // a second token per status.
    style.background = Fill{look.color, 0.20f};
    style.shrink = 0.0f;      // a fixed marker never gives up its width

    auto scope = ui.scope(style);
    text(ui, look.letter, {.color = look.color, .weight = FontWeight::SemiBold, .size = 11.0f});
    return scope.id();
}

Used:

cpp
auto row = listRow(ui, {.selected = isSelected});
statusPill(ui, FileStatus::Modified);
text(ui, "themes/nord/theme.json", {.grow = 1.0f});

The five rules that example is demonstrating

  1. An options struct, not six parameters. Call sites stay readable, and adding an option later does not break any of them.
  2. Tokens, never colours. A component that hardcodes a colour is a bug report waiting for the first light theme.
  3. shrink = 0 on anything whose size is its meaning — a marker, a pill, a button, an icon. Without it, a crowded row elides it into nothing.
  4. Containers return the Scope; leaves return the NodeId. The caller can then tag it, or find it in a test.
  5. It says what it is. ui.accessible({.role = …, .name = …}) on the node the keyboard lands on, beside the tag and the focusable that already go there. A component that draws itself and never says what it is is a rectangle.

A scope closes where it dies

The one mistake this API makes easy, and it has caused four layout bugs here:

cpp
{
    auto row = ui.row();

    (void)row;          // silences a warning. It is NOT a close.
}                       // <- this is the close

Everything built after (void)row is still a child of that row. The last time it bit, a date picker's two arrows came out different sizes because the second was built inside the month label. When a container has to end before the enclosing block does, put it in braces of its own or call scope.close().

A component that opens two containers and hands back one scope uses adopt()/disown(): the inner scope takes on the extra pop and the outer one is disowned. The other way round leaves the outer popping the moment the component returns, which unbalances the stack and puts everything after it in the wrong parent.

Interactive components take an Interaction

A control that reacts adds two things: the frame it reads, and an id to be known by.

cpp
bool statusFilter(Ui& ui, const Interaction& input, std::string_view id, bool on) {
    auto scope = ui.scope({.cursorHint = Cursor::Pointer});
    ui.tag(id).focusable();

    bool activated = input.clicked(id);
    if (input.isFocused(id)) {
        for (const KeyEvent& event : input.keys())
            if (event.key == Key::Space || event.key == Key::Return) activated = true;
    }
    if (input.isFocusVisible(id)) /* draw the ring with an Outline */;
    return activated;
}

It reports what happened and never writes to the caller's model — which is what makes undo, validation and "are you sure?" possible without the toolkit knowing about any of them.

Shape and sizing come from ui.design() rather than from constants, so the component follows a design switch; anything animated goes through ui.animate with the same id, so it costs nothing when no animator is present.

Where the state lives

Components are stateless functions. Anything that persists — which row is selected, what a field contains, whether a menu is open — belongs to the application and is passed in:

cpp
auto row = listRow(ui, {.selected = state.selected == path,
                        .hovered  = state.hovered == path,
                        .id       = path});

That keeps the toolkit out of the business of owning your model, and it is why the tree can be thrown away and rebuilt without losing anything.

Checklist before it is done

  • Does it say what it is? Every component carries a Role, a name, whatever state and value apply, and the relations that attach it to its label — set with ui.accessible(...), in this commit and not a later one. See a11y. This is the one item on the list that cannot be caught up on afterwards, and tests/accessibilityTest.cpp fails the build for any Tab stop with nothing to announce.
  • Does it re-theme? Load a light theme and look.
  • Does it survive a narrow window? Put it in a row with something that grows.
  • Does it have a header and a source file of its own, both named after it, and is the source listed in CMakeLists.txt?
  • Is it in the umbrella for its group — elements.hpp if it is a primitive, components.hpp if it is composed, or containers.hpp / overlays.hpp? A component nothing includes is not supported, it is an accident — and the umbrella is also what puts it in a group in the documentation and the metadata, so the answer is never "none of them".
  • Is anything it shares with a sibling component in src/widgets/detail.hpp rather than copied? A helper used by exactly one component stays with it.
  • Does it have a test? Layout is pure, so a test is ten lines — see Testing.

Released under the LGPL-3.0-or-later licence.