1 module jcli.text.console;
2 
3 import jansi, jcli.text;
4 
5 import std.conv : to;
6 import std.stdio : stdout;
7 
8 version(Windows) import core.sys.windows.windows;
9 version(Posix) import core.sys.posix.termios, core.sys.posix.unistd, core.sys.posix.signal;
10 
11 enum ConsoleKey
12 {
13     unknown,
14 
15     a,  b,  c,  d,  e,  f,  g,
16     h,  i,  j,  k,  l,  m,  n,
17     o,  p,  q,  r,  s,  t,  u,
18     v,  w,  x,  y,  z,
19 
20     printScreen, scrollLock, pause,
21     insert,      home,       pageUp,
22     pageDown,    del,        end,
23 
24     up, down, left, right,
25 
26     escape, f1, f2, f3, f4, f5, f6, f7, f8, f9, f10, f11, f12,
27 
28     enter, back, tab
29 }
30 
31 struct ConsoleEventUnknown {}
32 struct ConsoleEventInterrupt {}
33 struct ConsoleKeyEvent
34 {
35     enum SpecialKey
36     {
37         capslock = 0x0080,
38         leftAlt = 0x0002,
39         leftCtrl = 0x0008,
40         numlock = 0x0020,
41         rightAlt = 0x0001,
42         rightCtrl = 0x0004,
43         scrolllock = 0x0040,
44         shift = 0x0010
45     }
46 
47     bool isDown;
48     uint repeatCount;
49     ConsoleKey key;
50     uint scancode;
51     union
52     {
53         dchar charAsUnicode;
54         char charAsAscii;
55     }
56     SpecialKey specialKeys;
57 }
58 
59 import std.sumtype;
60 
61 alias ConsoleEvent = SumType!(
62     ConsoleKeyEvent,
63     ConsoleEventInterrupt,
64     ConsoleEventUnknown
65 );
66 
67 final class Console
68 {
69     static:
70 
71     private bool _useAlternateBuffer;
72     private bool _wasControlC;
73     version(Windows) private
74     {
75         HANDLE _stdin = INVALID_HANDLE_VALUE;
76         DWORD _oldMode;
77         UINT _oldOutputCP;
78         UINT _oldInputCP;
79     }
80     version(Posix) private
81     {
82         bool _attached;
83         termios _oldIos;
84     }
85 
86     bool attach(bool useAlternativeBuffer = true)
87     {
88         _useAlternateBuffer = false;
89         version(Windows)
90         {
91             Console._stdin = GetStdHandle(STD_INPUT_HANDLE);
92             if(Console._stdin == INVALID_HANDLE_VALUE)
93                 return false;
94 
95             if(!GetConsoleMode(Console._stdin, &Console._oldMode))
96                 return false;
97 
98             if(!SetConsoleMode(Console._stdin, Console._oldMode | ENABLE_WINDOW_INPUT | ENABLE_MOUSE_INPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING))
99                 return false;
100 
101             this._oldInputCP = GetConsoleCP();
102             this._oldOutputCP = GetConsoleOutputCP(); 
103             SetConsoleOutputCP(CP_UTF8);
104             SetConsoleCP(CP_UTF8);
105 
106             SetConsoleCtrlHandler((ctrlType)
107             {
108                 if(ctrlType == CTRL_C_EVENT)
109                 {
110                     Console._wasControlC = true;
111                     return TRUE;
112                 }
113 
114                 return FALSE;
115             }, TRUE);
116 
117             if(_useAlternateBuffer)
118                 stdout.write("\033[?1049h");
119             return true;
120         }
121         else version(Posix)
122         {
123             if(_useAlternateBuffer)
124                 stdout.write("\033[?1049h");
125             tcgetattr(STDIN_FILENO, &_oldIos);
126             auto newIos = _oldIos;
127 
128             newIos.c_lflag &= ~ECHO;
129             newIos.c_lflag &= ~ICANON;
130             newIos.c_cc[VMIN] = 0;
131             newIos.c_cc[VTIME] = 1;
132 
133             tcsetattr(STDIN_FILENO, TCSAFLUSH, &newIos);
134             signal(SIGINT, (_){ Console._wasControlC = true; });
135 
136             this._attached = true;
137             return true;
138         }
139         else return false;
140     }
141 
142     void detach()
143     {
144         version(Windows)
145         {
146             if(!Console.isAttached)
147                 return;
148 
149             SetConsoleMode(Console._stdin, Console._oldMode);
150             SetConsoleOutputCP(this._oldOutputCP);
151             SetConsoleCP(this._oldInputCP);
152             SetConsoleCtrlHandler(null, FALSE);
153             Console._stdin = INVALID_HANDLE_VALUE;
154         }
155         else version(Posix)
156         {
157             if(!Console.isAttached)
158                 return;
159             this._attached = false;
160             tcsetattr(STDIN_FILENO, TCSAFLUSH, &_oldIos);
161             signal(SIGINT, SIG_DFL);
162         }
163 
164         if(_useAlternateBuffer)
165             stdout.write("\033[?1049l");
166     }
167 
168     bool isAttached()
169     {
170         version(Windows) return Console._stdin != INVALID_HANDLE_VALUE;
171         else version(Posix) return Console._attached;
172         else return false;
173     }
174 
175     void processEvents(void delegate(ConsoleEvent) handler)
176     {
177         assert(handler !is null, "A null handler was provided.");
178 
179         if(_wasControlC)
180         {
181             _wasControlC = false;
182             handler(ConsoleEvent(ConsoleEventInterrupt()));
183         }
184 
185         version(Windows)
186         {
187             INPUT_RECORD[8] events;
188             DWORD eventsRead;
189 
190             assert(Console.isAttached, "We're not attached to the console.");
191             if(!PeekConsoleInput(Console._stdin, &events[0], 1, &eventsRead))
192                 return;
193 
194             ReadConsoleInput(Console._stdin, &events[0], cast(DWORD)events.length, &eventsRead);
195             foreach(event; events[0..eventsRead])
196             {
197                 const e = Console.translateEvent(event);
198                 handler(e);
199             }
200         }
201         else version(Posix)
202         {
203             import core.sys.posix.unistd : read;
204 
205             char ch;
206             ssize_t bytesRead = read(STDIN_FILENO, &ch, 1);
207             while(bytesRead > 0 && Console.isAttached)
208             {
209                 handler(ConsoleEvent(Console.translateKeyEvent(ch)));
210 
211                 if(Console.isAttached)
212                     bytesRead = read(STDIN_FILENO, &ch, 1);
213             }
214         }
215     }
216 
217     void waitForInput()
218     {
219         assert(Console.isAttached, "We're not attached to the console.");
220         version(Windows)
221         {
222             WaitForSingleObject(Console._stdin, 0);
223         }
224     }
225 
226     void setCursor(uint x, uint y)
227     {
228         stdout.writef("\033[%s;%sH", y, x);
229     }
230 
231     void hideCursor()
232     {
233         stdout.write("\033[?25l");
234     }
235 
236     void showCursor()
237     {
238         stdout.write("\033[?25h");
239     }
240 
241     Vector screenSize()
242     {
243         version(Windows)
244         {
245             CONSOLE_SCREEN_BUFFER_INFO csbi;
246             GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &csbi);
247 
248             return Vector(
249                 csbi.srWindow.Right - csbi.srWindow.Left + 1,
250                 csbi.srWindow.Bottom - csbi.srWindow.Top + 1
251             );
252         }
253         else version(Posix)
254         {
255             import core.sys.posix.sys.ioctl, core.sys.posix.unistd, core.sys.posix.stdio;
256             winsize w;
257             ioctl(STDOUT_FILENO, TIOCGWINSZ, &w);
258             return Vector(w.ws_col, w.ws_row);
259         }
260         else return Vector(80, 20);
261     }
262 
263     void refreshHandler(uint row, const TextBufferCell[] rowCells)
264     {
265         import std.array;
266         static Appender!(char[]) builder;
267 
268         Console.setCursor(0, row.to!uint + 1);
269         builder.clear();
270 
271         foreach(i, cell; rowCells)
272         {
273             if(i == 0 || cell.style != rowCells[i-1].style)
274             {
275                 builder.put(ANSI_COLOUR_RESET);
276                 char[AnsiStyleSet.MAX_CHARS_NEEDED] buffer;
277                 builder.put(ANSI_CSI);
278                 builder.put(cell.style.toSequence(buffer));
279                 builder.put(ANSI_COLOUR_END);
280             }
281             builder.put(cell.ch[0..cell.chLen]);
282         }
283 
284         builder.put(ANSI_COLOUR_RESET);
285         stdout.write(builder.data);
286     }
287 
288     TextBuffer createTextBuffer()
289     {
290         assert(this.isAttached, "We're not attached to the console.");
291         auto buffer = new TextBuffer(Console.screenSize.x, Console.screenSize.y);
292         import std.functional : toDelegate;
293         buffer.onRefresh((&Console.refreshHandler).toDelegate);
294         return buffer;
295     }
296 
297     private version(Windows)
298     {
299         ConsoleEvent translateEvent(INPUT_RECORD event)
300         {
301             switch(event.EventType)
302             {
303                 case KEY_EVENT:
304                     const k = event.KeyEvent;
305                     auto e = ConsoleKeyEvent(
306                         cast(bool)k.bKeyDown,
307                         k.wRepeatCount,
308                         Console.translateKey(k.wVirtualKeyCode),
309                         k.wVirtualScanCode,
310                     );
311                     e.specialKeys = cast(ConsoleKeyEvent.SpecialKey)k.dwControlKeyState;
312                     e.charAsUnicode = k.UnicodeChar.to!dchar;
313 
314                     return ConsoleEvent(e);
315 
316                 default: return ConsoleEvent(ConsoleEventUnknown());
317             }
318         }
319 
320         // https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
321         ConsoleKey translateKey(uint keycode)
322         {
323             switch(keycode) with(ConsoleKey)
324             {
325                 case VK_TAB: return ConsoleKey.tab;
326                 case VK_SNAPSHOT: return ConsoleKey.printScreen;
327                 case VK_SCROLL: return ConsoleKey.scrollLock;
328                 case VK_PAUSE: return ConsoleKey.pause;
329                 case VK_INSERT: return ConsoleKey.insert;
330                 case VK_HOME: return ConsoleKey.home;
331                 case VK_DELETE: return ConsoleKey.del;
332                 case VK_END: return ConsoleKey.end;
333                 case VK_NEXT: return ConsoleKey.pageDown;
334                 case VK_PRIOR: return ConsoleKey.pageUp;
335 
336                 case VK_ESCAPE: return ConsoleKey.escape;
337                 case VK_F1:..case VK_F12:
338                     return cast(ConsoleKey)(cast(uint)ConsoleKey.f1 + (keycode - VK_F1));
339 
340                 case VK_RETURN: return ConsoleKey.enter;
341                 case VK_BACK: return ConsoleKey.back;
342 
343                 case VK_UP: return ConsoleKey.up;
344                 case VK_DOWN: return ConsoleKey.down;
345                 case VK_LEFT: return ConsoleKey.left;
346                 case VK_RIGHT: return ConsoleKey.right;
347 
348                 // a-z
349                 case 0x41:..case 0x5A:
350                     return cast(ConsoleKey)(cast(uint)ConsoleKey.a + (keycode - 0x41));
351 
352                 default: return unknown;
353             }
354         }
355     }
356 
357     private version(Posix)
358     {
359         ConsoleKeyEvent translateKeyEvent(char ch)
360         {
361             ConsoleKeyEvent event;
362             event.key = Console.translateKey(ch, event.charAsUnicode);
363             event.isDown = true;
364             event.charAsAscii = ch;
365 
366             return event;
367         }
368 
369         ConsoleKey translateKey(char firstCh, out dchar utf)
370         {
371             import core.sys.posix.unistd : read;
372 
373             // TODO: Unicode support.
374             switch(firstCh) with(ConsoleKey)
375             {
376                 case 0x1A: return ConsoleKey.pause;
377                 case '\t': return ConsoleKey.tab;
378 
379                 case 0x0A: return ConsoleKey.enter;
380                 case 0x7F: return ConsoleKey.back;
381 
382                 // a-z
383                 case 0x41:..case 0x5A:
384                     utf = firstCh;
385                     return cast(ConsoleKey)(cast(uint)ConsoleKey.a + (firstCh - 0x41));
386 
387                 case '\033':
388                     char ch;
389                     auto bytesRead = read(STDIN_FILENO, &ch, 1);
390                     if(bytesRead == 0)
391                         return escape;
392                     else if(ch == 'O' && read(STDIN_FILENO, &ch, 1) != 0 && ch >= 0x50 && ch <= 0x7E)
393                         return cast(ConsoleKey)(cast(uint)ConsoleKey.f1 + (ch - 0x50));
394                     else if(ch != '[')
395                         return unknown;
396 
397                     bytesRead = read(STDIN_FILENO, &ch, 1);
398                     if(bytesRead == 0)
399                         return unknown;
400 
401                     switch(ch)
402                     {
403                         case 'A': return ConsoleKey.up; 
404                         case 'B': return ConsoleKey.down; 
405                         case 'C': return ConsoleKey.right; 
406                         case 'D': return ConsoleKey.left;
407                         case 'H': return ConsoleKey.home;
408                         case 'F': return ConsoleKey.end;
409                         case '2':
410                             bytesRead = read(STDIN_FILENO, &ch, 1);
411                             if(bytesRead == 0 || ch != '~')
412                                 return unknown;
413                             return ConsoleKey.insert;
414                         case '3':
415                             bytesRead = read(STDIN_FILENO, &ch, 1);
416                             if(bytesRead == 0 || ch != '~')
417                                 return unknown;
418                             return ConsoleKey.del;
419                         case '5':
420                             bytesRead = read(STDIN_FILENO, &ch, 1);
421                             if(bytesRead == 0 || ch != '~')
422                                 return unknown;
423                             return ConsoleKey.pageUp;
424                         case '6':
425                             bytesRead = read(STDIN_FILENO, &ch, 1);
426                             if(bytesRead == 0 || ch != '~')
427                                 return unknown;
428                             return ConsoleKey.pageDown;
429 
430 
431 
432                         default: return unknown;
433                     }
434 
435                 default: return unknown;
436             }
437         }
438     }
439 }