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 }