blog

git clone https://git.ce9e.org/blog.git

commit
10f87ea2cb82b3889e7dc2a6f66568b295aeea77
parent
9d641d08606a88e171a4e902dab076602263de28
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2023-04-01 17:50
add post on TUIs

Diffstat

A _content/posts/2023-03-30-tui/index.md 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A _content/posts/2023-03-30-tui/index.yml 3 +++

2 files changed, 221 insertions, 0 deletions


diff --git a/_content/posts/2023-03-30-tui/index.md b/_content/posts/2023-03-30-tui/index.md

@@ -0,0 +1,218 @@
   -1     1 Text-based user interfaces (TUIs) are like graphical user interfaces (GUIs),
   -1     2 except they run in a terminal. They are also distinct from command line
   -1     3 interfaces (CLIs) which also run in the terminal, but do not use a two
   -1     4 dimensional layout.
   -1     5 
   -1     6 Because TUIs and CLIs are so different, switching between the two modes can be
   -1     7 challenging. If you are not careful, the terminal ends up in an inconsistent
   -1     8 state. But fear not! I will walk you through all the relevant steps.
   -1     9 
   -1    10 # Enter and exit TUI mode
   -1    11 
   -1    12 Terminals do not have a dedicated TUI mode. Instead, there is a collection of
   -1    13 options we can combine to get what we want:
   -1    14 
   -1    15 ## Disable line buffering
   -1    16 
   -1    17 In CLI mode, input is buffered and sent to `stdin` when the user presses the
   -1    18 enter key. In TUI applications, we want to react to every key press
   -1    19 individually. So we want to disable that.
   -1    20 
   -1    21 In C, the termios library provides the two functions `tcgetattr()` and
   -1    22 `tcsetattr()` which allow us to get a struct with options for a given file
   -1    23 descriptor, change it, and the apply the new set of options.
   -1    24 
   -1    25 It is common to store a copy of the original struct so it can be used to
   -1    26 restore the original state when we want to exit TUI mode.
   -1    27 
   -1    28 The python standard library also provides bindings for termios as well as the
   -1    29 higher level `tty` module that allows us to easily disable line buffering using
   -1    30 the `cbreak()` function.
   -1    31 
   -1    32 ## Restore screen content on exit
   -1    33 
   -1    34 We do not want our TUI to mess up the terminal's scrollback buffer. So when we
   -1    35 exit, we want to restore the screen content as it was before we started.
   -1    36 
   -1    37 Luckily, many terminals provide an "alternate screen". We can switch to that
   -1    38 alternate screen when entering TUI mode and return to the normal screen on
   -1    39 exit. This way the original screen is never changed.
   -1    40 
   -1    41 To do this, we need to send some special bytes to the terminal. Usually, bytes
   -1    42 that we send to the terminal will be displayed as characters on the screen. But
   -1    43 there are some [special escape
   -1    44 codes](https://en.wikipedia.org/wiki/Ansi_escape_codes) that we can use to send
   -1    45 commands to the terminal instead.
   -1    46 
   -1    47 Unfortunately, not all terminals use the same codes. It is therefore best to
   -1    48 use the terminfo database to get the correct escape code for the current
   -1    49 terminal. In practice, many terminals are very similar to xterm, so if
   -1    50 portability is not a major concern you can often get by by using xterm codes.
   -1    51 
   -1    52 Switching to the alternate screen is `smcup` in terminfo and `\033[?1049h` in
   -1    53 xterm. Switching back to the original screen is `rmcup` in terminfo and
   -1    54 `\033[?1049l` in xterm.
   -1    55 
   -1    56 ## Hide cursor
   -1    57 
   -1    58 When typing in the command line, the cursor show us where the next typed
   -1    59 character will be inserted. In many TUI applications, arbitrary regions of the
   -1    60 screen change all the time, so the cursor moves around quite a bit. To avoid
   -1    61 distraction, it is therefore best to make the cursor invisible.
   -1    62 
   -1    63 This can again be done using escape codes. Hiding the cursor is `civis` in
   -1    64 terminfo and `\033[?25l` in xterm. Showing the cursor is `cnorm` in terminfo
   -1    65 and `\033[?25h` in xterm.
   -1    66 
   -1    67 ## Putting it all together
   -1    68 
   -1    69 ```python
   -1    70 import sys
   -1    71 import termios
   -1    72 import tty
   -1    73 
   -1    74 fd = sys.stdin.fileno()
   -1    75 old_state = termios.tcgetattr(fd)
   -1    76 
   -1    77 def enter_tui():
   -1    78 	tty.setcbreak(fd)
   -1    79 	sys.stdout.write('\033[?1049h')
   -1    80 	sys.stdout.write('\033[?25l')
   -1    81 	sys.stdout.flush()
   -1    82 
   -1    83 def exit_tui():
   -1    84 	sys.stdout.write('\033[?1049l')
   -1    85 	sys.stdout.write('\033[?25h')
   -1    86 	sys.stdout.flush()
   -1    87 	termios.tcsetattr(fd, otermios.TCSADRAIN, old_state)
   -1    88 
   -1    89 enter_tui()
   -1    90 run_mainloop()
   -1    91 exit_tui()
   -1    92 ```
   -1    93 
   -1    94 # Handling exceptions
   -1    95 
   -1    96 The above code still has a major issue: When our mainloop raises an exception,
   -1    97 the process ends without exiting TUI mode, so we end up with broken terminal.
   -1    98 The fix in this case is simple though: Wrap the code in a `try … finally` block
   -1    99 so the cleanup code is run even if there are exceptions.
   -1   100 
   -1   101 # Handling `ctrl-z`
   -1   102 
   -1   103 You can stop any program in the terminal by pressing `ctrl-z`. That program
   -1   104 will simply not do anything until you type `fg`. When we stop our TUI
   -1   105 application we have the same issue as before: We are left with a broken
   -1   106 terminal. So again we need to make sure to cleanup before stopping. This time
   -1   107 it is a bit more complicated than before.
   -1   108 
   -1   109 The underlying mechanism for this are the signals `SIGSTOP`, `SIGTSTP`, and
   -1   110 `SIGCONT`. `SIGSTOP` and `SIGTSTP` are used to stop a process. The difference
   -1   111 between the two is that the our application can intercept and handle (or
   -1   112 ignore) `SIGTSTP`, but not `SIGSTOP`. Luckily, the terminal sends `SIGTSTP` on
   -1   113 `ctrl-z`. `SIGCONT` is used to continue a process and is sent by the terminal
   -1   114 when you type `fg`.
   -1   115 
   -1   116 Signals can interrupt our code at any time, e.g. in the middle of writing a
   -1   117 string to stdout. There are very few operations that are safe to run in a
   -1   118 signal handler. It is therefore crucial that you integrate the signal handler
   -1   119 with your mainloop, e.g. using the [self-pipe
   -1   120 trick](https://cr.yp.to/docs/selfpipe.html). I am going into the details in
   -1   121 this article and instead assume that you have dealt with that yourself.
   -1   122 
   -1   123 The code we need to run on `SIGTSTP` should look something like this:
   -1   124 
   -1   125 ```python
   -1   126 import os
   -1   127 import signal
   -1   128 
   -1   129 def on_stop():
   -1   130 	exit_tui()
   -1   131 	os.kill(os.getpid(), signal.SIGSTOP)
   -1   132 	enter_tui()
   -1   133 	render()
   -1   134 ```
   -1   135 
   -1   136 Some things to note:
   -1   137 
   -1   138 -	Don't let the name confuse you: `kill()` is used for sending any signals,
   -1   139 	not just `SIGKILL`.
   -1   140 -	We have replaced the default handler for `SIGTSTP`, so we have to stop the
   -1   141 	process ourselves. One way would be to restore the default handler and send
   -1   142 	`SIGTSTP` again. But it is much simpler to just send `SIGSTOP` instead.
   -1   143 -	We do not need to register a separate handler for `SIGCONT`. Instead, we just
   -1   144 	rely on the fact that `SIGSTOP` will immediately stop the process. On
   -1   145 	`SIGCONT`, execution will continue and we can restore the TUI context in the
   -1   146 	next command.
   -1   147 -	The screen might have changed in the meantime, so it is best to do a fresh
   -1   148 	render.
   -1   149 
   -1   150 # Using context managers
   -1   151 
   -1   152 If you are like me, the code examples above scream *context manager*. This
   -1   153 could look something like this:
   -1   154 
   -1   155 ```python
   -1   156 import os
   -1   157 import signal
   -1   158 import sys
   -1   159 import termios
   -1   160 import tty
   -1   161 from contextlib import AbstractContextManager
   -1   162 
   -1   163 
   -1   164 class TUIMode(AbstractContextManager):
   -1   165 	def __init__(self):
   -1   166 		self.fd = sys.stdin.fileno()
   -1   167 		self.old_state = termios.tcgetattr(self.fd)
   -1   168 
   -1   169 	def __enter__(self):
   -1   170 		tty.setcbreak(self.fd)
   -1   171 		sys.stdout.write('\033[?1049h')
   -1   172 		sys.stdout.write('\033[?25l')
   -1   173 		sys.stdout.flush()
   -1   174 		return self
   -1   175 
   -1   176 	def __exit__(self, *exc):
   -1   177 		sys.stdout.write('\033[?1049l')
   -1   178 		sys.stdout.write('\033[?25h')
   -1   179 		sys.stdout.flush()
   -1   180 		termios.tcsetattr(self.fd, otermios.TCSADRAIN, self.old_state)
   -1   181 
   -1   182 
   -1   183 def on_stop(ctx):
   -1   184 	ctx.__exit__(None, None, None)
   -1   185 	os.kill(os.getpid(), signal.SIGSTOP)
   -1   186 	ctx.__enter__()
   -1   187 	render()
   -1   188 
   -1   189 
   -1   190 with TUIMode() as ctx:
   -1   191 	run_mainloop()
   -1   192 ```
   -1   193 
   -1   194 I am not entirely sure if I like this version better. It is a nice abstraction
   -1   195 for the simple case of handling exceptions. But the calls to exit and
   -1   196 re-enter the context on `SIGTSTP` feel clunky.
   -1   197 
   -1   198 In order for this to work, the context manager has to be
   -1   199 [*reusable*](https://docs.python.org/3/library/contextlib.html#single-use-reusable-and-reentrant-context-managers),
   -1   200 i.e. we must be able to enter and exit it multiple times. This is the case
   -1   201 here, but is not always guaranteed. For example, context managers that are
   -1   202 created using the `@contextmanager` decorator are not reusable.
   -1   203 
   -1   204 # Get terminal size
   -1   205 
   -1   206 As the cherry on top, we should know how much space is available, e.g. to draw
   -1   207 a bar that spans the complete width of the terminal. Python provides us with a
   -1   208 simple helper for that: `shutil.get_terminal_size()`.
   -1   209 
   -1   210 However, terminals can be resized. So you should also register a handler for
   -1   211 `SIGWINCH` that gets the latest size and re-renders your application whenever
   -1   212 the size changes.
   -1   213 
   -1   214 # Conclusion
   -1   215 
   -1   216 There are many libraries that take care of all of this for you. But sometimes
   -1   217 you run into issues anyway. You should now have all the tools to track down the
   -1   218 underlying issues and fix them yourself.

diff --git a/_content/posts/2023-03-30-tui/index.yml b/_content/posts/2023-03-30-tui/index.yml

@@ -0,0 +1,3 @@
   -1     1 title: How to TUI
   -1     2 date: 2023-03-30
   -1     3 tags: [code, linux]