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 }