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 stdin
when
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
tcgetattr()
and 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 cbreak()
function.
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
and \033[?1049h
in xterm. Switching back to the original
screen is rmcup
in terminfo and \033[?1049l
in
xterm.
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()
Handling exceptions
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
a try … finally
block so the cleanup code is run even if
there are exceptions.
Handling ctrl-z
You can stop any program in the terminal by pressing
ctrl-z
. That program will simply not do anything until you
type 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
SIGSTOP
, SIGTSTP
, and SIGCONT
.
SIGSTOP
and 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 SIGTSTP
on ctrl-z
. SIGCONT
is used to un-stop a
process and is sent by the terminal when you type fg
.
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
like this:
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 justSIGKILL
. - 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 sendSIGTSTP
again. But it is much simpler to just sendSIGSTOP
instead. - We do not need to register a separate handler for
SIGCONT
. Instead, we just rely on the fact thatSIGSTOP
will immediately stop the process. OnSIGCONT
, 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
not reusable.
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:
shutil.get_terminal_size()
.
However, terminals can be resized. So you should also register a
handler for SIGWINCH
that gets the latest size and
re-renders your application whenever the size changes.
Conclusion
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.