Cursed adventure

Posted on 27 March 2020 in dev

curses is a python wrapper around the [n|p]curses library, a GUI for linux. I love that library.

When I started college, the programming languages I was taught were C/C++, Cobol and Java. Java had the reputation to be the language to use if you wanted to create a graphical interface (think about swing).

Disliking Java for being too verbose, I always use C or C++ for my programming needs, even when I needed a graphical interface.

I wrote a checker, a poker and a board game with no issues.

NB: the python curses is available for unix-based system (linux and mac) as a standard lib but not on win. It seems it could be downloaded but I have no experience with it.

Why I'm writing about it

Always looking to do something fun with gamebooks, I thought I could make a gamebook reader for the terminal. Therefore I needed a TUI, a Terminal User Interface. curses seemed the obvious choice for me but it is quite too basic to do what I wanted. I decided to create my wrapper around it, named cursed-adventure.

I have several objectives with it:

  • learn more about object programming in Python because I've never done much OO with that language;
  • learn more about TUI for python, as I always used Flask and js to do all of my GUI needs;
  • create a nice TUI module that I can share, even if there are several existing already.

Cursed doggie

Cursed doggie

The curses objects

In curses you get to play with mainly 4 types of object: windows, pads, panels and textpads.

Windows

Windows are the basic abstraction in curses. You can draw in it, write text but you can't never go outside the area you defined for that window. Also, it might be smaller than your terminal.

Pads

A pad is a window which can be extended further than what your terminal size allows and only the visible part will be displayed.

Panels

An object I particularly like. Panels are windows with an extra option: depth. This makes the creation of modals, dropdown menus, etc. so much easier to deal with.

Textpads

I've not yet used them. They are windows specialized in text edition.

Cursed adventure

Cursed Adventure

Cursed adventure demo with dropdown menu, question modal, scroll window, text window and status bar

The widgets

On top of what exist already for curses, I created or plan to create several widgets to use.

Already implemented

The names are temporary. I'm sure I will unify them.

  • CurseApp: the main app object gathering and controlling all other widget objects,
  • StatusBar: a status bar, displaying information (with rolling text option),
  • ScrollWindow: a window with a scroll bar on the right side,
  • TextWindow: a window with text displayed in it. It uses textwrap to avoid crashing the window if the line of text goes over the window row size limit,
  • Modal: a modal window,
  • ModalQuestion: a modal window with yes / no buttons,
  • MenuBar: a dropdown menu bar.

To be implemented

  • TabPane: a pane with different tabs you can navigate,
  • Music: an odd one, but I would like to be able to play music in the background,
  • ImageWindow: a window displaying images. I have a working demo but I need to create the widget which will take an image and transform it for the window.
  • VideoWindow: extending on the ImageWindow and display a video since a video is just a series of images ;);
  • HistogramWindow: a window to display a histogram;
  • CurveWindow: a window to display a curve.

Architecture

Base widget

The base widget is quite simple: one constructor method, one rendering method and an update method.

  • __init__: the app object stores objects in a dictionary where the keys are the given name we give to the widget object;
  • render: called by the app object whenever we need to redraw the screen, especially after widget updates. All widgets are rendered during a loop. This might induce flickering.
  • update(key): speaking of the devil, it updates the widget. Usually only one widget is updated at the time during a loop. The method asks for a key which is a keyboard key and depending on its values, it will perform a specific action. It also returns an integer, used to tell the main app to break or not the update cycle for that widget.
class DefaultWin:
    """Default curse window object.

    Contains the minimum necessary methods.
    ...
    Attributes
    ----------
    None.

    Methods
    -------
    """

    def __init__(self):
        """Class constructor."""
        pass

    def render(self):
        """This methods updates the rendering of the window."""
        pass

    def update(self, key):
        """This methods handles keyboard and/or mouse inputs and returns an int
        based on the input.

        Returns
        -------
            An integer related to some I/O input.
        """
        return 0

Refreshing the windows

I talked about refreshing windows. If you do it too frequently, you might induce flickering. To reduce it, you can:

  • call noutrefresh for each widget, stacking the changes to be done but not yet displayed, then call doupdate at the end of a cycle; and/or
  • mark widgets as to be updated or not, refreshing on the ones needing to.

Window(s) focus

When you want to work on a specific window, you have to assign a key to that window and then whenever this key is pressed, consider any actions performed after applied to this window.

Issues

I had several issues when I made the wrapper and below are my solutions to them.

Curses needs to exit the right way

If the application / script crashes mid-way, curses options will be still impact the terminal setup. There are several solutions to prevent this:

  • the ncurses library way with wrapper, or
  • with context manager, or
  • try and finally

If your terminal gets all scrambled, you can type stty sane to go back to a saner version. It might not reset all your preferences but it's good enough.

with context manager

It uses __enter__ and __exit__ functions. The two keywords are with statement context managers. Basically when you use with, __enter__ is executed when you enter the context stated by with and __exit__ when you exit said context. It's useful when you want to do specific processing and if you write your own class and / or module around curses.

try/finally

It uses a try statement with finally. It works well and it is useful when you want to write a quick and short script.

You can't just print lines

Whenever you write text in a window, you use addstr. Not an issue by itself but you need to remember the max length enabled by the window. If your text is longer than that, it won't be displayed. You will need to hyphenate your text if needed. Luckily Python does it by default via textwrap.

Dropdown menu

The menu might not be shown on the top of all the windows and this issue would show randomly. In order to avoid this, we need to render the dropdown menu last, after all other.

References

  • Curses: https://docs.python.org/3.7/howto/curses.html