1 /**
2  * TtyScreen module implements POSIX style terminals (ala XTerm).
3  *
4  * Copyright: Copyright 2022 Garrett D'Amore
5  * Authors: Garrett D'Amore
6  * License:
7  *   Distributed under the Boost Software License, Version 1.0.
8  *   (See accompanying file LICENSE or https://www.boost.org/LICENSE_1_0.txt)
9  *   SPDX-License-Identifier: BSL-1.0
10  */
11 module dcell.ttyscreen;
12 
13 package:
14 
15 import core.atomic;
16 import core.time;
17 import std.string;
18 import std.concurrency;
19 import std.exception;
20 import std.outbuffer;
21 import std.range;
22 import std.stdio;
23 
24 import dcell.cell;
25 import dcell.cursor;
26 import dcell.evqueue;
27 import dcell.key;
28 import dcell.mouse;
29 import dcell.termcap;
30 import dcell.database;
31 import dcell.termio;
32 import dcell.screen;
33 import dcell.event;
34 import dcell.parser;
35 import dcell.turnstile;
36 
37 class TtyScreen : Screen
38 {
39     this(TtyImpl tt, const(Termcap)* tc)
40     {
41         caps = tc;
42         ti = tt;
43         ti.start();
44         cells = new CellBuffer(ti.windowSize());
45         keys = ParseKeys(tc);
46         ob = new OutBuffer();
47         stopping = new Turnstile();
48         defStyle.bg = Color.reset;
49         defStyle.fg = Color.reset;
50     }
51 
52     ~this()
53     {
54         ti.stop();
55     }
56 
57     private void start(Tid tid, EventQueue eq)
58     {
59         if (started)
60             return;
61         stopping.set(false);
62         ti.save();
63         ti.raw();
64         puts(caps.clear);
65         resize();
66         draw();
67         spawn(&inputLoop, cast(shared TtyImpl) ti, keys, tid,
68                 cast(shared EventQueue) eq, cast(shared Turnstile) stopping);
69         started = true;
70     }
71 
72     void start(Tid tid)
73     {
74         start(tid, null);
75     }
76 
77     void start()
78     {
79         eq = new EventQueue();
80         start(Tid(), eq);
81     }
82 
83     void stop()
84     {
85         if (!started)
86             return;
87         puts(caps.resetColors);
88         puts(caps.attrOff);
89         puts(caps.cursorReset);
90         puts(caps.showCursor);
91         puts(caps.cursorReset);
92         puts(caps.clear);
93         puts(caps.disablePaste);
94         enableMouse(MouseEnable.disable);
95         flush();
96         stopping.set(true);
97         ti.blocking(false);
98         stopping.wait(false);
99         ti.blocking(true);
100         ti.restore();
101         started = false;
102     }
103 
104     void clear()
105     {
106         fill(" ");
107         clear_ = true;
108         // because we are going to clear it in the next cycle,
109         // lets mark all the cells clean, so that we don't waste
110         // needless time redrawing spaces for the entire screen.
111         cells.setAllDirty(false);
112     }
113 
114     void fill(string s, Style style)
115     {
116         cells.fill(s, style);
117     }
118 
119     void fill(string s)
120     {
121         fill(s, this.defStyle);
122     }
123 
124     void showCursor(Coord pos, Cursor cur = Cursor.current)
125     {
126         // just save the coordinates for now
127         // it will be used during the next draw cycle
128         cursorPos = pos;
129         cursorShape = cur;
130     }
131 
132     void showCursor(Cursor cur)
133     {
134         cursorShape = cur;
135     }
136 
137     Coord size() pure const
138     {
139         return (cells.size());
140     }
141 
142     void resize()
143     {
144         auto phys = ti.windowSize();
145         if (phys != cells.size())
146         {
147             cells.resize(phys);
148             cells.setAllDirty(true);
149         }
150     }
151 
152     ref Cell opIndex(size_t x, size_t y)
153     {
154         return (cells[x, y]);
155     }
156 
157     void opIndexAssign(Cell c, size_t x, size_t y)
158     {
159         cells[x, y] = c;
160     }
161 
162     void enablePaste(bool b)
163     {
164         pasteEn = b;
165         sendPasteEnable(b);
166     }
167 
168     bool hasMouse() const pure
169     {
170         return caps.mouse != "";
171     }
172 
173     int colors() const pure
174     {
175         return caps.colors;
176     }
177 
178     void show()
179     {
180         resize();
181         draw();
182     }
183 
184     void sync()
185     {
186         pos_ = Coord(-1, -1);
187         resize();
188         clear_ = true;
189         cells.setAllDirty(true);
190         draw();
191     }
192 
193     void beep()
194     {
195         puts(caps.bell);
196         flush();
197     }
198 
199     void setStyle(Style style)
200     {
201         defStyle = style;
202     }
203 
204     void setSize(Coord size)
205     {
206         if (caps.setWindowSize != "")
207         {
208             puts(caps.setWindowSize, size.x, size.y);
209             flush();
210             cells.setAllDirty(true);
211             resize();
212         }
213     }
214 
215     bool hasKey(Key k) const pure
216     {
217         return (keys.hasKey(k));
218     }
219 
220     void enableMouse(MouseEnable en)
221     {
222         // we rely on the fact that all known implementations adhere
223         // to the de-facto standard from XTerm.  This is necessary as
224         // there is no standard terminfo sequence for reporting this
225         // information.
226         if (caps.mouse != "")
227         {
228             mouseEn = en; // save this so we can restore after a suspend
229             sendMouseEnable(en);
230         }
231     }
232 
233     Event receiveEvent(Duration dur)
234     {
235         if (eq is null)
236         {
237             return Event(EventType.error);
238         }
239         return eq.receive(dur);
240     }
241 
242     /** This variant of receiveEvent blocks forever until an event is available. */
243     Event receiveEvent()
244     {
245         if (eq is null)
246         {
247             return Event(EventType.error);
248         }
249         return eq.receive();
250     }
251 
252 private:
253     struct KeyCode
254     {
255         Key key;
256         Modifiers mod;
257     }
258 
259     const(Termcap)* caps;
260     CellBuffer cells;
261     bool clear_; // if a sceren clear is requested
262     Coord pos_; // location where we will update next
263     Style style_; // current style
264     Style defStyle; // default style (when screen is cleared)
265     Coord cursorPos;
266     Cursor cursorShape;
267     MouseEnable mouseEn; // saved state for suspend/resume
268     bool pasteEn; // saved state for suspend/resume
269     ParseKeys keys;
270     TtyImpl ti;
271     OutBuffer ob;
272     Turnstile stopping;
273     bool started;
274     EventQueue eq;
275 
276     // puts emits a parameterized string that may contain embedded delay padding.
277     // it should not be used for user-supplied strings.
278     void puts(string s)
279     {
280         Termcap.puts(ob, s, &flush);
281     }
282 
283     void puts(string s, int[] args...)
284     {
285         puts(Termcap.param(s, args));
286     }
287 
288     void puts(string s, string[] args...)
289     {
290         puts(Termcap.param(s, args));
291     }
292 
293     // flush queued output
294     void flush()
295     {
296         ti.write(ob.toString());
297         ti.flush();
298         ob.clear();
299     }
300 
301     // sendColors sends just the colors for a given style
302     void sendColors(Style style)
303     {
304         auto fg = style.fg;
305         auto bg = style.bg;
306 
307         if (caps.colors == 0)
308         {
309             return;
310         }
311         if (fg == Color.reset || bg == Color.reset)
312         {
313             puts(caps.resetColors);
314         }
315         if (caps.colors > 256)
316         {
317             if (caps.setFgBgRGB != "" && isRGB(fg) && isRGB(bg))
318             {
319                 auto rgb1 = decompose(fg);
320                 auto rgb2 = decompose(bg);
321                 puts(caps.setFgBgRGB, rgb1[0], rgb1[1], rgb1[2], rgb2[0], rgb2[1], rgb2[2]);
322             }
323             else
324             {
325                 if (isRGB(fg) && caps.setFgRGB != "")
326                 {
327                     auto rgb = decompose(fg);
328                     puts(caps.setFgRGB, rgb[0], rgb[1], rgb[2]);
329                 }
330                 if (isRGB(bg) && caps.setBgRGB != "")
331                 {
332                     auto rgb = decompose(bg);
333                     puts(caps.setBgRGB, rgb[0], rgb[1], rgb[2]);
334                 }
335             }
336         }
337         else
338         {
339             fg = toPalette(fg, caps.colors);
340             bg = toPalette(bg, caps.colors);
341         }
342         if (fg < 256 && bg < 256 && caps.setFgBg != "")
343             puts(caps.setFgBg, fg, bg);
344         else
345         {
346             if (fg < 256)
347                 puts(caps.setFg, fg);
348             if (bg < 256)
349                 puts(caps.setBg, bg);
350         }
351 
352     }
353 
354     void sendAttrs(Style style)
355     {
356         auto attr = style.attr;
357         if (attr & Attr.bold)
358             puts(caps.bold);
359         if (attr & Attr.underline)
360             puts(caps.underline);
361         if (attr & Attr.reverse)
362             puts(caps.reverse);
363         if (attr & Attr.blink)
364             puts(caps.blink);
365         if (attr & Attr.dim)
366             puts(caps.dim);
367         if (attr & Attr.italic)
368             puts(caps.italic);
369         if (attr & Attr.strikethrough)
370             puts(caps.strikethrough);
371     }
372 
373     void clearScreen()
374     {
375         if (clear_)
376         {
377             clear_ = false;
378             puts(caps.attrOff);
379             puts(caps.exitURL);
380             sendColors(defStyle);
381             sendAttrs(defStyle);
382             style_ = defStyle;
383             puts(caps.clear);
384             flush();
385         }
386     }
387 
388     void goTo(Coord pos)
389     {
390         if (pos != pos_)
391         {
392             puts(caps.setCursor, pos.y, pos.x);
393             pos_ = pos;
394         }
395     }
396 
397     // sendCursor sends the current cursor location
398     void sendCursor()
399     {
400         if (!cells.isLegal(cursorPos) || (cursorShape == Cursor.hidden))
401         {
402             if (caps.hideCursor != "")
403             {
404                 puts(caps.hideCursor);
405             }
406             else
407             {
408                 // go to last cell (lower right)
409                 // this is the best we can do to move the cursor
410                 // out of the way.
411                 auto size = cells.size();
412                 goTo(Coord(size.x - 1, size.y - 1));
413             }
414             return;
415         }
416         goTo(cursorPos);
417         puts(caps.showCursor);
418         final switch (cursorShape)
419         {
420         case Cursor.current:
421             break;
422         case Cursor.hidden:
423             puts(caps.hideCursor);
424             break;
425         case Cursor.reset:
426             puts(caps.cursorReset);
427             break;
428         case Cursor.bar:
429             puts(caps.cursorBar);
430             break;
431         case Cursor.block:
432             puts(caps.cursorBlock);
433             break;
434         case Cursor.underline:
435             puts(caps.cursorUnderline);
436             break;
437         case Cursor.blinkingBar:
438             puts(caps.cursorBlinkingBar);
439             break;
440         case Cursor.blinkingBlock:
441             puts(caps.cursorBlinkingBlock);
442             break;
443         case Cursor.blinkingUnderline:
444             puts(caps.cursorBlinkingUnderline);
445             break;
446         }
447 
448         // update our location
449         pos_ = cursorPos;
450     }
451 
452     // drawCell draws one cell.  It returns the width drawn (1 or 2).
453     int drawCell(Coord pos)
454     {
455         Cell c = cells[pos];
456         auto insert = false;
457         if (!cells.dirty(pos))
458         {
459             return c.width;
460         }
461         // automargin handling -- if we are going to automatically
462         // wrap at the bottom right corner, then we want to insert
463         // that character in place, to avoid the scroll of doom.
464         auto size = cells.size();
465         if ((pos.y == size.y - 1) && (pos.x == size.x - 1) && caps.automargin
466                 && (caps.insertChar != ""))
467         {
468             auto pp = pos;
469             pp.x--;
470             goTo(pp);
471             insert = true;
472         }
473         else if (pos != pos_)
474         {
475             goTo(pos);
476         }
477 
478         if (caps.colors == 0)
479         {
480             // if its monochrome, simulate ligher and darker with reverse
481             if (darker(c.style.fg, c.style.bg))
482             {
483                 c.style.attr ^= Attr.reverse;
484             }
485         }
486         if (caps.enterURL == "")
487         {
488             // avoid pointless changes due to URL where not supported
489             c.style.url = "";
490         }
491 
492         if (c.style.fg != style_.fg || c.style.bg != style_.bg || c.style.attr != style_.attr)
493         {
494             puts(caps.attrOff);
495             sendColors(c.style);
496             sendAttrs(c.style);
497         }
498         if (c.style.url != style_.url)
499         {
500             if (c.style.url != "")
501             {
502                 puts(caps.enterURL, c.style.url);
503             }
504             else
505             {
506                 puts(caps.exitURL);
507             }
508         }
509         // TODO: replacement encoding (ACSC, application supplied fallbacks)
510 
511         style_ = c.style;
512 
513         if (pos.x + c.width > size.x)
514         {
515             // if too big to fit last column, just fill with a space
516             c.text = " ";
517         }
518 
519         puts(c.text);
520         pos_.x += c.width;
521         // Note that we might be beyond the width, and if automargin
522         // is set true, we might have wrapped.  But it turns out that
523         // we can't reliably depend on automargin, as some terminals
524         // that claim to behave that way actually don't.
525         cells.setDirty(pos, false);
526         if (insert)
527         {
528             // go back and redraw the second to last cell
529             drawCell(Coord(pos.x - 1, pos.y));
530         }
531         return c.width;
532     }
533 
534     void draw()
535     {
536         puts(caps.hideCursor); // hide the cursor while we draw
537         clearScreen(); // no op if not needed
538         auto size = cells.size();
539         Coord pos = Coord(0, 0);
540         for (pos.y = 0; pos.y < size.y; pos.y++)
541         {
542             int width = 1;
543             for (pos.x = 0; pos.x < size.x; pos.x += width)
544             {
545                 width = drawCell(pos);
546                 // this way if we ever redraw that cell, it will
547                 // be marked dirty, because we have clobbered it with
548                 // the adjacent character
549                 if (width < 1)
550                     width = 1;
551                 if (width > 1)
552                 {
553                     cells.setDirty(Coord(pos.x + 1, pos.y), true);
554                 }
555             }
556         }
557         sendCursor();
558         flush();
559     }
560 
561     void sendMouseEnable(MouseEnable en)
562     {
563         // we rely on the fact that all known implementations adhere
564         // to the de-facto standard from XTerm.  This is necessary as
565         // there is no standard terminfo sequence for reporting this
566         // information.
567         if (caps.mouse != "")
568         {
569             // start by disabling everything
570             puts("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l");
571             // then turn on specific enables
572             if (en & MouseEnable.buttons)
573                 puts("\x1b[?1000h");
574             if (en & MouseEnable.drag)
575                 puts("\x1b[?1002h");
576             if (en & MouseEnable.motion)
577                 puts("\x1b[?1003h");
578             // and if any are set, we need to send this
579             if (en & MouseEnable.all)
580                 puts("\x1b[?1006h");
581             flush();
582         }
583     }
584 
585     void sendPasteEnable(bool b)
586     {
587         puts(b ? caps.enablePaste : caps.disablePaste);
588         flush();
589     }
590 
591     static void inputLoop(shared TtyImpl tin, ParseKeys keys, Tid tid,
592             shared EventQueue eq, shared Turnstile stopping)
593     {
594         TtyImpl f = cast(TtyImpl) tin;
595         Parser p = new Parser(keys);
596         bool poll = false;
597 
598         f.blocking(true);
599 
600         for (;;)
601         {
602             string s;
603             try
604             {
605                 s = f.read();
606             }
607             catch (Exception e)
608             {
609             }
610             p.parse(s);
611             auto evs = p.events();
612             if (f.resized())
613             {
614                 Event ev;
615                 ev.type = EventType.resize;
616                 ev.when = MonoTime.currTime();
617                 evs ~= ev;
618             }
619             foreach (_, ev; evs)
620             {
621                 if (eq is null)
622                     send(ownerTid(), ev);
623                 else
624                 {
625                     import std.stdio;
626 
627                     eq.send(ev);
628                 }
629             }
630             if (!p.empty())
631             {
632                 f.blocking(false);
633                 poll = true;
634             }
635             else
636             {
637                 // No data, so we can sleep until some arrives.
638                 f.blocking(true);
639                 poll = false;
640             }
641 
642             if (stopping.get())
643             {
644                 stopping.set(false);
645                 return;
646             }
647         }
648     }
649 }
650 
651 version (Posix)  : import dcell.terminfo;
652 
653 Screen newTtyScreen(string term = "")
654 {
655     import std.process;
656 
657     if (term == "")
658     {
659         term = environment.get("TERM", "ansi");
660     }
661     auto caps = Database.get(term);
662     if (caps is null)
663     {
664         throw new Exception("terminal not found");
665     }
666     return new TtyScreen(newDevTty(), caps);
667 }