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 }