1 /** 2 * Cell module for dcell. 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.cell; 12 13 import std.algorithm; 14 import std.traits; 15 import std.utf; 16 17 import eastasianwidth; 18 19 public import dcell.coord; 20 public import dcell.style; 21 22 /** 23 * Cell represents the contents of a single character cell on screen, 24 * or in some cases two adjacent cells. Terminals are expected to have a uniform 25 * display width for each cell, and to have a fixed number of cell columsn and rows. 26 * (We assume fixed pitch fonts.) The occasion when a double wide character is present 27 * occurs for certain East Asian characters that require twice as much horizontal space 28 * to display as others. (This can also occur with some emoji.) 29 */ 30 struct Cell 31 { 32 string xtext; /// character content - one character followed by any combinging characters 33 Style style; /// styling for the cell 34 35 this(C)(C c, Style st = Style()) if (isSomeChar!C) 36 { 37 ss = toUTF8([c]); 38 ds = toUTF32([c]); 39 style = st; 40 } 41 42 this(S)(S s, Style st = Style()) if (isSomeString!S) 43 { 44 ss = toUTF8(s); 45 ds = toUTF32(s); 46 style = st; 47 } 48 49 @property const(string) text() pure @safe const 50 { 51 return ss; 52 } 53 54 @property const(string) text(const(string) s) pure @safe 55 { 56 ds = toUTF32(s); 57 ss = s; 58 return s; 59 } 60 61 /** 62 * The display width of the contents of the cell, which will be 1 (typical western 63 * characters, as well as ambiguous characters) or 2 (typical CJK characters). 64 * This relies on the accuracy of the content in the imported east_asian_width 65 * package, and may therefore not be perfectly correct for a given platform or 66 * font or context. In particular it may be wrong for some emoji. Note also that 67 * the D std.uni notion of grapheme boundaries is out of date, and so many 68 * things that should be treated as a single grapheme (or grapheme cluster) will 69 * not be. 70 */ 71 @property uint width() pure const 72 { 73 enum regionA = '\U0001F1E6'; 74 enum regionZ = '\U0001F1FF'; 75 76 if (ds.length < 1) 77 { 78 return (0); 79 } 80 if (ds[0] < ' ') 81 return 0; // control characters 82 if (ds[0] < '\u02b0') // most common case, covers ASCII, Latin supplements, IPA 83 return 1; 84 // flags - missing from east asian width decoding (and also stdin) 85 if ((ds.length >= 2) && (ds[0] >= regionA && ds[0] <= regionZ && ds[1] >= regionA && ds[1] <= regionZ)) 86 { 87 return 2; 88 } 89 return cast(uint) displayWidth(ds[0]); 90 } 91 92 private dstring ds; 93 private string ss; 94 } 95 96 unittest 97 { 98 assert(Cell('\t').width == 0); 99 assert(Cell('A').width == 1); // ASCII 100 assert(Cell("B").width == 1); // ASCII (string) 101 assert(Cell('ᅡ').width == 1); // half-width form 102 assert(Cell('¥').width == 2); // full-width yen 103 assert(Cell('Z').width == 2); // full-width Z 104 assert(Cell('角').width == 2); // a CJK character 105 assert(Cell('😂').width == 2); // emoji 106 assert(Cell('♀').width == 1); // modifier alone 107 assert(Cell("\U0001F44D").width == 2); // thumbs up 108 assert(Cell("\U0001f1fa\U0001f1f8").width == 2); // US flag (regional indicators) 109 110 // The following are broken due to bugs in std.uni and/or the east asian width. 111 // At some point it may be easier to refactor this ourself. 112 // assert(Cell("\U0001f9db\u200d\u2640").width == 1); // female vampire 113 // assert(Cell("🤝 🏽").width == 2); // modified emoji (medium skin tone handshake) 114 // assert(Cell("🧛♀️").width == 2); // modified emoji -- does not work 115 // assert(Cell("\U0001F44D\U0001F3fD").width == 2); // thumbs up, medium skin tone 116 } 117 118 /** 119 * CellBuffer is a logical grid of cells containing content to display on screen. 120 * It uses double buffering which can be used to reduce redrawing content on screen, 121 * which can have a very noticeable impact on performance and responsiveness. 122 * 123 * It behaves something like a two-dimensional array, but offers some conveniences. 124 * Values returned from the indexing are constant, but new values can be assigned. 125 */ 126 class CellBuffer 127 { 128 private Coord size_; 129 private Cell[] cells; // current content - linear for performance 130 private Cell[] prev; // previous content - linear for performance 131 132 private size_t index(Coord pos) nothrow pure const 133 { 134 return index(pos.x, pos.y); 135 } 136 137 private size_t index(size_t x, size_t y) nothrow pure const 138 { 139 assert(size_.x > 0); 140 return (y * size_.x + x); 141 } 142 143 package bool isLegal(Coord pos) nothrow pure const 144 { 145 return ((pos.x >= 0) && (pos.y >= 0) && (pos.x < size_.x) && (pos.y < size_.y)); 146 } 147 148 this(const size_t cols, const size_t rows) 149 { 150 assert((cols >= 0) && (rows >= 0) && (cols < int.max) && (rows < int.max)); 151 cells = new Cell[cols * rows]; 152 prev = new Cell[cols * rows]; 153 size_.x = cast(int) cols; 154 size_.y = cast(int) rows; 155 156 foreach (i; 0 .. cells.length) 157 { 158 cells[i].text = " "; 159 } 160 } 161 162 this(Coord size) 163 { 164 this(size.x, size.y); 165 } 166 167 /** 168 * Is a cell dirty? Dirty means that the cell has some content 169 * or style change that has not been written to the terminal yet. 170 * Writes of identical content to what was last displayed do not 171 * cause a cell to become dirty -- only *different* content does. 172 * 173 * Params: 174 * pos = coordinates of cell to check 175 */ 176 bool dirty(Coord pos) pure const 177 { 178 if (isLegal(pos)) 179 { 180 auto ix = index(pos); 181 if (prev[ix].text == "") 182 { 183 return true; 184 } 185 return cells[ix] != prev[ix]; 186 } 187 return false; 188 } 189 190 /** 191 * Mark a cell as either dirty or clean. 192 * 193 * Params: 194 * pos = coordinate of sell to update 195 * b = mark all dirty if true, or clean if false 196 */ 197 void setDirty(Coord pos, bool b) pure 198 { 199 if (isLegal(pos)) 200 { 201 auto ix = index(pos); 202 if (b) 203 { 204 prev[ix].text = ""; 205 } 206 else 207 { 208 prev[ix] = cells[ix]; 209 } 210 } 211 } 212 213 /** 214 * Mark all cells as either dirty or clean. 215 * 216 * Params: 217 * b = mark all dirty if true, or clean if false 218 */ 219 void setAllDirty(bool b) pure 220 { 221 // structured this way for efficiency 222 if (b) 223 { 224 foreach (i; 0 .. prev.length) 225 { 226 prev[i].text = ""; 227 } 228 } 229 else 230 { 231 foreach (i; 0 .. prev.length) 232 { 233 prev[i] = cells[i]; 234 } 235 } 236 } 237 238 ref Cell opIndex(Coord pos) 239 { 240 return this[pos.x, pos.y]; 241 } 242 243 ref Cell opIndex(size_t x, size_t y) 244 { 245 return cells[index(x, y)]; 246 } 247 248 Cell get(Coord pos) nothrow pure 249 { 250 if (isLegal(pos)) 251 { 252 return cells[index(pos)]; 253 } 254 return Cell(); 255 } 256 257 /** 258 * Set content for the cell. 259 * 260 * Params: 261 * c = content to store for the cell. 262 * pos = coordinate of the cell 263 */ 264 void opIndexAssign(Cell c, size_t x, size_t y) pure 265 { 266 if ((x < size_.x) && (y < size_.y)) 267 { 268 if (c.text == "" || c.text[0] < ' ') 269 { 270 c.text = " "; 271 } 272 cells[index(x, y)] = c; 273 } 274 } 275 276 void opIndexAssign(Cell c, Coord pos) pure 277 { 278 this[pos.x, pos.y] = c; 279 } 280 281 /** 282 * Set content for the cell, preserving existing styling. 283 * 284 * Params: 285 * s = text (character) to display. Note that only a single 286 * character (including combining marks) is written. 287 * pos = coordinate to update. 288 */ 289 void opIndexAssign(string s, Coord pos) pure 290 { 291 if (s == "" || s[0] < ' ') 292 { 293 s = " "; 294 } 295 if (isLegal(pos)) 296 { 297 auto ix = index(pos); 298 cells[ix].text = s; 299 } 300 } 301 302 void opIndexAssign(Style style, Coord pos) pure 303 { 304 if (isLegal(pos)) 305 { 306 cells[index(pos)].style = style; 307 } 308 } 309 310 void opIndexAssign(string s, size_t x, size_t y) pure 311 { 312 if (s == "" || s[0] < ' ') 313 { 314 s = " "; 315 } 316 317 cells[index(x, y)].text = s; 318 } 319 320 void opIndexAssign(Style v, size_t x, size_t y) pure 321 { 322 cells[index(x, y)].style = v; 323 } 324 325 int opDollar(size_t dim)() 326 { 327 if (dim == 0) 328 { 329 return size_.x; 330 } 331 else 332 { 333 return size_.y; 334 } 335 } 336 337 void fill(Cell c) pure 338 { 339 if (c.text == "" || c.text[0] < ' ') 340 { 341 c.text = " "; 342 } 343 foreach (i; 0 .. cells.length) 344 { 345 cells[i] = c; 346 } 347 } 348 349 /** 350 * Fill the entire contents, but leave any text styles undisturbed. 351 */ 352 void fill(string s) pure 353 { 354 foreach (i; 0 .. cells.length) 355 { 356 cells[i].text = s; 357 } 358 } 359 360 /** 361 * Fill the entires contents, including the given style. 362 */ 363 void fill(string s, Style style) pure 364 { 365 Cell c = Cell(s, style); 366 fill(c); 367 } 368 369 /** 370 * Resize the cell buffer. Existing contents will be preserved, 371 * provided that they still fit. Contents that no longer fit will 372 * be clipped (lost). Newly added cells will be initialized to empty 373 * content. The entire set of contents are marked dirty, because 374 * presumably everything needs to be redrawn when this happens. 375 */ 376 void resize(Coord size) 377 { 378 if (size_ == size) 379 { 380 return; 381 } 382 auto newCells = new Cell[size.x * size.y]; 383 foreach (i; 0 .. newCells.length) 384 { 385 // prefill with whitespace 386 newCells[i].text = " "; 387 } 388 // maximum dimensions to copy (minimum of dimensions) 389 int lx = min(size.x, size_.x); 390 int ly = min(size.y, size_.y); 391 392 foreach (y; 0 .. ly) 393 { 394 foreach (x; 0 .. lx) 395 { 396 newCells[y * size.x + x] = cells[y * size_.x + x]; 397 } 398 } 399 size_ = size; 400 cells = newCells; 401 prev = new Cell[size.x * size.y]; 402 } 403 404 void resize(int cols, int rows) 405 { 406 resize(Coord(cols, rows)); 407 } 408 409 Coord size() const pure nothrow 410 { 411 return size_; 412 } 413 414 unittest 415 { 416 auto cb = new CellBuffer(80, 24); 417 assert(cb.cells.length == 24 * 80); 418 assert(cb.prev.length == 24 * 80); 419 assert(cb.size_ == Coord(80, 24)); 420 421 assert(Cell('A').text == "A"); 422 423 cb[Coord(2, 5)] = "b"; 424 assert(cb[2, 5].text == "b"); 425 Cell c = cb[2, 5]; 426 Style st; 427 assert(c.width == 1); 428 assert(c.text == "b"); 429 assert(c.style == st); 430 assert(cb.cells[5 * cb.size_.x + 2].text == "b"); 431 432 st.bg = Color.white; 433 st.fg = Color.blue; 434 st.attr = Attr.reverse; 435 cb[3, 5] = "z"; 436 cb[3, 5] = st; 437 438 c = cb[Coord(3, 5)]; 439 assert(c.style.bg == Color.white); 440 assert(c.style.fg == Color.blue); 441 assert(c.style.attr == Attr.reverse); 442 443 cb[0, 0] = Cell("", st); 444 c = cb[0, 0]; 445 assert(c.text == " "); // space replaces null string 446 assert(c.width == 1); 447 assert(c.style == st); 448 449 cb[1, 0] = Cell("\x1b", st); 450 c = cb[1, 0]; 451 assert(c.text == " "); // space replaces control sequence 452 assert(c.width == 1); 453 assert(c.style == st); 454 455 cb[1, 1] = "\x1b"; 456 c = cb[1, 1]; 457 assert(c.text == " "); // space replaces control sequence 458 assert(c.width == 1); 459 460 c.text = "@"; 461 cb[2, 0] = c; 462 c = cb[2, 0]; 463 assert(c.text == "@"); 464 assert(cb.dirty(Coord(2, 0))); 465 466 st.attr = Attr.reverse; 467 st.bg = Color.none; 468 st.fg = Color.maroon; 469 cb.fill("%", st); 470 assert(cb[1, 1].text == "%"); 471 assert(cb[1, 1].style == st); 472 cb.fill("s"); 473 474 cb.setAllDirty(false); 475 476 cb[1, 1] = "U"; 477 cb[1, 1] = st; 478 assert(cb[1, 1].style == st); 479 assert(cb[1, 1].text == "U"); 480 assert(cb.dirty(Coord(1, 1))); 481 assert(!cb.dirty(Coord(2, 1))); 482 483 assert(cb.prev[0] == cb.cells[0]); 484 485 cb.setDirty(Coord(2, 1), true); 486 assert(cb.dirty(Coord(2, 1))); 487 assert(!cb.dirty(Coord(3, 1))); 488 489 cb.setDirty(Coord(3, 1), false); 490 assert(!cb.dirty(Coord(3, 1))); 491 cb.setAllDirty(true); 492 assert(cb.dirty(Coord(3, 1))); 493 494 c.text = "A"; 495 cb.fill(c); 496 assert(cb[0, 0].width == 1); 497 assert(cb[0, 0].text == "A"); 498 assert(cb[1, 23].text == "A"); 499 assert(cb[79, 23].text == "A"); 500 cb.resize(132, 50); 501 assert(cb.size() == Coord(132, 50)); 502 assert(cb[79, 23].text == "A"); 503 assert(cb[80, 23].text == " "); 504 assert(cb[79, 24].text == " "); 505 cb.resize(132, 50); // this should be a no-op 506 assert(cb.size() == Coord(132, 50)); 507 assert(cb[79, 23].text == "A"); 508 assert(cb[80, 23].text == " "); 509 assert(cb[79, 24].text == " "); 510 511 c.text = ""; 512 cb.fill(c); 513 assert(cb[79, 23].text == " "); 514 515 // opDollar 516 assert(cb.size() == Coord(132, 50)); 517 cb[0, 0].text = "A"; 518 cb[$ - 1, 0].text = "B"; 519 cb[0, $ - 1].text = "C"; 520 cb[$ - 1, $ - 1].text = "D"; 521 assert(cb[0, 0].text == "A"); 522 assert(cb[131, 0].text == "B"); 523 assert(cb[0, 49].text == "C"); 524 assert(cb[131, 49].text == "D"); 525 } 526 }