Skip to content

Instantly share code, notes, and snippets.

@JustSlavic
Last active June 8, 2023 22:55
Show Gist options
  • Save JustSlavic/fd72162bb22cee7be6b002418ed69062 to your computer and use it in GitHub Desktop.
Save JustSlavic/fd72162bb22cee7be6b002418ed69062 to your computer and use it in GitHub Desktop.

Data oriented UI (part 2)

Last time (Part 1) I finished by drawing a couple of rectangles on the screen. But I missed the bug, so let's fix it. Let's have a look at the code below, can you spot it?

void draw(system *s)
{
    // ...

    for (usize i = 0; i < s->shapes.size(); i++)
    {
        element *e = s->shapes.data() + i;

        auto model = e->transform_to_root;
        auto view = math::matrix::identity();
        auto rect = rectangle2::from_center_size(V2(0, 0), 100, 100);
        draw_rectangle(model, view, projection, rect, e->color); // @todo: implement this with OpenGL
    }
}

I draw all the elements in a forward direction. Here is what it would look like:

Forward draw direction

Bottom elements on top, and top elements on bottom. This is because I am not using Z-buffer to draw the UI. This is how I would expect things to be drawn:

Reversed draw direction

So let's draw them in the reverse order. Good thing that it's really easy to do:

void draw(system *s)
{
    // ...

    for (usize i = s->shapes.size() - 1; i < s->shapes.size(); i--)
    {
        element *e = s->shapes.data() + i;

        auto model = e->transform_to_root;
        auto view = math::matrix::identity();
        auto rect = rectangle2::from_center_size(V2(0, 0), 100, 100);
        draw_rectangle(model, view, projection, rect, e->color); // @todo: implement this with OpenGL
    }
}

Here I take advantage of the fact that when i goes below 0, it wraps around and becomes a huge number. Note that this is not UB (undefined behaviour), wrapping is defined in the standard for unsigned integers.

Hover over element

Normally, events such as hovering and clicking are handled through the event system. Each element sends an event to its parent or child, propagating events through the tree of elements. When an event is received, the element decides whether to send it on or stop and do something.

I find this a bit cumbersome. I want something simpler for my UI library.

I recently watched the talk where Casey Muratori explains the concept of Immediate UI (https://youtu.be/Z1qyvQsjK5Y), here's how he handles hovering and clicking.

Each UI element has the two states it can be in: "hot state", which is when the mouse cursor is over the element, and the user is about to interact with the element, and "active state", which is when the user is actually interacting with the element.

For example, when user hovers over the the button, the button may start blinking inviting the user to click on it (button is hot), and if the button is clicked, the appearance also changes to the clicked button (it becomes active).

A hoverable & clickable component

A button can be made of many things: graphics, text, animations, alpha masks, etc. How do we know what element is responsible for this behaviour? What's the solution for that?

I think of a button as just a container with the specific behaviour: being hoverable and clickable. So any UI group could be a button, as long as it has those components.

Let's make the struct for those components:

struct hover_behaviour
{
    element *owner;
    rectangle2 hover_area;
};

struct click_behaviour
{
    element *owner;
    rectangle2 click_area;
};

struct system
{
    // ...

    array<element> groups;
    array<element> shapes;
    array<hover_behaviour> hoverables;
    array<click_behaviour> clickables;

    element *hot;
    element *active;

    // ...
};

Here I have two new structures to represent the ability of the element to be hovered over and clicked. Let's add them to the element structure.

struct element
{
    // ...

    hover_behaviour *hoverable;
    click_behaviour *clickable;
};

So, if these pointers are equal to NULL, I will consider the element not hoverable or clickable. If they will point to the "behaviour" structure, I will consider them to have that type of behaviour.

Types of responses

There are really two types of response that an element can make after an event occurs: internal and external. Think about it, when the button is clicked, the code that makes an animation should not be interfere with the code that makes a custom user defined response, right? So I will define two callbacks for each of the "events", like this:

typedef void ui_callback(system *, element *);
void ui_callback_stub(system *, element *) {}

struct hover_behaviour
{
    // ...

    ui_callback *on_enter;
    ui_callback *on_leave;

    ui_callback *on_enter_internal;
    ui_callback *on_leave_internal;
};

struct click_behaviour
{
    // ...

    ui_callback *on_press;
    ui_callback *on_release;

    ui_callback *on_press_internal;
    ui_callback *on_release_internal;

Finding the hot element

It's you!

But seriously, it's really easy to do. We do a loop the entire hoverables array, find the mouse position in the local coordinate system of that element, and see if it's inside the hover_area rectangle.

void update(system *s, input_devices *input)
{
    for (usize i = 0; i < s->hoverables.size(); i++)
    {
        hover_behaviour *behaviour = s->hoverables.data() + i;
        element *owner = behaviour->owner;

        auto inverse_transform = math::inverse(owner->transform_to_root);
        auto mouse_position_local = inverse_transform * input->mouse.position;

        if (is_inside(behaviour->hover_area, mouse_position_local))
        {
            s->hot = owner;
            break;
        }
    }
}

But, wait a minute, what if these hover components are stored out of order? Let's look at the possible situation here:

Hoverables without order

Let's imagine that our mouse is placed in the intersection of two bounding boxes, how would we determine that the green triangle is on top of the blue circle? I propose this solution: before deciding which element is hot, let's enumerate all elements with the order index.

It would look like this:

Hoverables in order

But now we can determine which element is higher up in the UI hierarchy. To determine this order index we could run DFS (depth first search) with the l-value reference as the parameter, which will increment at each step.

struct element
{
    uint32 order_index;
// ...


void update_order_index(element *e, uint32& order_index)
{
    e->order_index = order_index++;
    for (usize child_index = 0; child_index < e->children.size; child_index++)
    {
        element *child = e->children.data[child_index];
        update_order_index(child, order_index);
    }
}

void update(system *s, input_devices *input)
{
    uint32 order_index = 0;
    update_order_index(&s->root, order_index);

    // Now we can use e->order_index as the sorting criteria

    element *hovered = NULL;
    for (usize i = 0; i < s->hoverables.size(); i++)
    {
        hover_behaviour *behaviour = s->hoverables.data() + i;
        element *owner = behaviour->owner;

        auto inverse_transform = math::inverse(owner->transform_to_root);
        auto mouse_position_local = inverse_transform * input->mouse.position;

        if (is_inside(behaviour->hover_area, mouse_position_local))
        {
            if ((hovered == NULL) ||
                (owner->order_index < hovered->order_index))
            {
                hovered = owner;
            }
        }
    }

    if (hovered)
    {
        // I found element under mouse! That's good.
        if (s->hot != NULL)
        {
            // There's something hot already, check if it's what I found.
            if (s->hot == hovered)
            {
                // The element I found under the mouse is exactly what I have hot. I will do nothing then.
            }
            else
            {
                // This is new element! Remove hot from old one and make hot a new one!
                make_cold(s, s->hot);
                make_hot(s, hovered);
            }
        }
        else
        {
            // There's nothing hot yet, let's make our element hot.
            make_hot(s, hovered);
        }
    }
    else
    {
        // I didn't find anything under a mouse, so if there's anything hot, I should make it cold.
        if (s->hot) make_cold(s, s->hot);
    }
}

I hope this code is simple enough that anyone can read it, although it looks a bit scary and big because of the way I place curly braces.

The make_hot and make_cold functions just set s->hot to a pointer of an element or NULL. But what if we need to respond to an event?

Responding to entering and leaving hover area

That's easy, we already have two functions that should do that:

hover_behaviour *make_hoverable(system *s, element *e)
{
    hover_behaviour *result = NULL;
    if (e->hoverable == NULL)
    {
        result = s->hoverables.push();
        result->owner = e;
        result->hover_area = math::rectangle2::from_center_size(V2(0), 100, 100);
        // Fill in all callbacks with stubs
        result->on_enter = callback_stub;
        result->on_leave = callback_stub;
        result->on_enter_internal = callback_stub;
        result->on_leave_internal = callback_stub;

        e->hoverable = result;
    }
    // Return pointer so user can fill in callbacks as he wishes
    return result;
}

void make_cold(system *s, element *e)
{
    if (s->hot == e)
    {
        s->hot->hoverable->on_leave_internal(s, s->hot);
        s->hot->hoverable->on_leave(s, s->hot);
        s->hot = NULL;
    }
}

void make_hot(system *s, element *e)
{
    s->hot = e;
    s->hot->hoverable->on_enter(s, s->hot);
    s->hot->hoverable->on_enter_internal(s, s->hot);
}

So now we can make rectangles that change color when the mouse is over them. Let's do that:

    auto group_1 = ui::make_group(&ui, &ui.root);
    auto shape_1 = ui::make_shape(&ui, group_1);
    shape_1->position = V2(500, 600);
    shape_1->rotation = 20.f;
    shape_1->color = V4(0.9, 0.4, 0.2, 1.0);

    auto hoverable_1 = ui::make_hoverable(&ui, shape_1);
    hoverable_1->on_enter_internal = [](ui::system *s, ui::element *e)
    {
        e->color = V4(1, 0, 0, 1);
    };
    hoverable_1->on_leave_internal = [](ui::system *s, ui::element *e)
    {
        e->color = V4(0.9, 0.4, 0.2, 1.0);
    };

And the result is here:

2023-05-30_15-32-34

Making a click

Now, when you see pipepline completely, you can see that adding a click is very easy to do. The first thing that is different, is that we can click only on hot elements, so we need hover_behaviour to make a button. The second difference, that when you release the mouse button, but the active element is no longer hot, we do not register a click, but internally, we should release the button and start internal graphics animation for the button release.

So, the full implementation of our update function will look like this:

void update(system *s, input_devices *input)
{
    uint32 order_index = 0;
    update_order_index(&s->root, order_index);

    element *hovered = NULL;
    for (usize i = 0; i < s->hoverables.size; i++)
    {
        hover_behaviour *hoverable = s->hoverables.data() + i;

        auto inverse_transform = inverse(hoverable->owner->transform_to_root);
        auto mouse_position_local = inverse_transform * input->mouse.position;

        if (math::is_inside(hoverable->hover_area, mouse_position_local.xy))
        {
            if ((hovered == NULL) ||
                (hoverable->owner->order_index < hovered->order_index))
            {
                hovered = hoverable->owner;
            }
        }
    }

    if (hovered)
    {
        if (s->active == NULL)
        {
            // I found element under mouse, and no element is active! That's good, I can set it as hot.
            if (s->hot != NULL)
            {
                // There's something hot already, check if it's what I found.
                if (s->hot == hovered)
                {
                    // The element I found under the mouse is exactly what I have hot. I will do nothing then.
                }
                else
                {
                    // This is new element! Remove hot from old one and make hot a new one!
                    make_cold(s, s->hot);
                    make_hot(s, hovered);
                }
            }
            else
            {
                // There's nothing hot yet, let's make our element hot.
                make_hot(s, hovered);
            }
        }
        else
        {
            // @todo: When this happens?
        }
    }
    else
    {
        if (s->hot)
        {
            // I didn't find anything under the mouse, but I have a hot element? Make it cold again.
            make_cold(s, s->hot);
        }
    }

    if (get_press_count(inp->mouse[mouse_device::LMB]))
    {
        s->active = s->hot;
        if (s->active)
        {
            if (s->active->clickable)
            {
                s->active->clickable->on_press_internal(s, s->active);
                s->active->clickable->on_press(s, s->active);
            }
        }
    }

    if (get_release_count(inp->mouse[mouse_device::LMB]))
    {
        if (s->active)
        {
            if (s->active->clickable)
            {
                if (s->active == s->hot)
                {
                    s->active->clickable->on_release(s, s->active);
                }
                s->active->clickable->on_release_internal(s, s->active);
            }
            s->active = NULL;
        }
    }

    // @note: This should be applied each frame after update phase, right?
    update_transforms(s);
}

If you want to know what are the get_press_count and get_release_count functions, I can refer you to this little note of mine: Data oriented input in games.

Conclusion

I hope this has been easy to follow. Although it took some time to explain the algorithm, this code is very simple at its core. I didn't use any OOP of fancy "Modern C++" features, only simplest code I could imagine.

But you can do whatever you want with your code, and use any modern features you like, for example:

  • Use std::function to store callbacks, so you could store capturing lambdas there;
  • Use smart pointers where I used raw pointers, it's your choice. I use raw pointers hovewer because I do not use heap for storing anything in the UI;
  • Cache values, like recompute order index only when order of elements is changed, elements are added or deleted;

Positive aspects of such an approach are:

  • The processor does not do busy work by moving events all over every frame;
  • Implementation is very simple;

Negative sides are:

  • It's hard to add new types of behaviours on the user side for now. But even with that limitation, practically any UI element could be implemented using those two behaviours.

In the next part I will make a query mechanism and try to separate shapes from groups, so stay tuned!

References

  1. Immediate-Mode Graphical User Interfaces - 2005: https://youtu.be/Z1qyvQsjK5Y
  2. Data oriented input in games: https://gist.github.com/JustSlavic/0f3570dec2d738aa6a4b624c7fed5000
@JustSlavic
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment