Line data Source code
1 : /* SPDX-License-Identifier: GPL-3.0-or-later */
2 : /* Copyright 2026 Peter Csaszar */
3 :
4 : /**
5 : * POSIX terminal implementation.
6 : * Uses termios(3), ioctl TIOCGWINSZ, wcwidth(3).
7 : */
8 : #include "../terminal.h"
9 : #include <errno.h>
10 : #include <poll.h>
11 : #include <signal.h>
12 : #include <stdio.h>
13 : #include <stdlib.h>
14 : #include <string.h>
15 : #include <unistd.h>
16 : #include <wchar.h>
17 : #include <termios.h>
18 : #include <sys/ioctl.h>
19 : #include <wchar.h>
20 :
21 : /** Opaque saved terminal state. Definition lives here (hidden from header). */
22 : struct TermRawState {
23 : struct termios saved;
24 : int active;
25 : };
26 :
27 8 : int terminal_cols(void) {
28 : struct winsize ws;
29 8 : if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0)
30 7 : return (int)ws.ws_col;
31 1 : return 80;
32 : }
33 :
34 8 : int terminal_rows(void) {
35 : struct winsize ws;
36 8 : if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_row > 0)
37 7 : return (int)ws.ws_row;
38 1 : return 0;
39 : }
40 :
41 32 : int terminal_is_tty(int fd) {
42 32 : return isatty(fd);
43 : }
44 :
45 32 : TermRawState *terminal_raw_enter(void) {
46 32 : TermRawState *state = malloc(sizeof(TermRawState));
47 32 : if (!state) return NULL;
48 :
49 32 : if (tcgetattr(STDIN_FILENO, &state->saved) != 0) {
50 1 : free(state);
51 1 : return NULL;
52 : }
53 :
54 31 : struct termios raw = state->saved;
55 31 : raw.c_lflag &= ~(unsigned)(ICANON | ECHO | ISIG);
56 31 : raw.c_cc[VMIN] = 1;
57 31 : raw.c_cc[VTIME] = 0;
58 31 : tcsetattr(STDIN_FILENO, TCSANOW, &raw);
59 31 : state->active = 1;
60 31 : return state;
61 : }
62 :
63 33 : void terminal_raw_exit(TermRawState **state) {
64 33 : if (!state || !*state) return;
65 31 : if ((*state)->active)
66 31 : tcsetattr(STDIN_FILENO, TCSANOW, &(*state)->saved);
67 31 : free(*state);
68 31 : *state = NULL;
69 : }
70 :
71 : /**
72 : * Read one byte from STDIN_FILENO via read(2) — NOT via getchar()/stdio.
73 : *
74 : * Rationale: getchar() uses the C stdio buffer. When getchar() calls
75 : * read(2) with VMIN=0 VTIME=1 and gets a 0-byte return (timeout for bare
76 : * ESC), stdio marks the FILE* EOF flag. All subsequent getchar() calls
77 : * then return EOF immediately without blocking, causing an infinite
78 : * redraw loop in the TUI. Using read(2) directly bypasses the stdio
79 : * layer entirely and avoids the EOF flag problem.
80 : *
81 : * Returns the byte value [0..255], or -1 on error/timeout.
82 : */
83 183 : static int read_byte(void) {
84 : unsigned char c;
85 183 : ssize_t n = read(STDIN_FILENO, &c, 1);
86 183 : return (n == 1) ? (int)c : -1;
87 : }
88 :
89 : static int g_last_printable = 0;
90 :
91 87 : int terminal_last_printable(void) { return g_last_printable; }
92 :
93 121 : TermKey terminal_read_key(void) {
94 : /* The terminal must already be in raw mode (VMIN=1, VTIME=0). */
95 121 : g_last_printable = 0;
96 121 : int c = read_byte();
97 121 : TermKey result = TERM_KEY_IGNORE; /* unknown input → silent no-op */
98 :
99 121 : if (c == '\033') {
100 : /* Temporarily switch to VMIN=0 VTIME=1 (100 ms timeout) to drain
101 : * the escape sequence without blocking if it is a bare ESC. */
102 : struct termios t;
103 27 : tcgetattr(STDIN_FILENO, &t);
104 27 : struct termios drain = t;
105 27 : drain.c_cc[VMIN] = 0;
106 27 : drain.c_cc[VTIME] = 1;
107 27 : tcsetattr(STDIN_FILENO, TCSANOW, &drain);
108 :
109 27 : int c2 = read_byte();
110 27 : if (c2 == '[') {
111 23 : int c3 = read_byte();
112 23 : switch (c3) {
113 3 : case 'A': result = TERM_KEY_PREV_LINE; break; /* ESC[A — Up arrow */
114 2 : case 'B': result = TERM_KEY_NEXT_LINE; break; /* ESC[B — Down arrow */
115 1 : case 'C': result = TERM_KEY_RIGHT; break; /* ESC[C — Right arrow */
116 5 : case 'D': result = TERM_KEY_LEFT; break; /* ESC[D — Left arrow */
117 2 : case 'H': result = TERM_KEY_HOME; break; /* ESC[H — Home */
118 2 : case 'F': result = TERM_KEY_END; break; /* ESC[F — End */
119 1 : case '1': { /* ESC[1~ Home or ESC[1;... */
120 1 : int c4 = read_byte();
121 1 : if (c4 == '~') result = TERM_KEY_HOME;
122 0 : else result = TERM_KEY_IGNORE; /* ESC[1;... modifier */
123 1 : break;
124 : }
125 1 : case '3': read_byte(); result = TERM_KEY_DELETE; break; /* ESC[3~ Del */
126 1 : case '4': read_byte(); result = TERM_KEY_END; break; /* ESC[4~ End */
127 1 : case '5': read_byte(); result = TERM_KEY_PREV_PAGE; break; /* ESC[5~ PgUp */
128 1 : case '6': read_byte(); result = TERM_KEY_NEXT_PAGE; break; /* ESC[6~ PgDn */
129 1 : case '7': read_byte(); result = TERM_KEY_HOME; break; /* ESC[7~ Home */
130 1 : case '8': read_byte(); result = TERM_KEY_END; break; /* ESC[8~ End */
131 1 : default:
132 1 : if (c3 != -1) {
133 : int ch;
134 2 : while ((ch = read_byte()) != -1) {
135 2 : if ((ch >= 'A' && ch <= 'Z') ||
136 2 : (ch >= 'a' && ch <= 'z') || ch == '~') break;
137 : }
138 : }
139 1 : result = TERM_KEY_IGNORE;
140 1 : break;
141 : }
142 4 : } else if (c2 == 'O') {
143 3 : int c3 = read_byte();
144 3 : switch (c3) {
145 1 : case 'H': result = TERM_KEY_HOME; break; /* ESC O H — Home */
146 1 : case 'F': result = TERM_KEY_END; break; /* ESC O F — End */
147 1 : default: result = TERM_KEY_IGNORE; break;
148 : }
149 : } else {
150 1 : result = TERM_KEY_ESC; /* bare ESC — go back */
151 : }
152 :
153 : /* Restore VMIN=1 VTIME=0 raw mode. */
154 27 : t.c_cc[VMIN] = 1;
155 27 : t.c_cc[VTIME] = 0;
156 27 : tcsetattr(STDIN_FILENO, TCSANOW, &t);
157 94 : } else if (c == '\n' || c == '\r') {
158 11 : result = TERM_KEY_ENTER;
159 83 : } else if (c == 3 /* Ctrl-C */) {
160 3 : result = TERM_KEY_QUIT;
161 80 : } else if (c == 4 /* Ctrl-D */) {
162 11 : result = TERM_KEY_CTRL_D;
163 69 : } else if (c == 1 /* Ctrl-A */) {
164 1 : result = TERM_KEY_CTRL_A;
165 68 : } else if (c == 5 /* Ctrl-E */) {
166 1 : result = TERM_KEY_CTRL_E;
167 67 : } else if (c == 11 /* Ctrl-K */) {
168 2 : result = TERM_KEY_CTRL_K;
169 65 : } else if (c == 23 /* Ctrl-W */) {
170 2 : result = TERM_KEY_CTRL_W;
171 63 : } else if (c == 127 || c == 8 /* DEL / Backspace */) {
172 2 : result = TERM_KEY_BACK;
173 61 : } else if (c >= 32 && c <= 126) {
174 61 : g_last_printable = c;
175 61 : result = TERM_KEY_IGNORE;
176 : }
177 : /* c == -1 (read error/timeout) → result stays TERM_KEY_IGNORE */
178 :
179 121 : return result;
180 : }
181 :
182 1779 : int terminal_wcwidth(uint32_t cp) {
183 1779 : int w = wcwidth((wchar_t)cp);
184 1779 : return (w < 0) ? 0 : w;
185 : }
186 :
187 37 : int terminal_wait_key(int timeout_ms) {
188 37 : struct pollfd pfd = { .fd = STDIN_FILENO, .events = POLLIN, .revents = 0 };
189 37 : int rc = poll(&pfd, 1, timeout_ms);
190 37 : if (rc < 0) {
191 : /* EINTR (e.g. SIGWINCH) is not a hard error — caller retries. */
192 3 : return (errno == EINTR) ? -1 : -1;
193 : }
194 34 : if (rc == 0) return 0;
195 34 : return (pfd.revents & POLLIN) ? 1 : 0;
196 : }
197 :
198 : /* ---- SIGTERM / SIGHUP / SIGINT cleanup handlers ---- */
199 :
200 : /**
201 : * Pointer to the saved termios inside the active TermRawState.
202 : * Written once from terminal_install_cleanup_handlers() before
203 : * any signal can arrive; read only from signal handlers thereafter.
204 : * volatile to prevent the compiler from caching the load.
205 : */
206 : static volatile struct termios *g_saved_termios = NULL;
207 :
208 : /**
209 : * Async-signal-safe terminal cleanup and re-raise.
210 : *
211 : * Uses only async-signal-safe functions:
212 : * tcsetattr(3) — POSIX async-signal-safe
213 : * write(2) — POSIX async-signal-safe
214 : * signal(2) — POSIX async-signal-safe
215 : * raise(3) — POSIX async-signal-safe
216 : */
217 0 : static void cleanup_signal_handler(int sig) {
218 : /* Restore cooked mode if we have a saved state. */
219 0 : if (g_saved_termios) {
220 : /* Cast away volatile: tcsetattr requires a non-volatile pointer.
221 : * The cast is safe because we only write this pointer once and
222 : * the pointed-to struct outlives the signal handler. */
223 : struct termios saved;
224 0 : saved = *(struct termios *)g_saved_termios;
225 0 : tcsetattr(STDIN_FILENO, TCSANOW, &saved);
226 : }
227 : /* Show the cursor (ESC[?25h) — write(2) is async-signal-safe.
228 : * Ignore the return value: if the write fails there is nothing
229 : * sensible to do inside a signal handler. */
230 : static const char show_cursor[] = "\033[?25h";
231 0 : ssize_t _wr_unused = write(STDOUT_FILENO, show_cursor,
232 : sizeof(show_cursor) - 1);
233 : (void)_wr_unused;
234 : /* Reset to default handler and re-raise so the shell sees the
235 : * correct exit status (e.g. 128 + SIGTERM). */
236 0 : signal(sig, SIG_DFL);
237 0 : raise(sig);
238 0 : }
239 :
240 6 : void terminal_install_cleanup_handlers(TermRawState *state) {
241 6 : if (!state) return;
242 : /* Expose the saved termios to the signal handler. */
243 5 : g_saved_termios = &state->saved;
244 :
245 : struct sigaction sa;
246 5 : memset(&sa, 0, sizeof(sa));
247 5 : sa.sa_handler = cleanup_signal_handler;
248 5 : sigemptyset(&sa.sa_mask);
249 5 : sa.sa_flags = 0; /* no SA_RESTART — we re-raise immediately */
250 :
251 5 : sigaction(SIGTERM, &sa, NULL);
252 5 : sigaction(SIGHUP, &sa, NULL);
253 5 : sigaction(SIGINT, &sa, NULL);
254 : }
255 :
256 : /* ---- SIGWINCH / resize notifications ---- */
257 :
258 : static volatile sig_atomic_t g_resize_pending = 0;
259 : static int g_resize_handler_installed = 0;
260 :
261 5 : static void resize_handler(int sig) {
262 : (void)sig;
263 5 : g_resize_pending = 1;
264 5 : }
265 :
266 8 : void terminal_enable_resize_notifications(void) {
267 8 : if (g_resize_handler_installed) return;
268 : struct sigaction sa;
269 6 : memset(&sa, 0, sizeof(sa));
270 6 : sa.sa_handler = resize_handler;
271 6 : sigemptyset(&sa.sa_mask);
272 : /* No SA_RESTART on purpose: we want blocking read(2) in
273 : * terminal_read_key to return with EINTR so the TUI loop can
274 : * observe the resize between keystrokes. */
275 6 : sa.sa_flags = 0;
276 6 : sigaction(SIGWINCH, &sa, NULL);
277 6 : g_resize_handler_installed = 1;
278 : }
279 :
280 13 : int terminal_consume_resize(void) {
281 13 : if (g_resize_pending) {
282 5 : g_resize_pending = 0;
283 5 : return 1;
284 : }
285 8 : return 0;
286 : }
287 :
288 12 : int terminal_read_password(const char *prompt, char *buf, size_t size) {
289 12 : if (!buf || size == 0) return -1;
290 :
291 10 : int fd = fileno(stdin);
292 10 : int is_tty = isatty(fd);
293 :
294 10 : if (is_tty) {
295 7 : printf("%s: ", prompt);
296 7 : fflush(stdout);
297 :
298 : struct termios oldt, newt;
299 7 : tcgetattr(fd, &oldt);
300 7 : newt = oldt;
301 7 : newt.c_lflag &= ~(unsigned)ECHO;
302 7 : tcsetattr(fd, TCSANOW, &newt);
303 :
304 7 : char *line = NULL;
305 7 : size_t len = 0;
306 7 : ssize_t nread = getline(&line, &len, stdin);
307 :
308 7 : tcsetattr(fd, TCSANOW, &oldt);
309 7 : printf("\n");
310 :
311 7 : if (nread == -1 || !line) {
312 1 : free(line);
313 1 : return -1;
314 : }
315 :
316 : /* Strip trailing newline */
317 6 : size_t slen = strlen(line);
318 6 : if (slen > 0 && (line[slen-1] == '\n' || line[slen-1] == '\r'))
319 6 : line[--slen] = '\0';
320 6 : if (slen > 0 && (line[slen-1] == '\r'))
321 0 : line[--slen] = '\0';
322 :
323 6 : if (slen >= size) slen = size - 1;
324 6 : memcpy(buf, line, slen);
325 6 : buf[slen] = '\0';
326 6 : free(line);
327 6 : return (int)slen;
328 : } else {
329 : /* Non-TTY: read from stdin without echo manipulation */
330 3 : char *line = NULL;
331 3 : size_t len = 0;
332 3 : ssize_t nread = getline(&line, &len, stdin);
333 3 : if (nread == -1 || !line) {
334 2 : free(line);
335 2 : return -1;
336 : }
337 1 : size_t slen = strlen(line);
338 1 : if (slen > 0 && (line[slen-1] == '\n' || line[slen-1] == '\r'))
339 1 : line[--slen] = '\0';
340 1 : if (slen > 0 && (line[slen-1] == '\r'))
341 0 : line[--slen] = '\0';
342 1 : if (slen >= size) slen = size - 1;
343 1 : memcpy(buf, line, slen);
344 1 : buf[slen] = '\0';
345 1 : free(line);
346 1 : return (int)slen;
347 : }
348 : }
|