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 }