So you want to make a TUI…

So you want to make a TUI…

Over the past few years I've spent way more time on text-based user interfaces than what can still be considered healthy, and I'd like to discuss some aspects of their creation in the context of the *nix environment.

All things considered, I've had enough of terminals, and I'm even starting to find monospace fonts hard to read, so while this stays a valid means to write all sorts of applications, I'm going to be focusing on GUIs more in the future instead of trying to circumvent the limitations of something coming from the 70s.

I apologize in advance for any factual errors in the article, it is very broad in scope.

Encodings

In order to be able to store any text in the computer, you need a well-defined character set and a way to encode it. Historically, many encodings have been in use, most of them based on ASCII and each covering a slightly different charset, assigning different values to the narrow upper half of a byte's value range. Until, of course, Unicode finally happened in the early 90s, a solution to the plethora of ways one could interpret a stream of bytes. Well, sort of, as you still need to know that it is being used in the first place, and which encoding of it, or at least be able to make an educated guess. Anyway, its character set was designed to be a superset of all the other charsets in use and more.

The C language also has something that may be used to represent a character from any supported encoding or charset on the system—so called wide characters. Unfortunately, this is not necessarily the same thing as Unicode, unless __STDC_ISO_10646__ is defined on the system. In fact, the real purpose of this concept is to coalesce units in multibyte strings, and to do something about those ugly Asian shift encodings. But it is perfectly fine for FreeBSD, NetBSD and others to make it locale-dependent (again because of some East Asian nonsense). I'm not so sure if it's okay for Windows to use UTF-16, where you may need two units to represent a codepoint, but at least it's not UCS-2 anymore and they can't even really change that width because of backward compatibility. Luckily, at least glibc/Linux is the sane one here and gives us the full UCS-4.

Why am I talking about this? I want you to be aware of the mess this is, and that you might not want to pick these wide characters for internal representation of text—serious applications going with their needs beyond the current locale's encoding will probably want to create iconv converters between that encoding, be it the wide or multibyte form, and UTF-8/UCS-4, and only use the locale's encoding when I/O is needed. That is, only to communicate with the user, interpret filenames and maybe a few other things like reading out the user's full name info from /etc/passwd. You can also ignore legacy encodings altogether like e.g. Neovim does, which is not completely unreasonable in this age.

Unicode

So let's assume for simplicity that everything is UTF-8 and UCS-4. Are we free of trouble now? Of course not. Enter the world of full-width/double-wide characters! Again, we can safely blame Asians, as they're even the ones behind the Emoji nonsense. The original problem though was that characters such as 日本語 were a bit too wide to be represented by just one cell on your typical terminal, so they had to use two. (Cells, not terminals.) The way you tell a half-width character from a full-width one is through the standard C library function wcwidth. And you need to do this for every single character of questionable origin you put on the screen. There go simple alignment algorithms. As if handling tabs wasn't enough.

It also creates some interesting problems. For example, there must be an agreement between the terminal's wcwidth, font glyphs, your libraries' wcwidth, and your application's wcwidth. What's more, ncurses gets very confused if you get the crazy idea to write in the middle of a character on a window, which is a real concern when you do something like the painter's algorithm, drawing one thing on top of another:

#include <locale.h>
#include <ncurses.h>

int main (int argc, char *argv[]) {
	if (!setlocale (LC_ALL, "") || !initscr () || curs_set (0) == ERR)
		return 1;

	// Replacing a full-width character with a half-width breaks ncurses
	for (int i = 0; i < 5; i++) {
		mvaddwstr (    i,  0, L"======");
		mvaddwstr (    i, 10, L"======");
		mvaddwstr (    i, 10 + i, L"\\");

		mvaddwstr (6 + i,  0, L"ーー==");
		mvaddwstr (6 + i, 10, L"ーー==");
		// However this fixes it: wnoutrefresh (stdscr);
		mvaddwstr (6 + i, 10 + i, L"\\");
	}

	// The first vertical line ends up misaligned, the second one is fine;
	// interestingly, just calling wnoutrefresh() wouldn't be enough
	for (int i = 0; i < 11; i++) mvaddwstr (i, 20, L"|");
	refresh ();
	for (int i = 0; i < 11; i++) mvaddwstr (i, 25, L"X");
	refresh (); getch (); endwin (); return 0;
}

This program produces the output on the left, unless you uncomment the magical statement, which somehow makes ncurses suddenly realize that full-width characters exist, and replace the other half of overwritten wide characters with a space, as seen on the right side:

Last but not least, you must be aware of non-spacing characters that modify their surroundings.

Others have written about Unicode shenanigans, too.

Keys and key combinations

Originally there weren't that many things you could send through a terminal: basic ASCII including the control range, and a few special keys. The kind-of-famous VT100 didn't even have an Alt/Meta key:

SET-UP SET/ CLEAR TAB CLEAR ALL TABS LINE/ LOCAL SETUP A/B TOGGLE 1/0 TRANSMIT SPEED RECEIVE SPEED 80/132 COLUMNS RESET ON LINE LOCAL KBD LOCKED L1 L2 L3 L4 ESC ! 1 @ 2 # £ 3 $ 4 % 5 ^ 6 &7 * 8 ( 9 ) 0 _ - + = ~ ` BACK SPACE BREAK TAB Q W E R T Y U I O P { [ } ] RETURN DELETE CTRL CAPS LOCK A S D F BELL G H J K L : ; " ' | \ NO SCROLL SHIFT Z X C V B N M < , > . ? / SHIFT LINE FEED PF1 PF2 PF3 PF4 7 8 9 - 4 5 6 , 1 2 3 ENTER 0 .

Eventually someone figured out that the normally unused top bit of a character, given eight bits in a byte, could be used to relay the Meta key, and when it became obvious that people wanted to use that bit for their fancy encodings, realised that perhaps prepending an Escape (control character 27) instead could also work. So now we have two ways of doing it and a source of confusion in dated software like XTerm and GNU Readline.

Similarly, no one anticipated today's desires to handle wild combinations such as Ctrl-Shift-<letter>, and to this day it's being translated by terminals as that letter with the top three bits of its ASCII value grounded, losing the Shift information in the process.

There also used to be all sorts of incompatible models of terminals, sending different codes for the same keys, and requiring different control sequences, prompting the creation of the termcap and later terminfo library, for without them you would be unable to discern what it is you are receiving over the wire. In fact, while VT100 has become a de facto standard, even modern virtual terminals in the likes of xterm and urxvt still can't agree on all their codes. This is typically handled by ncurses, which also consequently becomes somewhat constrained by their specifics, as well as by its own standardised API.

Realising some of the limitations, Paul Evans has created libtermkey, which is being used today in Neovim as well as other software, and provides a better alternative. Obviously you're going to need a terminal that can send the extended key codes in order to make use of those. Unfortunately it's also UTF-8-only, causing me to create my termo fork to be used in my employer's legacy encoding-using system. It has some other slight improvements, too, for things I care about.

Mouse

There are three basic modes you can enable—1000 will only get you clicks, 1002 will get you drags, and 1003 spams you with all mouse movement. However, to get the last two ones, ncurses either ridiculously wants you to change your TERM to something like xterm-1002, or you need to write a magical sequence of e.g. "\x1b[?1002h" straight to the terminal, in addition to setting REPORT_MOUSE_POSITION.

Next, if you want to get extended mouse coordinates at all (column ≥ 223), there are three submodes you can opportunistically enable—the broken 1005 that tried to misuse UTF-8, the fixed SGR 1006, and rxvt-unicode's custom 1015. Again, you'll need to manually change TERM to xterm-1006 before running your application, or at least output a magical sequence of "\x1b[?1006h". That should work with xterm or VTE-based terminals at least. rxvt-unicode won't support the 1006, instead standing by its own 1015, which aimed to fix 1005 and predates the 1006. To enable that, you have to check TERM manually in your application and use a different magical sequence of "\x1b[?1015h". As a side note, I guess it's apparent that maintainers of xterm/ncurses and rxvt-unicode don't like each other much. And to be frank, Marc Lehmann is an ass.

Mouse has been historically broken in ncurses. At least between the years of 2012 and 2014, it was basically unusable because of its unreliability, which luckily seems to be fixed now. Yet I've just discovered that it crashes with rxvt-unicode if you keep clicking outside the coordinate range. Therefore, to get the best results, I can again suggest libtermkey, or even better my fork termo, as I focused on good mouse support.

Clipboard

In theory, there's nothing to do here. Selection and consequently copying is handled by the terminal. Pasting, however, can be a problem when you don't want to accidentally pass several hundred lines of something through a chat client, when you want to avoid autoindent in a text editor, or when you want to avoid parsing arbitrary data as non-letter keys.

One not quite reliable way of detecting whether the user is pasting something is via timing. Recently though, a better means of solving this problem has gained wider support: the bracketed paste mode. Just enable it via "\x1b[?2004h" and all pasted input will come wrapped in a pair of "\x1b[200~" and "\x1b[201~". Hopefully, that input itself won't be so evil as to contain a premature, fake end marker, doing whatever this was supposed to protect the user from in the first place. Although I suppose you can combine this feature with timing…

Of course, the bracketed paste mode is way too new for ncurses to handle. Basically, you're on your own, not even libtermkey does anything about this yet, and likely never will.

Pictures

TODO: sixel, quarter blocks, w3mimgdisplay

Colors

TODO: arbitrary 16 colors, 88 and 256 color mode, true color

TODO: tricks with the 16-color palette

Display

TODO: ncurses, S-Lang, libtickit? https://midnight-commander.org/ticket/3264

Frameworks

TODO: show TurboVision, ...

TODO: show various custom UIs that various applications have

TODO: mention that on Linux, about the best thing is ncurses, followed by S-Lang, dead libtickit

Widgets

TODO: say about the reduced line drawing set available in curses and follow that by showing how Unicode helps, e.g. with scrollbars

TODO: show the bar cursor for text editing elements, mention that it breaks ConnectBot

TODO: mention https://arcan-fe.com/2017/07/12/the-dawn-of-a-new-command-line-interface/


Maybe I'll finish this one day…

Discussion has been outsourced to the Lobsters technology-focused link aggregator, and Hacker News. A little bit inconsiderate to post it there in this state but what can I do.