How to TUI
Text-based user interfaces (TUIs) are like graphical user interfaces (GUIs), except they run in a terminal. They are also distinct from command line interfaces (CLIs) which also run in the terminal, but do not use a two dimensional layout.
Because TUIs and CLIs are so different, switching between the two modes can be challenging. If you are not careful, the terminal ends up in an inconsistent state. But fear not! I will walk you through all the relevant steps.
Enter and exit TUI mode
Terminals do not have a dedicated TUI mode. Instead, there is a collection of options we can combine to get what we want. I will first describe each aspect and then provide a code example.
Disable line buffering
In CLI mode, input is buffered and sent to
the user presses the enter key. In TUI applications, we want to react to
every key press individually. So we want to disable that.
In C, the termios library provides the two functions
tcsetattr() which allow us to
get a struct with options for
stdin, change it, and the
apply the new set of options.
It is common to store a copy of the original struct so it can be used to restore the original state when we want to exit TUI mode.
The python standard library also provides bindings for termios as
well as the higher level
tty module that allows us to
easily disable line buffering using the
Restore screen content on exit
We do not want our TUI to mess up the terminal’s scrollback buffer. So when we exit, we want to restore the screen content as it was before we started.
Luckily, many terminals provide an “alternate screen”. We can switch to that alternate screen when entering TUI mode and return to the normal screen on exit. This way the original screen is never changed.
To do this, we need to send some special bytes to the terminal. Usually, bytes that we send to the terminal will be displayed as characters on the screen. But there are some special escape codes that we can use to send commands to the terminal instead.
Unfortunately, not all terminals use the same codes. It is therefore best to use the terminfo database to get the correct escape code for the current terminal. In practice, many terminals are very similar to xterm, so if portability is not a major concern you can often get by by using xterm codes.
Switching to the alternate screen is
smcup in terminfo
\033[?1049h in xterm. Switching back to the original
rmcup in terminfo and
Hide the cursor
When typing in the command line, the cursor show us where the next typed character will be inserted. In many TUI applications, arbitrary regions of the screen change all the time, so the cursor moves around quite a bit. To avoid distraction, it is therefore best to make the cursor invisible.
This can again be done using escape codes. Hiding the cursor is
civis in terminfo and
\033[?25l in xterm.
Showing the cursor is
cnorm in terminfo and
\033[?25h in xterm.
Putting it all together
import sys import termios import tty = sys.stdin.fileno() fd = termios.tcgetattr(fd) old_state def enter_tui(): tty.setcbreak(fd)'\033[?1049h') sys.stdout.write('\033[?25l') sys.stdout.write( sys.stdout.flush() def exit_tui(): '\033[?1049l') sys.stdout.write('\033[?25h') sys.stdout.write( sys.stdout.flush() termios.tcsetattr(fd, termios.TCSADRAIN, old_state) enter_tui() run_mainloop()exit_tui()
The above code still has a major issue: When our mainloop raises an
exception, the process ends without exiting TUI mode, so we end up with
broken terminal. The fix in this case is simple though: Wrap the code in
try … finally block so the cleanup code is run even if
there are exceptions.
You can stop any program in the terminal by pressing
ctrl-z. That program will simply not do anything until you
fg. When we stop our TUI application we have the same
issue as before: We are left with a broken terminal. So again we need to
make sure to cleanup before stopping. This time it is a bit more
complicated than before.
The underlying mechanism for this are the signals
SIGTSTP are used to stop a
process. The difference between the two is that the our application can
intercept and handle (or ignore)
SIGTSTP, but not
SIGSTOP. Luckily, the terminal sends
SIGCONT is used to un-stop a
process and is sent by the terminal when you type
Signals can interrupt our code at any time, e.g. in the middle of writing a string to stdout. There are very few operations that are safe to run in a signal handler. It is therefore crucial that you integrate the signal handler with your mainloop, e.g. using the self-pipe trick. I am going into the details in this article and instead assume that you have dealt with that yourself.
The code we need to run on
SIGTSTP should look something
import os import signal def on_stop(): exit_tui() os.kill(os.getpid(), signal.SIGSTOP) enter_tui() render()
Some things to note:
- Don’t let the name confuse you:
kill()is used for sending any signals, not just
- We have replaced the default handler for
SIGTSTP, so we have to stop the process ourselves. One way would be to restore the default handler and send
SIGTSTPagain. But it is much simpler to just send
- We do not need to register a separate handler for
SIGCONT. Instead, we just rely on the fact that
SIGSTOPwill immediately stop the process. On
SIGCONT, execution will continue and we can restore the TUI context in the next line.
- The screen might have changed in the meantime, so it is best to do a fresh render.
Using context managers
If you are like me, the code examples above scream context manager. This could look something like this:
import os import signal import sys import termios import tty from contextlib import AbstractContextManager class TUIMode(AbstractContextManager): def __init__(self): self.fd = sys.stdin.fileno() self.old_state = termios.tcgetattr(self.fd) def __enter__(self): self.fd) tty.setcbreak('\033[?1049h') sys.stdout.write('\033[?25l') sys.stdout.write( sys.stdout.flush()return self def __exit__(self, *exc): '\033[?1049l') sys.stdout.write('\033[?25h') sys.stdout.write( sys.stdout.flush()self.fd, termios.TCSADRAIN, self.old_state) termios.tcsetattr( def on_stop(ctx): __exit__(None, None, None) ctx. os.kill(os.getpid(), signal.SIGSTOP)__enter__() ctx. render() with TUIMode() as ctx: run_mainloop()
I am not entirely sure if I like this version better. It is a nice
abstraction for the simple case of handling exceptions. But the calls to
exit and re-enter the context on
SIGTSTP feel clunky.
In order for this to work, the context manager has to be reusable,
i.e. we must be able to enter and exit it multiple times. This is the
case here, but is not always guaranteed. For example, context managers
that are created using the
@contextmanager decorator are
Get terminal size
As the cherry on top, we should know how much space is available,
e.g. to draw a bar that spans the complete width of the terminal. Python
provides us with a simple helper for that:
However, terminals can be resized. So you should also register a
SIGWINCH that gets the latest size and
re-renders your application whenever the size changes.
There are many libraries that take care of all of this for you. But sometimes you run into issues anyway. You should now have all the tools to track down the underlying issues and fix them yourself.