1 /** 2 * Cell module for dcell. 3 * 4 * Copyright: Copyright 2025 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.cell; 12 13 import std.algorithm; 14 import std.exception; 15 import std.traits; 16 import std.uni; 17 import std.utf; 18 19 import eastasianwidth; 20 21 public import dcell.coord; 22 public import dcell.style; 23 24 /** 25 * Cell represents the contents of a single character cell on screen, 26 * or in some cases two adjacent cells. Terminals are expected to have a uniform 27 * display width for each cell, and to have a fixed number of cell columns and rows. 28 * (We assume fixed pitch fonts.) The occasion when a double wide character is present 29 * occurs for certain East Asian characters that require twice as much horizontal space 30 * to display as others. (This can also occur with some emoji.) 31 */ 32 struct Cell 33 { 34 // The order of these is chosen to minimize space per cell. 35 Style style; /// styling for the cell 36 37 this(C)(C c, Style st = Style()) if (isSomeChar!C) 38 { 39 ss = toUTF8([c]); 40 dw = calcWidth(); 41 style = st; 42 } 43 44 this(S)(S s, Style st = Style()) if (isSomeString!S) 45 { 46 ss = toUTF8(s); 47 dw = calcWidth(); 48 style = st; 49 } 50 51 @property const(string) text() nothrow pure @safe const 52 { 53 return ss; 54 } 55 56 @property const(string) text(const(string) s) pure @safe 57 { 58 ss = toUTF8(s); 59 dw = calcWidth(); 60 return s; 61 } 62 63 /** 64 * The display width of the contents of the cell, which will be 1 (typical western 65 * characters, as well as ambiguous characters), 2 (typical CJK characters), or in 66 * some cases 0 (empty content, control characters, zero-width whitespace). 67 * Note that cells immediately following a cell with a wide character will themselves 68 * generally have no content (as their screen display real-estate is consumed by the 69 * wide character.) 70 * 71 * This relies on the accuracy of the content in the imported east_asian_width 72 * package and the terminal/font combination (and for some characters it may even 73 * be sensitive to which Unicode edition is being supported). Therefore the results 74 * may not be perfectly correct for a given platform or font or context. 75 */ 76 @property ubyte width() const nothrow pure @safe 77 { 78 return dw; 79 } 80 81 private: 82 83 string ss; 84 ubyte dw; // display width 85 86 ubyte calcWidth() pure const @safe 87 { 88 enum regionA = '\U0001F1E6'; 89 enum regionZ = '\U0001F1FF'; 90 91 if (ss.length < 1 || ss[0] < ' ' || ss[0] == '\x7F') // empty or control characters 92 { 93 return (0); 94 } 95 if (ss[0] < '\x80') // covers ASCII 96 { 97 return 1; 98 } 99 auto d = toUTF32(ss); 100 // flags - missing from east asian width decoding 101 if ((d.length >= 2) && (d[0] >= regionA && d[0] <= regionZ && d[1] >= regionA && d[1] <= regionZ)) 102 { 103 return 2; 104 } 105 assert(d.length > 0); 106 if (d[0] < '\u02b0') 107 { 108 return 1; // covers latin supplements, IPA 109 } 110 auto w = displayWidth(d[0]); 111 if (w < 0) 112 { 113 return 0; 114 } 115 if (w > 2) 116 { 117 return 2; 118 } 119 return cast(ubyte) w; 120 } 121 } 122 123 unittest 124 { 125 assert(Cell('\t').width == 0); 126 assert(Cell('\x7F').width == 0); 127 assert(Cell("").width == 0); 128 assert(Cell('A').width == 1); // ASCII 129 assert(Cell("B").width == 1); // ASCII (string) 130 assert(Cell('ᅡ').width == 1); // half-width form 131 assert(Cell('¥').width == 2); // full-width yen 132 assert(Cell('Z').width == 2); // full-width Z 133 assert(Cell('角').width == 2); // a CJK character 134 assert(Cell('😂').width == 2); // emoji 135 assert(Cell('♀').width == 1); // modifier alone 136 assert(Cell("\U0001F44D").width == 2); // thumbs up 137 assert(Cell("\U0001f1fa\U0001f1f8").width == 2); // US flag (regional indicators) 138 assert(Cell("\U0001f9db\u200d\u2640").width == 2); // female vampire 139 assert(Cell("🤝 🏽").width == 2); // modified emoji (medium skin tone handshake) 140 assert(Cell("\U0001F44D\U0001F3fD").width == 2); // thumbs up, medium skin tone 141 assert(Cell("🧛♀️").width == 2); // modified emoji 142 143 // The following are broken due to bugs in std.uni and/or the east asian width. 144 // At some point it may be easier to refactor this ourself. 145 // assert(Cell('\u200d').width == 0); // Zero Width Joiner 146 } 147 148 /** 149 * CellBuffer is a logical grid of cells containing content to display on screen. 150 * It uses double buffering which can be used to reduce redrawing content on screen, 151 * which can have a very noticeable impact on performance and responsiveness. 152 * 153 * It behaves something like a two-dimensional array, but offers some conveniences. 154 * Values returned from the indexing are constant, but new values can be assigned. 155 */ 156 class CellBuffer 157 { 158 private Coord size_; 159 private Cell[] cells; // current content - linear for performance 160 private Cell[] prev; // previous content - linear for performance 161 162 private size_t index(Coord pos) nothrow pure const @safe @nogc 163 { 164 return index(pos.x, pos.y); 165 } 166 167 private size_t index(size_t x, size_t y) nothrow pure const @safe @nogc 168 { 169 assert(size_.x > 0); 170 return (y * size_.x + x); 171 } 172 173 package bool isLegal(Coord pos) nothrow pure const @safe @nogc 174 { 175 return ((pos.x >= 0) && (pos.y >= 0) && (pos.x < size_.x) && (pos.y < size_.y)); 176 } 177 178 this(const size_t cols, const size_t rows) @safe 179 { 180 assert((cols >= 0) && (rows >= 0) && (cols < int.max) && (rows < int.max)); 181 cells = new Cell[cols * rows]; 182 prev = new Cell[cols * rows]; 183 size_.x = cast(int) cols; 184 size_.y = cast(int) rows; 185 186 foreach (i; 0 .. cells.length) 187 { 188 cells[i].text = " "; 189 } 190 } 191 192 this(Coord size) @safe 193 { 194 this(size.x, size.y); 195 } 196 197 /** 198 * Is a cell dirty? Dirty means that the cell has some content 199 * or style change that has not been written to the terminal yet. 200 * Writes of identical content to what was last displayed do not 201 * cause a cell to become dirty -- only *different* content does. 202 * 203 * Params: 204 * pos = coordinates of cell to check 205 */ 206 bool dirty(Coord pos) nothrow pure const @safe 207 { 208 if (isLegal(pos)) 209 { 210 auto ix = index(pos); 211 if (prev[ix].text == "") 212 { 213 return true; 214 } 215 return cells[ix] != prev[ix]; 216 } 217 return false; 218 } 219 220 /** 221 * Mark a cell as either dirty or clean. 222 * 223 * Params: 224 * pos = coordinate of sell to update 225 * b = mark all dirty if true, or clean if false 226 */ 227 void setDirty(Coord pos, bool b) pure @safe 228 { 229 if (isLegal(pos)) 230 { 231 auto ix = index(pos); 232 if (b) 233 { 234 prev[ix].text = ""; 235 } 236 else 237 { 238 prev[ix] = cells[ix]; 239 } 240 } 241 } 242 243 /** 244 * Mark all cells as either dirty or clean. 245 * 246 * Params: 247 * b = mark all dirty if true, or clean if false 248 */ 249 void setAllDirty(bool b) pure @safe 250 { 251 // structured this way for efficiency 252 if (b) 253 { 254 foreach (i; 0 .. prev.length) 255 { 256 prev[i].text = ""; 257 } 258 } 259 else 260 { 261 foreach (i; 0 .. prev.length) 262 { 263 prev[i] = cells[i]; 264 } 265 } 266 } 267 268 ref Cell opIndex(Coord pos) nothrow @safe 269 { 270 return this[pos.x, pos.y]; 271 } 272 273 ref Cell opIndex(size_t x, size_t y) nothrow @safe 274 { 275 return cells[index(x, y)]; 276 } 277 278 Cell get(Coord pos) nothrow pure @safe 279 { 280 if (isLegal(pos)) 281 { 282 return cells[index(pos)]; 283 } 284 return Cell(); 285 } 286 287 /** 288 * Set content for the cell. 289 * 290 * Params: 291 * c = content to store for the cell. 292 * pos = coordinate of the cell 293 */ 294 void opIndexAssign(Cell c, size_t x, size_t y) pure @safe 295 { 296 if ((x < size_.x) && (y < size_.y)) 297 { 298 if (c.text == "" || c.text[0] < ' ') 299 { 300 c.text = " "; 301 } 302 cells[index(x, y)] = c; 303 } 304 } 305 306 void opIndexAssign(Cell c, Coord pos) pure @safe 307 { 308 this[pos.x, pos.y] = c; 309 } 310 311 /** 312 * Set content for the cell, preserving existing styling. 313 * 314 * Params: 315 * s = text (character) to display. Note that only a single 316 * character (including combining marks) is written. 317 * pos = coordinate to update. 318 */ 319 void opIndexAssign(string s, Coord pos) pure @safe 320 { 321 if (s == "" || s[0] < ' ') 322 { 323 s = " "; 324 } 325 if (isLegal(pos)) 326 { 327 auto ix = index(pos); 328 cells[ix].text = s; 329 } 330 } 331 332 void opIndexAssign(Style style, Coord pos) nothrow pure @safe 333 { 334 if (isLegal(pos)) 335 { 336 cells[index(pos)].style = style; 337 } 338 } 339 340 void opIndexAssign(string s, size_t x, size_t y) pure @safe 341 { 342 if (s == "" || s[0] < ' ') 343 { 344 s = " "; 345 } 346 347 cells[index(x, y)].text = s; 348 } 349 350 void opIndexAssign(Style v, size_t x, size_t y) nothrow pure @safe 351 { 352 cells[index(x, y)].style = v; 353 } 354 355 int opDollar(size_t dim)() nothrow pure @safe 356 { 357 if (dim == 0) 358 { 359 return size_.x; 360 } 361 else 362 { 363 return size_.y; 364 } 365 } 366 367 void fill(Cell c) pure @safe 368 { 369 if (c.text == "" || c.text[0] < ' ') 370 { 371 c.text = " "; 372 } 373 foreach (i; 0 .. cells.length) 374 { 375 cells[i] = c; 376 } 377 } 378 379 /** 380 * Fill the entire contents, but leave any text styles undisturbed. 381 */ 382 void fill(string s) pure @safe 383 { 384 foreach (i; 0 .. cells.length) 385 { 386 cells[i].text = s; 387 } 388 } 389 390 /** 391 * Fill the entire contents, including the given style. 392 */ 393 void fill(string s, Style style) pure @safe 394 { 395 Cell c = Cell(s, style); 396 fill(c); 397 } 398 399 /** 400 * Resize the cell buffer. Existing contents will be preserved, 401 * provided that they still fit. Contents that no longer fit will 402 * be clipped (lost). Newly added cells will be initialized to empty 403 * content. The entire set of contents are marked dirty, because 404 * presumably everything needs to be redrawn when this happens. 405 */ 406 void resize(Coord size) @safe 407 { 408 if (size_ == size) 409 { 410 return; 411 } 412 auto newCells = new Cell[size.x * size.y]; 413 foreach (i; 0 .. newCells.length) 414 { 415 // pre-fill with whitespace 416 newCells[i].text = " "; 417 } 418 // maximum dimensions to copy (minimum of dimensions) 419 int lx = min(size.x, size_.x); 420 int ly = min(size.y, size_.y); 421 422 foreach (y; 0 .. ly) 423 { 424 foreach (x; 0 .. lx) 425 { 426 newCells[y * size.x + x] = cells[y * size_.x + x]; 427 } 428 } 429 size_ = size; 430 cells = newCells; 431 prev = new Cell[size.x * size.y]; 432 } 433 434 void resize(int cols, int rows) @safe 435 { 436 resize(Coord(cols, rows)); 437 } 438 439 Coord size() const pure nothrow @safe 440 { 441 return size_; 442 } 443 444 // This is the default style we use when writing content using 445 // put and similar APIs. 446 Style style; 447 448 // This is the current position that will be writing when when using 449 // put or write. 450 Coord position; 451 452 void put(Grapheme g) @safe 453 { 454 if (isLegal(position)) 455 { 456 auto ix = index(position); 457 string str = toUTF8(g[]); 458 cells[ix].text = str; 459 cells[ix].style = style; 460 auto w = cells[ix].width; 461 final switch (w) 462 { 463 case 0: 464 break; 465 case 1: 466 position.x++; 467 if (position.x >= size_.x) 468 { 469 // auto wrap 470 position.y++; 471 position.x = 0; 472 } 473 break; 474 case 2: 475 position.x++; 476 if (isLegal(position)) 477 { 478 cells[index(position)].text = ""; 479 } 480 position.x++; 481 if (position.x >= size_.x) 482 { 483 position.y++; 484 position.x = 0; 485 } 486 } 487 } 488 } 489 490 // Put uses a range put, and can thus support a formatted writer, but 491 // note that this WILL NOT WORK with grapheme clusters, because the formatted 492 // writer does not know about unicode segmentation. Use write() and create 493 // a string elsewhere if you need to work with grapheme clusters. Single code 494 // point use cases (i.e. most simple text, or precomposed scripts) will work fine. 495 void put(Char)(Char c) @safe if (isSomeChar!Char) 496 { 497 put(Grapheme(c)); 498 } 499 500 // Write a string at the current `position`, using the current `style`. 501 // This will wrap if it reaches the end of the terminal. 502 void write(Str)(Str s) @safe if (isSomeString!Str) 503 { 504 foreach (g; s.byGrapheme) 505 { 506 put(g); 507 } 508 } 509 510 unittest 511 { 512 auto cb = new CellBuffer(80, 24); 513 assert(cb.cells.length == 24 * 80); 514 assert(cb.prev.length == 24 * 80); 515 assert(cb.size_ == Coord(80, 24)); 516 517 assert(Cell('A').text == "A"); 518 519 cb[Coord(2, 5)] = "b"; 520 assert(cb[2, 5].text == "b"); 521 Cell c = cb[2, 5]; 522 Style st; 523 assert(c.width == 1); 524 assert(c.text == "b"); 525 assert(c.style == st); 526 assert(cb.cells[5 * cb.size_.x + 2].text == "b"); 527 528 st.bg = Color.white; 529 st.fg = Color.blue; 530 st.attr = Attr.reverse; 531 cb[3, 5] = "z"; 532 cb[3, 5] = st; 533 534 c = cb[Coord(3, 5)]; 535 assert(c.style.bg == Color.white); 536 assert(c.style.fg == Color.blue); 537 assert(c.style.attr == Attr.reverse); 538 539 cb[0, 0] = Cell("", st); 540 c = cb[0, 0]; 541 assert(c.text == " "); // space replaces null string 542 assert(c.width == 1); 543 assert(c.style == st); 544 545 cb[1, 0] = Cell("\x1b", st); 546 c = cb[1, 0]; 547 assert(c.text == " "); // space replaces control sequence 548 assert(c.width == 1); 549 assert(c.style == st); 550 551 cb[1, 1] = "\x1b"; 552 c = cb[1, 1]; 553 assert(c.text == " "); // space replaces control sequence 554 assert(c.width == 1); 555 556 c.text = "@"; 557 cb[2, 0] = c; 558 c = cb[2, 0]; 559 assert(c.text == "@"); 560 assert(cb.dirty(Coord(2, 0))); 561 562 st.attr = Attr.reverse; 563 st.bg = Color.invalid; 564 st.fg = Color.maroon; 565 cb.fill("%", st); 566 assert(cb[1, 1].text == "%"); 567 assert(cb[1, 1].style == st); 568 cb.fill("s"); 569 570 cb.setAllDirty(false); 571 572 cb[1, 1] = "U"; 573 cb[1, 1] = st; 574 assert(cb[1, 1].style == st); 575 assert(cb[1, 1].text == "U"); 576 assert(cb.dirty(Coord(1, 1))); 577 assert(!cb.dirty(Coord(2, 1))); 578 579 assert(cb.prev[0] == cb.cells[0]); 580 581 cb.setDirty(Coord(2, 1), true); 582 assert(cb.dirty(Coord(2, 1))); 583 assert(!cb.dirty(Coord(3, 1))); 584 585 cb.setDirty(Coord(3, 1), false); 586 assert(!cb.dirty(Coord(3, 1))); 587 cb.setAllDirty(true); 588 assert(cb.dirty(Coord(3, 1))); 589 590 c.text = "A"; 591 cb.fill(c); 592 assert(cb[0, 0].width == 1); 593 assert(cb[0, 0].text == "A"); 594 assert(cb[1, 23].text == "A"); 595 assert(cb[79, 23].text == "A"); 596 cb.resize(132, 50); 597 assert(cb.size() == Coord(132, 50)); 598 assert(cb[79, 23].text == "A"); 599 assert(cb[80, 23].text == " "); 600 assert(cb[79, 24].text == " "); 601 cb.resize(132, 50); // this should be a no-op 602 assert(cb.size() == Coord(132, 50)); 603 assert(cb[79, 23].text == "A"); 604 assert(cb[80, 23].text == " "); 605 assert(cb[79, 24].text == " "); 606 607 c.text = ""; 608 cb.fill(c); 609 assert(cb[79, 23].text == " "); 610 611 // opDollar 612 assert(cb.size() == Coord(132, 50)); 613 cb[0, 0].text = "A"; 614 cb[$ - 1, 0].text = "B"; 615 cb[0, $ - 1].text = "C"; 616 cb[$ - 1, $ - 1].text = "D"; 617 assert(cb[0, 0].text == "A"); 618 assert(cb[131, 0].text == "B"); 619 assert(cb[0, 49].text == "C"); 620 assert(cb[131, 49].text == "D"); 621 } 622 } 623 624 unittest 625 { 626 auto cb = new CellBuffer(80, 24); 627 cb.put('1'); 628 cb.position = Coord(5, 10); 629 cb.style.attr = Attr.bold; 630 cb.put('2'); 631 632 assert(cb[0, 0].text == "1"); 633 assert(cb[5, 10].text == "2"); 634 assert(cb[5, 10].style.attr == Attr.bold); 635 636 cb.position = Coord(76, 1); 637 cb.write("this wraps"); 638 assert(cb[76, 1].text == "t"); 639 assert(cb[77, 1].text == "h"); 640 assert(cb[78, 1].text == "i"); 641 assert(cb[79, 1].text == "s"); 642 assert(cb[0, 2].text == " "); 643 assert(cb[1, 2].text == "w"); 644 assert(cb[2, 2].text == "r"); 645 assert(cb[3, 2].text == "a"); 646 assert(cb[4, 2].text == "p"); 647 assert(cb[5, 2].text == "s"); 648 649 cb.position = Coord(0, 3); 650 cb.write("¥ yen sign"); 651 assert(cb[0, 3].text == "¥"); 652 assert(cb[0, 3].width == 2); 653 assert(cb[1, 3].text == ""); 654 assert(cb[1, 3].width == 0); 655 assert(cb[2, 3].text == " "); 656 assert(cb[2, 3].width == 1); 657 assert(cb[3, 3].text == "y"); 658 assert(cb[4, 3].text == "e"); 659 assert(cb[5, 3].text == "n"); 660 }