Ξ

How to TUI

Published on 2023-03-30 code linux

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

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 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:

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):
        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, 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.