---
title: How to TUI
date: 2023-03-30
tags: [code, linux]
description: Because TUIs and CLIs are so different, switching between the two modes can be challenging. But fear not! I will walk you through all the relevant steps.
---

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](https://en.wikipedia.org/wiki/Ansi_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

```python
import sys
import termios
import tty

fd = sys.stdin.fileno()
old_state = termios.tcgetattr(fd)

def enter_tui():
	tty.setcbreak(fd)
	sys.stdout.write('\033[?1049h')
	sys.stdout.write('\033[?25l')
	sys.stdout.flush()

def exit_tui():
	sys.stdout.write('\033[?1049l')
	sys.stdout.write('\033[?25h')
	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 a 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](https://cr.yp.to/docs/selfpipe.html). I am not 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:

```python
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 `SIGKILL`.
-	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
	`SIGTSTP` again. But it is much simpler to just send `SIGSTOP` instead.
-	We do not need to register a separate handler for `SIGCONT`. Instead, we just
	rely on the fact that `SIGSTOP` will 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:

```python
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):
		tty.setcbreak(self.fd)
		sys.stdout.write('\033[?1049h')
		sys.stdout.write('\033[?25l')
		sys.stdout.flush()
		return self

	def __exit__(self, *exc):
		sys.stdout.write('\033[?1049l')
		sys.stdout.write('\033[?25h')
		sys.stdout.flush()
		termios.tcsetattr(self.fd, termios.TCSADRAIN, self.old_state)


def on_stop(ctx):
	ctx.__exit__(None, None, None)
	os.kill(os.getpid(), signal.SIGSTOP)
	ctx.__enter__()
	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*](https://docs.python.org/3/library/contextlib.html#single-use-reusable-and-reentrant-context-managers),
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.
