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 }