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 }