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

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

·

12 min read

2023: A Coding Odyssey

Introduction

In this article, we'll continue our exploration into the pygamelib's layout system, building on the progress we made in the last installment.

Additionally, I'll introduce the library's first practical widget: the LineInput widget.

And finally, this is it - the ultimate "catch-up" post! In the next article, we're diving headfirst into the most recent and thrilling (well sorta...) developments! 🎉

The GridLayout

Building upon the base layout class defined in the last entry of this series, it is now time to tackle the GridLayout. If the the BoxLayout was a nice "entrée en matière", the GridLayout is a different beast altogether. It is a much more complex layout.

So, what are the differences? Well, first and foremost, we now have widgets with potentially varying dimensions in distinct locations, and this needs to be taken into account.

For example, the layout must identify the largest widget in each row and column that limits the width or height of the entire row or column. Naturally, we'll need to compute or track the overall geometry of the widgets managed by the layout. This also hints at a considerable potential for a performance hit.

Why do I say that? Well, if you start considering the usage of such a layout, you can easily make assumptions about the potential performance impact. Let's think about the actual usage of the grid layout, which a programmer would often need:

  • to know the number of rows and columns in the layout,

  • like all other layouts, to know the list of widgets in the layout,

  • obviously, add and remove widgets,

  • manage the geometry of the layout (rows and columns size, spacing between widgets, etc.)

  • and finally, render the layout and its widgets on the screen.

Unfortunately, there are multiple questionable ways of implementing these features.

If you remember from last week, one of the requirements for all of the layouts is to have a Layout.add_widget(w: Widget) method, that adds a widget to the layout without any other information. This means in this case, that GridLayout will need to be able to find an empty space or to create more space in the grid if needed. More on that later.

You can appreciate that all of these operations require logic. And logic means calculations. Calculations mean impacts on performances.

So, what architecture did I ultimately choose? Well, as usual, I aim to write the least amount of code while considering the big O complexity of my code. I prefer not to maintain internal states that ultimately amount to cached values, primarily because it places a significant maintenance burden on future maintainers. If the code is not properly documented or commented, it becomes increasingly difficult to understand.

That being said, calculating a column width involves iterating through all the rows, checking if a widget is present in the cell, determining the widget's dimensions, and storing the largest width. Performing these steps every time the layout needs to return a column width, 60 times per second, could be too taxing on performance. This is especially true when considering that multiple values need to be calculated in this manner.

Ultimately, I decided to implement a balanced approach that would optimize performance without compromising functionality: by caching the values and employing the observer system to dynamically update these cached values as needed. This method allows for efficient storage and retrieval of the largest widget widths, while also ensuring that the most up-to-date dimensions are used for calculations. By caching the values, the system avoids the need to repeatedly perform the time-consuming process of iterating through all the rows (for example), checking for the presence of a widget in each cell, determining its dimensions, and storing the largest width. The observer system, on the other hand, helps keep track of any changes in the widget dimensions and allows for the dynamic update of the cached values. This combination of techniques effectively addresses the performance concerns associated with calculating multiple values in this manner, while still maintaining the accuracy and responsiveness required for a smooth user experience.

Using the observer mechanic like that is not unlike Qt's signal/slot system (which I love a lot).

Another specificity of that layout is that it requires a row and column to add a widget. Except that, I set a rule that all layouts should have an add_widget(w: Widget) method. This means that the add_widget method of GridLayout should be able to perform correctly without coordinates. I did that by adding a quick test on the coordinates and if even one is None, I do a quick search to look for a free cell in the layout. If none is found, I just add a row to the layout. This means that:

  1. The layout expands exclusively vertically for now,

  2. I need to add an expansion policy for that layout.

As I previously mentioned, one of the challenges for these layouts is to constrain the rendering surface for the contained widgets. The approach that I choose is to limit the buffer that is given to the widget to render into. This way, even if the widget wants to render in a bigger space, it cannot.

In practice, it means that in the rendering loop, when it's time to defer rendering to widgets, I just give them their reserved space in the buffer and a 0,0 coordinate:

w.render_to_buffer(
    buffer[
        row + r_offset : row + r_offset + self.__rows_geometry[r],
        column
        + c_offset : column
        + c_offset
        + self.__columns_geometry[c],
    ],
    0,
    0,
    self.__rows_geometry[r],
    self.__columns_geometry[c],
)

The result is quite functional:

Here, we can see the cyan widget being resized, and the rest of the layout adapting accordingly while respecting the widgets' size constraints. For example, the white widgets have a smaller maximum width than the cyan widget. It works great.

The aspect that still needs improvement is the fact that widgets not entirely outside the layout are still rendered in their entirety.

The issue is quite evident in the previous code snippet: I allow the widget to render on the full width of the column (or height of the row). The fix is simple; it just needs to take into consideration the maximum layout's buffer size.

Another #TODO I guess 😉

LineInput: An Actually Useful Widget

The last big addition to the UI toolkit that I added through that PR was the LineInput widget. Because colored squares and rectangles are nice placeholders but at some point, I need to put some real widgets in these layouts (to avoid discovering all the problems at the same time).

If you are wondering, a LineInput widget is a user interface element that allows users to enter and edit a single line of text. It typically includes a text box where the user can type, and may also provide additional features such as a cursor for easy navigation, text selection, and built-in validation. LineInput widgets are commonly used in forms, search bars, and other situations where the user needs to input a small amount of text quickly and efficiently (like a username for example).

So I thought that an input widget would actually tick a lot of boxes in terms of pains in the a*s. Indeed it needs a couple of things that are not needed by colored squares:

  • It needs to display interactive content,

  • It needs to have a focus management system,

  • It needs a visual cue for the user to know where he is typing stuff (like a cursor),

  • It is preferable to have a history system somewhere...

That's great because, so far, the UI module lacks a focus management system, completely ignores even the concept of a cursor, and most definitely doesn't have a history management system! We're off to an excellent start!

History

So I got to work, the first thing was the history system. It needed to be generic, i.e.: can manage all sorts of objects, not exclusively strings for example.

The second design decision was to make the History class a singleton. I know that a lot of people dislike the singleton design pattern but with very little argument aside from hopping on the hype train. The only really valid cons that I agree with are the tight coupling and the lack of transparency of the pattern. However, the solutions for these are very often dependency injections. So... Tight coupling anyway, no?

I use many singletons in the pygamelib, mostly for resources that must or can (mostly depending on the user's choice) be shared between many objects and for integrity and performance reasons you will want to manage as a single instance anyway.

In most (if not all) cases, it is up to the user (as in the programmer using the library) to decide if he wants to use a global instance or a specific instance.

Let me skip ahead and give you an example, in my implementation of the LineInput widget, the constructor accepts a history parameter that must hold a History object. If it is not provided, the constructor will try to acquire a global reference to the history object. It is up to the developer to choose to use the global instance, a specific instance or none at all. Another example of a singleton is the Terminal object, this is a common resource managed by a single instance because I want to make sure that nothing else is going to change the state of my terminal while I'm using it (the pygamelib restores the terminal to its previous state when it exits).

Back to the topic, it was my first time implementing a history system \o/

I implemented it by considering an action as a point in time. And I'm moving that action (or object) along the timeline. In practice, I have 2 lists for past and future actions and a scalar for the current action. Then it's just a matter of wrapping the movement along that timeline in actions. From a developer's point of view, using it looks like this:

global_history = History.instance()
global_history.add('Hel')
global_history.add('Hello')
global_history.add('Hello Wo')
global_history.add('Hello World')
print(global_history.current)  # print "Hello World"
global_history.undo()
print(global_history.current)  # print "Hello Wo"
global_history.undo()
print(global_history.current)  # print "Hello"
global_history.redo()
global_history.redo()
global_history.redo() # This one does nothing as we called undo only twice.
print(global_history.current)  # print "Hello World"
# Now an example of reset through add()
global_history.undo()
global_history.undo()
print(global_history.current)  # print "Hello"
global_history.add("Hello there!")
print(global_history.current)  # print "Hello there!"
global_history.redo() # does nothing as the future was reset by add()

This example comes straight from the documentation.

The obvious downside of that approach is that each "action" represents a full state of the object that is tracked through the history. It is not very memory efficient...

However, this will suffice for now, as I plan to optimize it later. At least we now have a highly adaptable, generic history support in place.

Alright, one down, two to go!

Cursor (not curse, that is the question)

If we are going to have input widgets, we'll inevitably need one or more cursors as indicators for the user.

The Cursor object is not very complex, but some little traps need to be avoided.

The first one is that we need a cursor that adapts to the user's needs. This means that we need to be able to at least customize the appearance, starting position, and blinking behavior. So that's what I coded!

To get a plain, non blinking cursor you just use:

cursor = Cursor(blink_time=0)

Simple and efficient. Now, to customize the look of the cursor we just accept a Sprixel in the constructor. Easy. And a blinking delay as well:

cursor = Cursor(
    blink_time=0.4,
    sprixel=Sprixel(
        "|", 
        bg_color=config.input_bg_color,
        fg_color=Color(255, 255, 255),
    ),
)

Aside from that, we need to be able to lock the cursor's position when widgets are updating their content but want the cursor to remain in the same position. Not a problem Cursor.lock_position() and Cursor.unlock_position() are here for that. It allows for a dirty optimization: the widgets can have properties/functions that update the cursor no matter what other functions may want. A good example of that is in the LineInput widget (a bit ahead of time): the LineInput.text property, sets the cursor's position to the end of the text. It is unconditional because it cannot know when to move or not the cursor. It falls down to the user of that property to know when and where to move the cursor. Therefore, when Cursor.insert_characters() uses the text property to set the text, it first locks the cursor's position, sets the text, and finally unlocks the cursor's position to calculate the new position. This way, the text is correctly inserted and the cursor is moved to the right position.

That's 2 down!

Focus Management

The last piece of this triumvirate is the focus management system. Such a system is needed to ventilate the different user-generated events to the correct receiver.

And this is where I kind of stopped. I did not stop because I was lazy but because I had no clear idea of what to do. I started by thinking like a C++ programmer: let's create a Focusable interface that would define everything needed and then write an actual manager to handle these. Oh, and let's create an event manager/dispatcher to accurately dispatch keystrokes and other events.

But wait, I have a lot of these. There are already a lot of systems available in the pygamelib for these tasks. So, after a bit of thinking, I added a focus property to Widget and decided to leave it at that. I'm not entirely sure how I will implement a more bundled/streamlined version of a focus management system so for now I decided to experiment. And to experiment, I need to leave room for errors.

This is extremely important to me in any creative process to leave room for experimentation and error. And, unfortunately, any system that I could come up with, however light, would still start to shape my thought process. Therefore, I just implemented the one thing that I'm certain that I'll need: the focus property.

It allows for basic management through a list and an index! Have a look at that example:

# Have some global variables
focus_stack = []
focus_stack_index = 0

# Now in the update function of my program (called every frame)
def user_update(g: Game, k: blessed.Keystroke dt: float):
    global focus_stack_index
    # If the Tab key is hit, we change who's focus property is True
    if k.name == "KEY_TAB":
        if not focus_stack[focus_stack_index].focus:
            focus_stack[focus_stack_index].focus = True
        else:
            focus_stack[focus_stack_index].focus = False
            focus_stack_index += 1
            focus_stack_index = focus_stack_index % len(focus_stack)
            focus_stack[focus_stack_index].focus = True
    # And if Tab was not hit, the currently focused widget receives
    # the keyboard input
    elif focus_stack[focus_stack_index].focus:
        g.screen.place(str(k), 23, 0)
        if k.name == "KEY_BACKSPACE":
            focus_stack[focus_stack_index].backspace()
        elif k.name == "KEY_DELETE":
            focus_stack[focus_stack_index].delete()
        elif k.name == "KEY_HOME":
            focus_stack[focus_stack_index].home()
        elif k.name == "KEY_END":
            focus_stack[focus_stack_index].end()
        elif k.name == "KEY_ESCAPE":
            focus_stack[focus_stack_index].focus = False

Now that I look back at it, I actually think that this is pretty much what I would expect from a focus manager. I little more packaged so I can call a method like focus_next() for example. We'll see.

What About LineInput?

Well, now that all of this is done, LineInput is more or less just a matter of organizing all of these features. So there's nothing really amazing in that class. But it does work, and here it is in action:

Conclusion

It's very interesting to do things, just for the sake of it. Just to learn something interesting.

I'm often reminded of that while coding this module. I find this exercise intellectually refreshing. Being able to really think of solutions while not being constrained by notions like ROI, speed of implementation, etc.

As usual, I hope that you found this article somewhat interesting. Do not hesitate to give me feedback!

Have fun and keep coding!

Arnaud.