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