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