1 /**
2  * Termio module for dcell contains code associated iwth managing terminal settings such as
3  * non-blocking mode.
4  *
5  * Copyright: Copyright 2025 Garrett D'Amore
6  * Authors: Garrett D'Amore
7  * License:
8  *   Distributed under the Boost Software License, Version 1.0.
9  *   (See accompanying file LICENSE or https://www.boost.org/LICENSE_1_0.txt)
10  *   SPDX-License-Identifier: BSL-1.0
11  */
12 module dcell.termio;
13 
14 import std.algorithm;
15 import std.datetime;
16 import std.exception;
17 import std.range.interfaces;
18 import dcell.coord;
19 import dcell.tty;
20 
21 version (OSX)
22 {
23     version = UseSelect;
24 }
25 else version (iOS)
26 {
27     version = UseSelect;
28 }
29 else version (tvOS)
30 {
31     version = UseSelect;
32 }
33 else version (VisionOS)
34 {
35     version = UseSelect;
36 }
37 else
38 {
39     version = UsePoll;
40 }
41 
42 //dfmt off
43 version (Posix):
44 //dfmt on
45 
46 import core.sys.posix.sys.ioctl;
47 import core.sys.posix.termios;
48 import core.sys.posix.unistd;
49 import core.sys.posix.fcntl;
50 import std.process;
51 import std.stdio;
52 
53 /**
54  * PosixTty implements the TTY interface for POSIX systems, using a normal
55  * file descriptor and the termio facility found on such systems.
56  */
57 class PosixTty : Tty
58 {
59     /// Create a Tty device on a given device path. The usual path is "/dev/tty".
60     this(string dev) nothrow @safe
61     {
62         path = dev;
63     }
64 
65     /**
66      * Create a Tty device form a given file. This should support termios.
67      * One reason to do this is so that an explictly created file on /dev/tty
68      * can be used together with poll, select, epoll, and so forth.  It must
69      * pass the `isatty` check.
70      *
71      * Caution: on macOS the tty device does _not_ support the standard
72      * `poll` or `kqueue` APIs, but does support `select`.
73      *
74      * For more advanced use cases, consider implementing the tty interface
75      * directly.
76      */
77     this(File f) @safe
78     {
79         enforce(f.isOpen, "file is not open");
80         file = f;
81         fd = file.fileno();
82     }
83 
84     void start() @safe
85     {
86         if (!file.isOpen)
87         {
88             file = File(path, "r+b");
89             fd = file.fileno();
90         }
91         save();
92         watchResize(fd);
93     }
94 
95     void stop() @safe
96     {
97         if (file.isOpen())
98         {
99             ignoreResize(fd);
100             flush();
101         }
102     }
103 
104     void close() @safe
105     {
106         if (file.isOpen)
107         {
108             stop();
109             restore();
110             file.close();
111         }
112     }
113 
114     void save() @trusted
115     {
116         if (!isatty(fd))
117             throw new Exception("not a tty device");
118         enforce(tcgetattr(fd, &saved) >= 0, "failed to get termio state");
119     }
120 
121     void restore() @trusted
122     {
123         enforce(tcsetattr(fd, TCSAFLUSH, &saved) >= 0, "failed to set termio state");
124     }
125 
126     void flush() @safe
127     {
128         file.flush();
129     }
130 
131     void raw() @trusted
132     {
133         termios tio;
134         enforce(tcgetattr(fd, &tio) >= 0, "failed to get termio state");
135         tio.c_iflag &= ~(IGNBRK | BRKINT | ISTRIP | INLCR | IGNCR | ICRNL | IXON);
136         tio.c_oflag &= ~OPOST;
137         tio.c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN);
138         tio.c_cflag &= ~(CSIZE | PARENB);
139         tio.c_cflag |= CS8;
140         tio.c_cc[VMIN] = 1; // at least one character
141         tio.c_cc[VTIME] = 0; // but block forever
142         enforce(tcsetattr(fd, TCSANOW, &tio) >= 0, "failed to set termios");
143     }
144 
145     Coord windowSize() @trusted
146     {
147         // If cores.sys.posix.sys.ioctl had more complete and accurate data...
148         // this structure is fairly consistent amongst all POSIX variants
149         struct winSz
150         {
151             ushort ws_row;
152             ushort ws_col;
153             ushort ws_xpix;
154             ushort ws_ypix;
155         }
156 
157         version (linux)
158         {
159             // has TIOCGWINSZ already -- but it might be wrong
160             // Linux has different values for TIOCGWINSZ depending
161             // on architecture
162             // SPARC, PPC, and MIPS use legacy BSD based values.
163             // Others use a newer // value.
164             version (SPARC64)
165                 enum TIOCGWINSZ = 0x40087468;
166             else version (SPARC)
167                 enum TIOCGWINSZ = 0x40087468;
168             else version (PPC)
169                 enum TIOCGWINSZ = 0x40087468;
170             else version (PPC64)
171                 enum TIOCGWINSZ = 0x40087468;
172             else version (MIPS32)
173                 enum TIOCGWINSZ = 0x40087468;
174             else version (MIPS64)
175                 enum TIOCGWINSZ = 0x40087468;
176             else
177                 enum TIOCGWINSZ = 0x5413; // everything else
178         }
179         else version (Apple)
180             enum TIOCGWINSZ = 0x40087468;
181         else version (Solaris)
182             enum TIOCGWINSZ = 0x5468;
183         else version (OpenBSD)
184             enum TIOCGWINSZ = 0x40087468;
185         else version (DragonFlyBSD)
186             enum TIOCGWINSZ = 0x40087468;
187         else version (NetBSD)
188             enum TIOCGWINSZ = 0x40087468;
189         else version (FreeBSD)
190             enum TIOCGWINSZ = 0x40087468;
191         else version (AIX)
192             enum TIOCGWINSZ = 0x40087468;
193 
194         winSz wsz;
195         enforce(ioctl(fd, TIOCGWINSZ, &wsz) >= 0);
196         return Coord(wsz.ws_col, wsz.ws_row);
197     }
198 
199     // On macOS, we have to use a select() based implementation because poll()
200     // does not work reasonably on /dev/tty. (This was very astonishing when first
201     // we discovered it -- POLLNVAL for device files.)
202     version (UseSelect) string read(Duration dur = Duration.zero) @trusted
203     {
204         // this has to use the underlying read system call
205         import unistd = core.sys.posix.unistd;
206         import core.sys.posix.sys.select; // Or similar module for select bindings
207 
208         fd_set readFds;
209         timeval timeout;
210         timeval* tvp;
211 
212         FD_ZERO(&readFds);
213         FD_SET(fd, &readFds);
214         FD_SET(sigRfd, &readFds);
215 
216         if (dur == Duration.max)
217         {
218             tvp = null;
219         }
220         else
221         {
222             // at least 10us, not more than an hour.
223             dur = min(hours(1), max(dur, usecs(10)));
224             auto usecs = dur.total!"usecs";
225             assert(usecs > 0);
226 
227             timeout.tv_sec = cast(typeof(timeout.tv_sec))(usecs / 1_000_000);
228             timeout.tv_usec = cast(typeof(timeout.tv_usec))(usecs % 1_000_000);
229             tvp = &timeout;
230         }
231 
232         import std.algorithm : max;
233 
234         int num = select(max(fd, sigRfd) + 1, &readFds, null, null, tvp);
235         if (num < 1)
236         {
237             return "";
238         }
239 
240         string result;
241 
242         if (FD_ISSET(fd, &readFds))
243         {
244             ubyte[128] buf;
245             auto nread = unistd.read(fd, cast(void*) buf.ptr, buf.length);
246             if (nread > 0)
247             {
248                 result = cast(string)(buf[0 .. nread]).dup;
249             }
250         }
251         if (FD_ISSET(sigRfd, &readFds))
252         {
253             ubyte[1] buf;
254             // this can fail, we're just clearning the signaled state
255             unistd.read(sigRfd, buf.ptr, 1);
256         }
257         return result;
258     }
259 
260     version (UsePoll) string read(Duration dur = Duration.zero) @trusted
261     {
262         // this has to use the underlying read system call
263         import unistd = core.sys.posix.unistd;
264         import core.sys.posix.poll;
265         import core.sys.posix.fcntl;
266 
267         pollfd[2] pfd;
268         pfd[0].fd = fd;
269         pfd[0].events = POLLRDNORM;
270         pfd[0].revents = 0;
271 
272         pfd[1].fd = sigRfd;
273         pfd[1].events = POLLRDNORM;
274         pfd[1].revents = 0;
275 
276         int dly;
277         if (dur == Duration.max)
278         {
279             dly = -1;
280         }
281         else
282         {
283             // clip to a day to prevent overrun
284             dur = min(hours(24), dur);
285             dly = cast(int)(dur.total!"msecs");
286         }
287 
288         string result;
289 
290         long rv = poll(pfd.ptr, 2, dly);
291         if (rv < 1)
292         {
293             return result;
294         }
295         if (pfd[0].revents & POLLRDNORM)
296         {
297             ubyte[128] buf;
298             auto nread = unistd.read(fd, cast(void*) buf.ptr, buf.length);
299             if (nread > 0)
300             {
301                 result = cast(string)(buf[0 .. nread]).dup;
302             }
303         }
304         if (pfd[1].revents & POLLRDNORM)
305         {
306             ubyte[1] buf;
307             // this can fail, and its fine (just clearing the signaled state)
308             unistd.read(sigRfd, buf.ptr, 1);
309         }
310         import std.format;
311 
312         return result;
313     }
314 
315     void write(string s) @safe
316     {
317         file.write(s);
318     }
319 
320     bool resized() nothrow @safe @nogc
321     {
322         // NB: resized is edge triggered.
323         return wasResized(fd);
324     }
325 
326     void wakeUp() nothrow @trusted
327     {
328         import unistd = core.sys.posix.unistd;
329 
330         ubyte[1] buf;
331 
332         // we do not care if this fails
333         unistd.write(sigWfd, buf.ptr, 1);
334     }
335 
336 private:
337     string path;
338     File file;
339     int fd;
340     termios saved;
341     bool block;
342 }
343 
344 import core.atomic;
345 import core.sys.posix.signal;
346 
347 private:
348 
349 __gshared int sigRaised = 0;
350 __gshared int sigFd = -1;
351 __gshared Pipe sigPipe;
352 __gshared int sigWfd;
353 __gshared int sigRfd;
354 
355 extern (C) void handleSigWinCh(int _) nothrow
356 {
357     atomicStore(sigRaised, 1);
358 
359     // wake any reader so it can see the update
360     // this is crummy but its the best way to get this noticed.
361     ubyte[1] buf;
362     import unistd = core.sys.posix.unistd;
363 
364     // we do not care if this fails
365     unistd.write(sigWfd, buf.ptr, 1);
366 }
367 
368 // We don't have a standard definition of SIGWINCH
369 version (linux)
370 {
371     // Legacy Linux is not even self-compatible ick.
372     version (MIPS_Any)
373         enum SIGWINCH = 20;
374     else
375         enum SIGWINCH = 28;
376 }
377 else version (Solaris)
378     enum SIGWINCH = 20;
379 else version (OSX)
380     enum SIGWINCH = 28;
381 else version (FreeBSD)
382     enum SIGWINCH = 28;
383 else version (NetBSD)
384     enum SIGWINCH = 28;
385 else version (DragonFlyBSD)
386     enum SIGWINCH = 28;
387 else version (OpenBSD)
388     enum SIGWINCH = 28;
389 else version (AIX)
390     enum SIGWINCH = 28;
391 else
392     static assert(0, "no version");
393 
394 void watchResize(int fd) @trusted
395 {
396     import std.process;
397     import core.sys.posix.fcntl;
398 
399     if (atomicLoad(sigFd) == -1)
400     {
401         // create the pipe for notifications if not already done so.
402         sigPipe = pipe();
403         sigWfd = sigPipe.writeEnd.fileno();
404         sigRfd = sigPipe.readEnd.fileno();
405         fcntl(sigWfd, F_SETFL, O_NONBLOCK);
406         fcntl(sigRfd, F_SETFL, O_NONBLOCK);
407 
408         sigFd = fd;
409         sigaction_t sa;
410         sa.sa_handler = &handleSigWinCh;
411         sigaction(SIGWINCH, &sa, null);
412     }
413 }
414 
415 void ignoreResize(int fd) @trusted
416 {
417     if (atomicLoad(sigFd) == fd)
418     {
419         sigaction_t sa;
420         sa.sa_handler = SIG_IGN;
421         sigaction(SIGWINCH, &sa, null);
422         sigFd = -1;
423         sigPipe.close();
424     }
425 }
426 
427 bool wasResized(int fd) nothrow @trusted @nogc
428 {
429     if (fd == atomicLoad(sigFd) && fd != -1)
430     {
431         return atomicExchange(&sigRaised, 0) != 0;
432     }
433     else
434     {
435         return false;
436     }
437 }