Journey into the Terminal: Building the pygamelib UI Module (part 2)

Journey into the Terminal: Building the pygamelib UI Module (part 2)

2023: A Coding Odyssey

·

9 min read

Introduction

In the previous episode, we concluded with the implementation of the Widget class and numerous unanswered questions. Many of these questions revolve around the automatic management of multiple widgets.

In this installment, I will discuss the development of the Layout system.

Initially, I intended for this entry to be the final one, summarizing previous developments. However, due to falling quite ill this week, I was unable to edit the vast amount of material I have on the topic. As a result, I plan to publish one more entry before catching up to real-time. Sorry about that.

The state of things

At this point, we have a functional foundation for a widget with an effective size management system.

Here's a small program I used to test it:

from pygamelib.engine import Game
from pygamelib.gfx.ui import (
    Widget,
    UiConfig,
)
from pygamelib.constants import (
    EngineConstant,
    EngineMode,
)


def uu(g: Game, k, dt: float):
    # Get the widget (because we know where it is, obviously...)
    w: Widget = g.screen.get(1, 1)

    # Delete potential labels
    g.screen.delete(0, 1 + round(w.width / 2))
    g.screen.delete(1 + round(w.height / 2), 2 + w.width)

    # Handle key stroke to change the widget size
    if k == "Q" or k.name == "KEY_ESCAPE":
        g.stop()
    elif k == "H":
        w.height += 1
    elif k == "h":
        w.height -= 1
    elif k == "W":
        w.width += 1
    elif k == "w":
        w.width -= 1
    elif k == "d":
        w.width -= 1
        w.height -= 1
    elif k == "D":
        w.width += 1
        w.height += 1

    # Add the current widget's dimensions to the screen
    g.screen.place(f"{w.width}", 0, 1 + round(w.width / 2))
    g.screen.place(f"{w.height}", 1 + round(w.height / 2), 2 + w.width)

    # Update the screen
    g.screen.update()


if __name__ == "__main__":
    # Create the instances of the Game and the unified UiConfig
    g = Game(
        mode=EngineMode.MODE_REAL_TIME,
        player=EngineConstant.NO_PLAYER,
        user_update=uu,
    )
    config = UiConfig.instance(game=g)

    # Create a widget with
    w = Widget(30, 15, 10, 4, 60, 40, config=config)

    # Place the widget on screen
    g.screen.place(w, 1, 1)

    # Run the event loop
    g.run()

The results are mindblowing: it's a square...

But, as you can test yourself, a square with functional size management.

As observed last week, we soon began asking questions about positioning, automatic size management, and so on. Having experience with various UI frameworks (such as Qt, GTK, and ImGui), I understand that if we have a widget system, we also need a layout system. So, let's tackle that beast!

Layouts: where problems begin!

The problem

In any decent UI framework, you will find a concept of layout. These layouts are incredibly practical, as they enable much faster development of UI projects by handling many of the geometry calculations needed in various situations.

If you recall last week's installment, my goal is to solve the problem independently. I'm not interested in examining existing code, as the challenge itself is my objective. However, I also mentioned that I would loosely base my UI toolkit on Qt.

There are two reasons for this. First, I have extensive experience coding with Qt. As a result, whether I like it or not, its design philosophy will influence me. Second, I believe that Qt's architecture is the most sensible among UI toolkits. However, that is the extent of the inspiration. Drawing inspiration from the implementation itself wouldn't make much sense, as Qt is a much larger and more sophisticated framework.

That being said, the layout system must be capable of managing the standard operations of a typical layout system. That means:

  • Managing sub-widgets (adding/removing widgets to and from the layout)

  • Keeping count of widgets in the layout.

  • Managing geometry

  • Enforcing size constraints

  • Guaranteeing the access to the list of managed widgets.

That is the base set of core features that all layouts will share.

Identifying the key components

For the initial implementation of the pygamelib's UI framework, I am focusing on a select few practical layouts. I prefer having a concise list of functional and useful layouts rather than an extensive list of partially implemented ones.

So, we will have:

  • Layout: The foundational (mostly virtual) class that establishes the basic API.

  • BoxLayout: A layout that presents all widgets in a list, either horizontally or vertically.

  • GridLayout: A layout designed to arrange all widgets in a grid format (quite unexpected, isn't it?)

  • FormLayout: a layout to quickly create a form-type complex widget. It will organize the widgets one by line and each line has a label (a line/row is composed of a label and a widget).

Let's get coding!

Creating a basic Layout class

For the Layout class, I went with something very simple. It is an instantiable object (as Python does not really support pure virtual classes), but only a handful of properties are implemented (as it should be the same requirements for all the layouts). We have:

  • parent [property]: store and return the parent widget.

  • spacing [property]: store and return the spacing between widgets. Note that some layouts may need more detailed spacing information (like vertical or horizontal) but again: this is the bare minimum.

  • and, obviously, a basic constructor.

For the virtual methods, we need to think a little about what is common to all the layouts. Quite simply, all layouts will need the ability to:

  • Add widgets.

  • Remove widgets.

  • Count how many widgets they are managing.

  • Return a list of widgets that they are managing.

  • Return their total height (including spacing and potential padding).

  • Return their total width (also including spacing and potential padding).

  • Render to the frame buffer.

Padding is not supported or even on the roadmap for the first version. But it'll come!

Now as I write that and look at the code, I realize that there is no Layout.remove_widget(w: Widget) method in Layout... That is a problem. But I know why it's like that: it's because it feels useless!

Let me elaborate. In most UI frameworks, there is usually some sort of remove function that allows you to remove a widget based on its pointer or reference. Interestingly, I have never actually used it!

In a box or form layout, I use the widget's index. In a grid layout, I use the row and column coordinates. I understand that I shouldn't impose my own practices on other programmers, but it seems even more pointless than this entire project! Why? Because, obtaining a widget's reference when a user clicks on it is simple, yet maintaining the focus stack in a keyboard-based terminal interface will inevitably lead to using indexes or coordinates.

Nonetheless, I will add a TODO note to implement a virtual method in Layout.

The documentation is already available on Readthedocs: pygamelib.gfx.ui.Layout.

The BoxLayout class

Same as Layout, the documentation is already available: pygamelib.gfx.ui.BoxLayout.

I'm sure that I don't need to be explicit about that but, I also mean that the code is on GitHub.

The BoxLayout is quite simple to conceptualize: it essentially consists of a list of widgets. These widgets are rendered either horizontally or vertically. Unsurprisingly, I initially believed that implementing it would be a walk in the park...

To be fair, the first iteration was easy: add widgets, render them vertically. Easy, peasy. Then, take into consideration the spacing. Not very difficult.

Then things started to go south... Adding orientation management is not that hard, but it is taxing to check on the orientation every frame. I came up with a solution that, for the moment, feels like the least of many evils.

I maintain 2 offset variables during the rendering loop: one for the row and one for the column (the terminal coordinate system), and I increase only the correct one depending on the orientation property. It limits the tests to just one if. I then simply render the widget at the coordinate plus the offset. It is basic geometry but it works fine.

Adding size constraints becomes more interesting. At first, and it is the current implementation, I just deferred all of the work to Widget! Remember, Widget does that very well already.

So I decided that the solution was easy: when the user changes the size constraints of the layout, pass them down to all the widgets. Easy!

The code look like that:

 @size_constraint.setter
 def size_constraint(self, data: constants.SizeConstraint) -> None:
     if isinstance(data, constants.SizeConstraint):
         self.__size_constraint = data
         for w in self.__widgets:
             w.size_constraint = data

And it does the job perfectly. Not only that, but it is also efficient as we update the constraints only when necessary. Job done, thank you very much, and push to production!

But wait... There's just one small problem: what about widgets that are added after a change? Well... Then it just doesn't work. And that is another TODO note that I need to add to the code following this article... In that case, you would have widgets with different size constraints that lives in the same layout. It's not hard to fix, it does not even need a comparison or anything: when a widget is added, we need to overwrite its size constraint with the layout's one.

Aside from that minor inconvenience, I'm quite pleased with the progress of this layout system. Thus far, it appears to be versatile enough. The API is also fairly straightforward, as I extensively utilize Python's properties for setter and getter methods. This makes assigning a layout to a widget as simple as:

# The number in Widget's constructor are: width, height, minimum_width,
# minimum_height,maximum_width, maximum_height.
my_widget = Widget(30, 15, 10, 4, 40, 20, config=config)
my_widget.layout = BoxLayout()

# And then simply add widgets to the layout using add_widget()
my_widget.layout.add_widget(
    Widget(6, 3, 3, 1, 8, 4, bg_color=Color.random(), config=config)
)

# Orientation and size constraint can be set with simple properties
my_widget.layout.orientation = constants.Orientation.VERTICAL
my_widget.layout.size_constraint = constants.SizeConstraint.DEFAULT_SIZE

And despite its simplicity, it works pretty well! I'm sure that you're dying for more screenshots of colored rectangles so here it is!

The current BoxLayout handle orientation changes with and without spacing between widgets:

Without spacing

And with spacing

It also gracefully handles adding non-trivial widgets, i.e: widgets that are already a composition of multiple widgets held into another layout (layout-ception!):

Finally, I have also incorporated culling into the render loop to prevent rendering sub-elements that are no longer within the frame. This completely skips the rendering of elements that are entirely outside the rendering surface.

Oh, and I realized that there was no way to remove any widgets (even using their IDs) in the BoxLayout class... So I guess that's one more TODO...

Closing word

This entry is already quite long, so we'll wrap things up for today. As I mentioned in the introduction, the next entry will be the last about the past before we switch to the PR, I'm working on right now.

Next time we will talk about the GridLayout and an actually useful implementation of a Widget: the LineInput widget.

I hope you find this somewhat interesting. Do not hesitate to give me feedback!

Have fun and keep coding!

Arnaud.