1 module tui; 2 3 import core.sys.posix.signal : SIGINT; 4 import core.sys.posix.sys.ioctl : ioctl, TIOCGWINSZ, winsize; 5 import core.sys.posix.termios : ECHO, ICANON, tcgetattr, TCSAFLUSH, TCSANOW, tcsetattr, termios; 6 import std.algorithm : countUntil, find, max, min; 7 import std.array : appender, array; 8 import std.conv : to; 9 import std.exception : enforce, errnoEnforce; 10 import std.math.algebraic : abs; 11 import std.range : cycle, empty, front, popFront, split; 12 import std.string : format, join; 13 import std.typecons : Tuple; 14 import tui.kittykeyboardprotocol : Tokenizer; 15 public import tui.kittykeyboardprotocol : KeyInput, KITTY_KEYBOARD_DISABLE, 16 KITTY_KEYBOARD_ENABLE, Key, Modifier, EventType; 17 18 version (unittest) 19 { 20 import unit_threaded; 21 } 22 23 alias Position = Tuple!(int, "x", int, "y"); 24 alias Dimension = Tuple!(int, "width", int, "height"); /// https://en.wikipedia.org/wiki/ANSI_escape_code 25 enum SIGWINCH = 28; 26 @safe auto next(Range)(Range r) 27 { 28 r.popFront; 29 return r.front; 30 } 31 32 @("next") unittest 33 { 34 auto range = [1, 2, 3]; 35 range.next.should == 2; 36 range.next.should == 2; 37 } 38 39 enum Operation : string 40 { 41 CURSOR_UP = "A", 42 CURSOR_DOWN = "B", 43 CURSOR_FORWARD = "C", 44 CURSOR_BACKWARD = "D", 45 CURSOR_POSITION = "H", 46 ERASE_IN_DISPLAY = "J", 47 ERASE_IN_LINE = "K", 48 DEVICE_STATUS_REPORT = "n", 49 CURSOR_POSITION_REPORT = "R", 50 CLEAR_TERMINAL = "2J", 51 CLEAR_LINE = "2K", 52 } 53 54 enum State : string 55 { 56 CURSOR = "?25", 57 ALTERNATE_BUFFER = "?1049", 58 } 59 60 enum Mode : string 61 { 62 LOW = "l", 63 HIGH = "h", 64 } 65 66 string execute(Operation operation, string[] args...) 67 { 68 return "\x1b[" ~ args.join(";") ~ operation; 69 } 70 71 string to(State state, Mode mode) 72 { 73 return "\x1b[" ~ state ~ mode; 74 } 75 76 __gshared Terminal INSTANCE; 77 78 extern (C) void signal(int sig, void function(int)); 79 extern (C) void ctrlC(int s) 80 { 81 import core.sys.posix.unistd : write; 82 83 INSTANCE.ctrlCSignalFD().write(&s, s.sizeof); 84 } 85 86 class SelectSet 87 { 88 import core.stdc.errno : EINTR, errno; 89 import core.sys.posix.sys.select : FD_ISSET, FD_SET, fd_set, FD_ZERO, select; 90 91 fd_set fds; 92 int maxFD; 93 this() 94 { 95 FD_ZERO(&fds); 96 maxFD = 0; 97 } 98 99 void addFD(int fd) 100 { 101 FD_SET(fd, &fds); 102 maxFD = max(fd, maxFD); 103 } 104 105 int readyForRead() 106 { 107 return select(maxFD + 1, &fds, null, null, null); 108 } 109 110 bool isSet(int fd) 111 { 112 return FD_ISSET(fd, &fds); 113 } 114 } 115 116 class Terminal 117 { 118 int stdinFD; 119 int stdoutFD; 120 termios originalState; 121 auto buffer = appender!(char[])(); 122 /// used to handle signals 123 int[2] selfSignalFDs; 124 int ctrlCSignalFD() 125 { 126 return selfSignalFDs[1]; 127 } 128 129 /// used to run delegates in the input handling thread 130 int[2] terminalThreadFDs; 131 void delegate()[] terminalThreadDelegates; 132 133 this(int stdinFD = 0, int stdoutFD = 1) 134 { 135 import core.sys.posix.unistd : pipe; 136 137 auto result = pipe(this.selfSignalFDs); 138 (result != -1).errnoEnforce("Cannot create pipe for signal handling"); 139 140 result = pipe(this.terminalThreadFDs); 141 (result != -1).errnoEnforce("Cannot create pipe for run in terminal input thread"); 142 143 this.stdinFD = stdinFD; 144 this.stdoutFD = stdoutFD; 145 146 (tcgetattr(stdoutFD, &originalState) == 0).errnoEnforce("Cannot get termios"); 147 148 termios newState = originalState; 149 newState.c_lflag &= ~ECHO & ~ICANON; 150 (tcsetattr(stdoutFD, TCSAFLUSH, &newState) == 0).errnoEnforce( 151 "Cannot set new termios state"); 152 153 wDirect(State.ALTERNATE_BUFFER.to(Mode.HIGH), "Cannot switch to alternate buffer"); 154 wDirect(Operation.CLEAR_TERMINAL.execute, "Cannot clear terminal"); 155 wDirect(State.CURSOR.to(Mode.LOW), "Cannot hide cursor"); 156 wDirect(KITTY_KEYBOARD_ENABLE, "Cannot enable kitty keyboard protocol"); 157 158 INSTANCE = this; 159 2.signal(&ctrlC); 160 } 161 162 ~this() 163 { 164 wDirect(KITTY_KEYBOARD_DISABLE, "Cannot disable kitty keyboard protocol"); 165 wDirect(Operation.CLEAR_TERMINAL.execute, "Cannot clear alternate buffer"); 166 wDirect(State.ALTERNATE_BUFFER.to(Mode.LOW), "Cannot switch to normal buffer"); 167 wDirect(State.CURSOR.to(Mode.HIGH), "Cannot show cursor"); 168 169 (tcsetattr(stdoutFD, TCSANOW, &originalState) == 0).errnoEnforce( 170 "Cannot set original termios state"); 171 import core.sys.posix.unistd : close; 172 173 selfSignalFDs[0].close(); 174 selfSignalFDs[1].close(); 175 176 terminalThreadFDs[0].close(); 177 terminalThreadFDs[1].close(); 178 } 179 180 auto putString(string s) 181 { 182 w(s); 183 return this; 184 } 185 186 auto xy(int x, int y) 187 { 188 w(Operation.CURSOR_POSITION.execute((y + 1).to!string, (x + 1).to!string)); 189 return this; 190 } 191 192 final void wDirect(string data, lazy string errorMessage) 193 { 194 import core.sys.posix.unistd : write; 195 196 (2.write(data.ptr, data.length) == data.length).errnoEnforce(errorMessage); 197 } 198 199 final void w(string data) 200 { 201 buffer.put(cast(char[]) data); 202 } 203 204 auto clearBuffer() 205 { 206 buffer.clear; 207 w(Operation.CLEAR_TERMINAL.execute); 208 return this; 209 } 210 211 auto flip() 212 { 213 auto data = buffer.data; 214 // was 2 ??? 215 import core.sys.posix.unistd : write; 216 217 (2.write(data.ptr, data.length) == data.length).errnoEnforce("Cannot blit data"); 218 return this; 219 } 220 221 Dimension dimension() 222 { 223 winsize ws; 224 (ioctl(stdoutFD, TIOCGWINSZ, &ws) == 0).errnoEnforce("Cannot get winsize"); 225 return Dimension(ws.ws_col, ws.ws_row); 226 } 227 228 void runInTerminalThread(void delegate() d) 229 { 230 synchronized (this) 231 { 232 terminalThreadDelegates ~= d; 233 } 234 ubyte h = 0; 235 import core.sys.posix.unistd : write; 236 237 (terminalThreadFDs[1].write(&h, h.sizeof) == h.sizeof).errnoEnforce( 238 "Cannot write ubyte to terminalThreadFD"); 239 } 240 241 immutable(KeyInput) getInput() 242 { 243 import core.sys.posix.unistd : read; 244 245 Tokenizer tokenizer = new Tokenizer(); 246 while (true) 247 { 248 // osx needs to do select when working with /dev/tty https://nathancraddock.com/blog/macos-dev-tty-polling/ 249 scope sel = new SelectSet(); 250 sel.addFD(selfSignalFDs[0]); 251 sel.addFD(terminalThreadFDs[0]); 252 sel.addFD(stdinFD); 253 254 int result = sel.readyForRead(); 255 if (result == -1) 256 return KeyInput.fromInterrupt(); 257 258 if (sel.isSet(selfSignalFDs[0])) 259 { 260 int buf; 261 auto count = selfSignalFDs[0].read(&buf, buf.sizeof); 262 (count == buf.sizeof).errnoEnforce("Cannot read on self signal fds read end"); 263 return KeyInput.fromCtrlC(); 264 } 265 266 if (sel.isSet(terminalThreadFDs[0])) 267 { 268 ubyte buf; 269 auto count = read(terminalThreadFDs[0], &buf, buf.sizeof); 270 (count == buf.sizeof).errnoEnforce(format("Cannot read next delegate on fd %s", 271 terminalThreadFDs[0])); 272 if (terminalThreadDelegates.length > 0) 273 { 274 void delegate() h; 275 synchronized (this) 276 { 277 h = terminalThreadDelegates[0]; 278 terminalThreadDelegates = terminalThreadDelegates[1 .. $]; 279 } 280 h(); 281 } 282 // delegate ran — loop back to check for input 283 continue; 284 } 285 286 if (sel.isSet(stdinFD)) 287 { 288 byte b; 289 auto count = stdinFD.read(&b, 1); 290 (count != -1).errnoEnforce("Cannot read next input byte"); 291 if (count == 0) 292 { 293 continue; 294 } 295 auto keyInput = tokenizer.feed(b); 296 if (keyInput) 297 { 298 return keyInput; 299 } 300 } 301 } 302 } 303 } 304 305 alias InputHandler = bool delegate(KeyInput input); 306 abstract class Component 307 { 308 Component parent; 309 Component[] children; 310 311 // the root of a component hierarchy carries all focusComponents, 312 // atm those have to be registered manually via 313 // addToFocusComponents. 314 Component focusPath; 315 316 // component that is really focused atm 317 Component currentFocusedComponent; 318 // stores the focused component in case a popup is pushed 319 Component lastFocusedComponent; 320 321 InputHandler inputHandler; 322 323 int left; /// Left position of the component relative to the parent 324 int top; /// Top position of the component relative to the parent 325 int width; /// Width of the component 326 int height; /// Height of the component 327 328 this(Component[] children = null) 329 { 330 this.children = children; 331 foreach (child; children) 332 { 333 child.setParent(this); 334 } 335 } 336 337 void clearFocus() 338 { 339 this.currentFocusedComponent = null; 340 foreach (Component c; children) 341 { 342 c.clearFocus(); 343 } 344 } 345 346 void setInputHandler(InputHandler inputHandler) 347 { 348 this.inputHandler = inputHandler; 349 } 350 351 void resize(int left, int top, int width, int height) 352 { 353 this.left = left; 354 this.top = top; 355 this.width = width; 356 this.height = height; 357 } 358 359 void setParent(Component parent) 360 { 361 this.parent = parent; 362 } 363 364 abstract void render(Context context); 365 bool handlesInput() 366 { 367 return true; 368 } 369 370 bool focusable() 371 { 372 return false; 373 } 374 375 bool handleInput(KeyInput input) 376 { 377 if (input.key == Key.tab && input.eventType == EventType.press) 378 { 379 focusNext(); 380 return true; 381 } 382 if (focusPath !is null && focusPath.handleInput(input)) 383 { 384 // does the parent (e.g. scroller) handle the input 385 return true; 386 } 387 if (inputHandler !is null && inputHandler(input)) 388 { 389 // does the installed input handler want to handle the key input 390 return true; 391 } 392 return false; 393 } 394 395 // establishes the input handling path from current focused 396 // child to the root component 397 void requestFocus() 398 { 399 currentFocusedComponent = this; 400 if (this.parent !is null) 401 { 402 this.parent.buildFocusPath(this, this); 403 } 404 } 405 406 void buildFocusPath(Component focusedComponent, Component path) 407 { 408 enforce(children.countUntil(path) >= 0, "Cannot find child"); 409 this.focusPath = path; 410 if (this.currentFocusedComponent !is null) 411 { 412 this.currentFocusedComponent.currentFocusedComponent = focusedComponent; 413 } 414 this.currentFocusedComponent = focusedComponent; 415 if (this.parent !is null) 416 { 417 this.parent.buildFocusPath(focusedComponent, this); 418 } 419 } 420 421 void focusNext() 422 { 423 if (parent is null) 424 { 425 auto components = findAllFocusableComponents(); 426 if (components.empty) 427 { 428 return; 429 } 430 if (currentFocusedComponent is null) 431 { 432 components.front.requestFocus; 433 } 434 else 435 { 436 components.cycle.find(currentFocusedComponent).next.requestFocus; 437 } 438 } 439 else 440 { 441 parent.focusNext(); 442 } 443 } 444 445 private Component[] findAllFocusableComponents(Component[] result = null) 446 { 447 if (focusable()) 448 { 449 result ~= this; 450 } 451 foreach (child; children) 452 { 453 result = child.findAllFocusableComponents(result); 454 } 455 return result; 456 } 457 } 458 459 string dropIgnoreAnsiEscapes(string s, int n) 460 { 461 string result; 462 bool inColorAnsiEscape = false; 463 int count = 0; 464 465 if (n < 0) 466 { 467 n = -n; 468 result = s; 469 for (int i = 0; i < n; ++i) 470 { 471 result = " " ~ result; 472 } 473 return result; 474 } 475 476 while (!s.empty) 477 { 478 auto current = s.front; 479 if (current == 27) 480 { 481 inColorAnsiEscape = true; 482 result ~= current; 483 } 484 else 485 { 486 if (inColorAnsiEscape) 487 { 488 if (current == 'm') 489 { 490 inColorAnsiEscape = false; 491 } 492 result ~= current; 493 } 494 else 495 { 496 if (count >= n) 497 { 498 result ~= current; 499 } 500 count++; 501 } 502 } 503 s.popFront; 504 } 505 return result; 506 } 507 508 @("dropIgnoreAnsiEscapes/basic") unittest 509 { 510 import unit_threaded; 511 512 "abc".dropIgnoreAnsiEscapes(1).should == "bc"; 513 } 514 515 @("dropIgnoreAnsiEscapes/basicWithAnsi") unittest 516 { 517 import unit_threaded; 518 519 "a\033[123mbcdefghijkl".dropIgnoreAnsiEscapes(3).should == "\033[123mdefghijkl"; 520 } 521 522 @("dropIgnoreAnsiEscapes/dropAll") unittest 523 { 524 import unit_threaded; 525 526 "abc".dropIgnoreAnsiEscapes(4).should == ""; 527 } 528 529 @("dropIgnoreAnsiEscapes/negativeNumber") unittest 530 { 531 import unit_threaded; 532 533 "abc".dropIgnoreAnsiEscapes(-1).should == " abc"; 534 } 535 536 string takeIgnoreAnsiEscapes(string s, uint length) 537 { 538 string result; 539 uint count = 0; 540 bool inColorAnsiEscape = false; 541 while (!s.empty) 542 { 543 auto current = s.front; 544 if (current == 27) 545 { 546 inColorAnsiEscape = true; 547 result ~= current; 548 } 549 else 550 { 551 if (inColorAnsiEscape) 552 { 553 result ~= current; 554 if (current == 'm') 555 { 556 inColorAnsiEscape = false; 557 } 558 } 559 else 560 { 561 if (count < length) 562 { 563 result ~= current; 564 count++; 565 } 566 } 567 } 568 s.popFront; 569 } 570 return result; 571 } 572 573 @("takeIgnoreAnsiEscapes") unittest 574 { 575 import unit_threaded; 576 577 "hello world".takeIgnoreAnsiEscapes(5).should == "hello"; 578 "he\033[123mllo world\033[0m".takeIgnoreAnsiEscapes(5).should == "he\033[123mllo\033[0m"; 579 "köstlin".takeIgnoreAnsiEscapes(10).should == "köstlin"; 580 } 581 582 int clipTo(int v, size_t maximum) 583 { 584 return min(v, maximum); 585 } 586 587 extern (C) void signal(int sig, void function(int)); 588 UiInterface theUi; 589 extern (C) void windowSizeChangedSignalHandler(int) 590 { 591 theUi.resized(); 592 } 593 594 abstract class UiInterface 595 { 596 void resized(); 597 } 598 599 struct Viewport 600 { 601 int x; 602 int y; 603 int width; 604 int height; 605 } 606 607 class Context 608 { 609 Terminal terminal; 610 int left; 611 int top; 612 int width; 613 int height; 614 Viewport viewport; 615 this(Terminal terminal, int left, int top, int width, int height) 616 { 617 this.terminal = terminal; 618 this.left = left; 619 this.top = top; 620 this.width = width; 621 this.height = height; 622 this.viewport = Viewport(0, 0, width, height); 623 } 624 625 this(Terminal terminal, int left, int top, int width, int height, Viewport viewport) 626 { 627 this.terminal = terminal; 628 this.left = left; 629 this.top = top; 630 this.width = width; 631 this.height = height; 632 this.viewport = viewport; 633 } 634 635 override string toString() 636 { 637 return "Context(left=%s, top=%s, width=%s, height=%s, viewport=%s)".format(left, 638 top, width, height, viewport); 639 } 640 641 auto forChild(Component c) 642 { 643 return new Context(terminal, this.left + c.left, this.top + c.top, c.width, c.height); 644 } 645 646 auto forChild(Component c, Viewport viewport) 647 { 648 return new Context(terminal, this.left + c.left, this.top + c.top, 649 c.width, c.height, viewport); 650 } 651 652 /// low level output (taking left/top, viewport and scroll into account) 653 auto putString(int x, int y, string s) 654 { 655 int scrolledY = y - viewport.y; 656 if (scrolledY < 0) 657 { 658 return this; 659 } 660 if (scrolledY >= viewport.height) 661 { 662 return this; 663 } 664 // dfmt off 665 terminal 666 .xy(left + x, top + scrolledY) 667 .putString( 668 s.dropIgnoreAnsiEscapes(viewport.x) 669 .takeIgnoreAnsiEscapes(viewport.width)); 670 // dfmt on 671 return this; 672 } 673 674 // see https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm#All_cases 675 void line(const(Position) from, const(Position) to, const(string) what) 676 { 677 const int dx = (to.x - from.x).abs; 678 const int stepX = from.x < to.x ? 1 : -1; 679 680 const int dy = -(to.y - from.y).abs; 681 const int stepY = from.y < to.y ? 1 : -1; 682 683 int error = dx + dy; 684 int x = from.x; 685 int y = from.y; 686 while (true) 687 { 688 putString(x, y, what); 689 if (x == to.x && y == to.y) 690 { 691 break; 692 } 693 const e2 = 2 * error; 694 if (e2 >= dy) 695 { 696 if (x == to.x) 697 { 698 break; 699 } 700 error += dy; 701 x += stepX; 702 } 703 if (e2 <= dx) 704 { 705 if (y == to.y) 706 { 707 break; 708 } 709 error += dx; 710 y += stepY; 711 } 712 } 713 } 714 } 715 716 class Ui : UiInterface 717 { 718 Terminal terminal; 719 Component[] roots; 720 this(Terminal terminal) 721 { 722 this.terminal = terminal; 723 theUi = this; 724 signal(SIGWINCH, &windowSizeChangedSignalHandler); 725 } 726 727 auto push(Component root) 728 { 729 if (!roots.empty) 730 { 731 auto oldRoot = roots[$ - 1]; 732 oldRoot.lastFocusedComponent = oldRoot.currentFocusedComponent; 733 oldRoot.clearFocus; 734 } 735 roots ~= root; 736 auto dimension = terminal.dimension; 737 root.resize(0, 0, dimension.width, dimension.height); 738 root.focusNext; 739 return this; 740 } 741 742 auto pop() 743 { 744 roots = roots[0 .. $ - 1]; 745 746 auto root = roots[$ - 1]; 747 root.lastFocusedComponent.requestFocus; 748 root.lastFocusedComponent = null; 749 return this; 750 } 751 752 void render() 753 { 754 try 755 { 756 terminal.clearBuffer(); 757 foreach (root; roots) 758 { 759 scope context = new Context(terminal, root.left, root.top, 760 root.width, root.height); 761 root.render(context); 762 } 763 terminal.flip; 764 } 765 catch (Exception e) 766 { 767 import std.experimental.logger : error; 768 769 e.to!string.error; 770 } 771 } 772 773 override void resized() 774 { 775 auto dimension = terminal.dimension; 776 foreach (root; roots) 777 { 778 root.resize(0, 0, dimension.width, dimension.height); 779 } 780 render; 781 } 782 783 void resize() 784 { 785 auto dimension = terminal.dimension; 786 foreach (root; roots) 787 { 788 root.resize(0, 0, dimension.width, dimension.height); 789 } 790 } 791 792 bool handleInput(KeyInput input) 793 { 794 return roots[$ - 1].handleInput(input); 795 } 796 } 797 798 struct Refresh 799 { 800 }