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