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
Flaskandjsto do all of my GUI needs; - create a nice TUI module that I can share, even if there are several existing already.
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
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
textwrapto 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 akeywhich 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
noutrefreshfor each widget, stacking the changes to be done but not yet displayed, then calldoupdateat 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 withcontext manager, ortryandfinally
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
