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