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
andjs
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
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
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 akey
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 calldoupdate
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, ortry
andfinally
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