@textui/widgets 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/control/text-area.d.ts +2 -0
  2. package/dist/control/text-area.d.ts.map +1 -1
  3. package/dist/control/text-area.js +16 -8
  4. package/dist/control/text-input.d.ts.map +1 -1
  5. package/dist/control/text-input.js +6 -0
  6. package/dist/data/code-viewer.d.ts +2 -0
  7. package/dist/data/code-viewer.d.ts.map +1 -1
  8. package/dist/data/code-viewer.js +9 -1
  9. package/dist/data/index.d.ts +9 -0
  10. package/dist/data/index.d.ts.map +1 -1
  11. package/dist/data/index.js +8 -2
  12. package/dist/data/list.d.ts +9 -0
  13. package/dist/data/list.d.ts.map +1 -1
  14. package/dist/data/list.js +20 -3
  15. package/dist/data/markdown-view.d.ts.map +1 -1
  16. package/dist/data/markdown-view.js +4 -1
  17. package/dist/data/pagination.d.ts.map +1 -1
  18. package/dist/data/pagination.js +4 -1
  19. package/dist/data/tree.d.ts +2 -0
  20. package/dist/data/tree.d.ts.map +1 -1
  21. package/dist/data/tree.js +8 -2
  22. package/dist/display/card.d.ts.map +1 -1
  23. package/dist/display/card.js +12 -0
  24. package/dist/display/color-text.d.ts +13 -1
  25. package/dist/display/color-text.d.ts.map +1 -1
  26. package/dist/display/color-text.js +28 -6
  27. package/dist/display/font-text.d.ts +72 -0
  28. package/dist/display/font-text.d.ts.map +1 -0
  29. package/dist/display/font-text.js +35 -0
  30. package/dist/display/fonts.d.ts +138 -0
  31. package/dist/display/fonts.d.ts.map +1 -0
  32. package/dist/display/fonts.js +893 -0
  33. package/dist/display/gard.d.ts +26 -0
  34. package/dist/display/gard.d.ts.map +1 -0
  35. package/dist/display/gard.js +479 -0
  36. package/dist/display/index.d.ts +3 -0
  37. package/dist/display/index.d.ts.map +1 -1
  38. package/dist/display/index.js +7 -0
  39. package/dist/display/key-value.d.ts +25 -5
  40. package/dist/display/key-value.d.ts.map +1 -1
  41. package/dist/display/key-value.js +13 -3
  42. package/dist/display/marquee.d.ts.map +1 -1
  43. package/dist/display/marquee.js +16 -3
  44. package/dist/display/pagga.d.ts +29 -0
  45. package/dist/display/pagga.d.ts.map +1 -0
  46. package/dist/display/pagga.js +101 -0
  47. package/dist/display/pattern.d.ts +117 -0
  48. package/dist/display/pattern.d.ts.map +1 -0
  49. package/dist/display/pattern.js +257 -0
  50. package/dist/display/spinner.d.ts +2 -0
  51. package/dist/display/spinner.d.ts.map +1 -1
  52. package/dist/display/spinner.js +3 -2
  53. package/dist/navigation/menu.d.ts +11 -0
  54. package/dist/navigation/menu.d.ts.map +1 -1
  55. package/dist/navigation/menu.js +22 -9
  56. package/dist/navigation/tabs.d.ts +2 -0
  57. package/dist/navigation/tabs.d.ts.map +1 -1
  58. package/dist/navigation/tabs.js +12 -2
  59. package/dist/overlay/command-palette.d.ts.map +1 -1
  60. package/dist/overlay/command-palette.js +18 -6
  61. package/dist/shells/workbench-shell.d.ts.map +1 -1
  62. package/dist/shells/workbench-shell.js +5 -1
  63. package/package.json +2 -2
  64. package/src/control/text-area.ts +18 -7
  65. package/src/control/text-input.ts +6 -0
  66. package/src/data/code-viewer.ts +10 -1
  67. package/src/data/index.ts +9 -2
  68. package/src/data/list.ts +21 -3
  69. package/src/data/markdown-view.ts +7 -4
  70. package/src/data/pagination.ts +4 -1
  71. package/src/data/tree.ts +9 -2
  72. package/src/display/card.ts +12 -0
  73. package/src/display/color-text.ts +33 -10
  74. package/src/display/font-text.tsx +94 -0
  75. package/src/display/fonts.ts +990 -0
  76. package/src/display/gard.ts +475 -0
  77. package/src/display/index.ts +7 -0
  78. package/src/display/key-value.ts +44 -6
  79. package/src/display/marquee.ts +15 -3
  80. package/src/display/pagga.ts +102 -0
  81. package/src/display/pattern.tsx +427 -0
  82. package/src/display/spinner.ts +5 -2
  83. package/src/navigation/menu.ts +32 -9
  84. package/src/navigation/tabs.ts +13 -2
  85. package/src/overlay/command-palette.ts +13 -2
  86. package/src/shells/workbench-shell.ts +5 -1
@@ -0,0 +1,893 @@
1
+ /**
2
+ * Block fonts, and the engine that draws text in them.
3
+ *
4
+ * A font is data: a table of glyphs, a tracking and a space width. `banner()`
5
+ * lays a string out in one and answers with plain text, which is the shape
6
+ * half only - `ColorText` colours it cell by cell, and the two are deliberately
7
+ * separate, because a banner wants a ramp across it and prose does not.
8
+ * [`FontText`](font-text.tsx) is the one-liner over this: text and a font in,
9
+ * letters out.
10
+ *
11
+ * These ship so that component has something to draw with; the tables are data
12
+ * an application can replace, and `Font` is the whole contract.
13
+ *
14
+ * There is one hand-drawn table and three transforms of it, which is the other
15
+ * thing worth showing: a bitmap font is a grid of characters, and a grid of
16
+ * characters can be sheared, doubled or duplicated by ten lines of code. Every
17
+ * font below has both cases, because the table does.
18
+ *
19
+ * A glyph is a grid of characters. Four of them are placeholders the renderer
20
+ * fills in from the theme - `#` a full cell, `%` a lighter one, `^` the top
21
+ * half of a cell and `v` the bottom half - and every other character is drawn
22
+ * as itself, which is what lets `dots`, `stars` and `mini` be made of dots,
23
+ * stars and pipes rather than of blocks. A space is nothing. Blank columns at
24
+ * either edge are trimmed when a glyph is used, so the letters are
25
+ * proportional rather than a fixed pitch.
26
+ */
27
+ import { graphemeWidth, stringWidth } from '@textui/core';
28
+ import { GARD } from './gard.js';
29
+ import { PAGGA } from './pagga.js';
30
+ /**
31
+ * Capitals, five rows on a five-column body.
32
+ *
33
+ * Every stroke is one cell thick, which is what makes these legible at this
34
+ * size and what makes the `shadow` transform read as a shadow rather than as
35
+ * a thicker letter.
36
+ */
37
+ const CAPS = {
38
+ A: ' ### |# #|#####|# #|# #',
39
+ B: '#### |# #|#### |# #|#### ',
40
+ C: ' ####|# |# |# | ####',
41
+ D: '#### |# #|# #|# #|#### ',
42
+ E: '#####|# |#### |# |#####',
43
+ F: '#####|# |#### |# |# ',
44
+ G: ' ####|# |# ##|# #| ####',
45
+ H: '# #|# #|#####|# #|# #',
46
+ I: '#####| # | # | # |#####',
47
+ J: '#####| # | # |# # | ## ',
48
+ K: '# #|# # |### |# # |# #',
49
+ L: '# |# |# |# |#####',
50
+ M: '# #|## ##|# # #|# #|# #',
51
+ N: '# #|## #|# # #|# ##|# #',
52
+ O: ' ### |# #|# #|# #| ### ',
53
+ P: '#### |# #|#### |# |# ',
54
+ Q: ' ### |# #|# #|# # | ## #',
55
+ R: '#### |# #|#### |# # |# #',
56
+ S: ' ####|# | ### | #|#### ',
57
+ T: '#####| # | # | # | # ',
58
+ U: '# #|# #|# #|# #| ### ',
59
+ V: '# #|# #|# #| # # | # ',
60
+ W: '# #|# #|# # #|## ##|# #',
61
+ X: '# #| # # | # | # # |# #',
62
+ Y: '# #| # # | # | # | # ',
63
+ Z: '#####| # | # | # |#####',
64
+ };
65
+ /**
66
+ * Lowercase, on the same five rows.
67
+ *
68
+ * **One x-height, for all of them.** The body of every lowercase letter sits
69
+ * on rows two to five and the ascenders reach up into row one, so the two
70
+ * cases share a baseline and a cap is visibly taller than an x - which is the
71
+ * whole reason for a second set rather than folding the case away. Letting an
72
+ * `e` be four rows while the `o` beside it was three is what made `Hello` read
73
+ * as a ransom note, and it is the kind of thing only a rendered alphabet shows.
74
+ *
75
+ * Nothing descends below the baseline: five rows is not enough to put a tail
76
+ * under a `g` and keep the line spacing honest, so `g` and `y` hook left
77
+ * instead - which is also what keeps `g` from being a `q`.
78
+ */
79
+ const LOWER = {
80
+ a: ' | ###|# #|# #| ###',
81
+ b: '# |### |# #|# #|### ',
82
+ c: ' | ###|# |# | ###',
83
+ d: ' #| ###|# #|# #| ###',
84
+ e: ' | ## |# #|### | ###',
85
+ f: ' ##| # |### | # | # ',
86
+ g: ' | ###|# #| ###|## ',
87
+ h: '# |### |# #|# #|# #',
88
+ i: ' # | | # | # | # ',
89
+ j: ' # | | # | # |## ',
90
+ k: '# |# #|## |# # |# #',
91
+ l: '## | # | # | # |### ',
92
+ m: ' |#####|# # #|# # #|# # #',
93
+ n: ' |### |# #|# #|# #',
94
+ o: ' | ## |# #|# #| ## ',
95
+ p: ' |### |# #|### |# ',
96
+ q: ' | ###|# #| ###| #',
97
+ r: ' |# ##|## |# |# ',
98
+ s: ' | ###|## | ##|### ',
99
+ t: ' # |### | # | # | # ',
100
+ u: ' |# #|# #|# #| ###',
101
+ v: ' |# #|# #| ## | # ',
102
+ w: ' |# #|# # #|# # #| # # ',
103
+ x: ' |# #| ## | ## |# #',
104
+ y: ' |# #|# #| ###|## ',
105
+ z: ' |####| # | # |####',
106
+ };
107
+ const REST = {
108
+ 0: ' ### |# ##|# # #|## #| ### ',
109
+ 1: ' # | ## | # | # |#####',
110
+ 2: ' ### |# #| # | # |#####',
111
+ 3: '#### | #| ### | #|#### ',
112
+ 4: '# # |# # |#####| # | # ',
113
+ 5: '#####|# |#### | #|#### ',
114
+ 6: ' ### |# |#### |# #| ### ',
115
+ 7: '#####| # | # | # | # ',
116
+ 8: ' ### |# #| ### |# #| ### ',
117
+ 9: ' ### |# #| ####| #| ### ',
118
+ '!': ' # | # | # | | # ',
119
+ '?': ' ### |# #| # | | # ',
120
+ '.': ' | | | | # ',
121
+ ',': ' | | | # |# ',
122
+ ':': ' | # | | # | ',
123
+ ';': ' | # | | # |# ',
124
+ '-': ' | |####| | ',
125
+ '+': ' | # | ### | # | ',
126
+ '=': ' |####| |####| ',
127
+ '*': ' |# # | ### |# # | ',
128
+ '/': ' #| # | # | # |# ',
129
+ '\\': '# | # | # | # | #',
130
+ '(': ' #| # | # | # | #',
131
+ ')': '# | # | # | # |# ',
132
+ "'": ' # | # | | | ',
133
+ '"': '# #|# #| | | ',
134
+ '#': ' # # |#####| # # |#####| # # ',
135
+ '@': ' ### |# #|# ## |# | ### ',
136
+ '&': ' ## |## | ### |# # | ## #',
137
+ '<': ' #| # |# | # | #',
138
+ '>': '# | # | #| # |# ',
139
+ '$': ' ####|# # | ### | # #|#### ',
140
+ '%': '# #| # | # | # |# #',
141
+ '[': '### |# |# |# |### ',
142
+ ']': ' ###| #| #| #| ###',
143
+ '^': ' # | # # |# #| | ',
144
+ '`': '# | # | | | ',
145
+ '{': ' ##| # |## | # | ##',
146
+ '|': '#|#|#|#|#',
147
+ '}': '## | # | ##| # |## ',
148
+ '~': ' | ## |# ##| | ',
149
+ '_': ' | | | |####',
150
+ };
151
+ /** The one hand-drawn table: every glyph, in both cases. */
152
+ const BLOCK = Object.fromEntries(Object.entries({ ...CAPS, ...LOWER, ...REST }).map(([key, spec]) => [key, pad(spec.split('|'))]));
153
+ /**
154
+ * A second hand-drawn table: three rows, and drawn in strokes rather than in
155
+ * cells.
156
+ *
157
+ * Three rows of *solid* cells cannot hold an alphabet - at that size an `A` and
158
+ * an `M` are the same three-by-three block, and so are `G` and `O`. The way out
159
+ * is the one every small figlet font takes: stop filling cells and start
160
+ * drawing strokes, so a `|` and a `/` and a `\` each carry a direction the cell
161
+ * on its own could not. That is why this table is characters rather than `#`,
162
+ * and why it needs no placeholder - it is already what it looks like.
163
+ *
164
+ * One case. At three rows there is no room under a cap for an x-height, so
165
+ * lowercase folds to these; `banner` does the folding.
166
+ */
167
+ const MINI = {
168
+ A: [' _ ', '|_|', '| |'],
169
+ B: [' _ ', '|_)', '|_)'],
170
+ C: [' _ ', '/ ', '\\_ '],
171
+ D: [' _ ', '| \\', '|_/'],
172
+ E: [' _ ', '|_ ', '|_ '],
173
+ F: [' _ ', '|_ ', '| '],
174
+ G: [' _ ', '/ _', '\\_|'],
175
+ H: [' ', '|_|', '| |'],
176
+ I: [' _ ', ' | ', ' | '],
177
+ J: [' _ ', ' |', '._|'],
178
+ K: [' ', '|/ ', '|\\ '],
179
+ L: [' ', '| ', '|_ '],
180
+ M: [' ', '|\\/|', '| |'],
181
+ N: [' ', '|\\ |', '| \\|'],
182
+ O: [' _ ', '/ \\', '\\_/'],
183
+ P: [' _ ', '|_)', '| '],
184
+ Q: [' _ ', '/ \\', '\\_\\'],
185
+ R: [' _ ', '|_)', '| \\'],
186
+ S: [' _ ', '(_ ', '._)'],
187
+ T: ['___', ' | ', ' | '],
188
+ U: [' ', '| |', '╰─╯'],
189
+ V: [' ', '\\ /', ' \\/ '],
190
+ W: [' ', '| |', '|\\/|'],
191
+ X: [' ', '\\ /', '/ \\'],
192
+ Y: [' ', '\\ /', ' | '],
193
+ Z: ['___', ' / ', '/_ '],
194
+ // Struck through, or it is the letter `O` again.
195
+ 0: [' _ ', '/|\\', '\\_/'],
196
+ 1: [' ', ' /|', ' |'],
197
+ 2: [' _ ', ' _)', '(_ '],
198
+ 3: [' _ ', ' _)', ' _)'],
199
+ 4: [' ', '|_|', ' |'],
200
+ 5: [' _ ', '|_ ', ' _)'],
201
+ 6: [' _ ', '/_ ', '(_)'],
202
+ 7: ['___', ' /', ' / '],
203
+ 8: [' _ ', '(_)', '(_)'],
204
+ 9: [' _ ', '(_)', ' _/'],
205
+ '!': [' ', '|', '.'],
206
+ '?': [' _ ', ' _)', ' . '],
207
+ '.': [' ', ' ', '. '],
208
+ ',': [' ', ' ', ', '],
209
+ ':': [' ', '. ', '. '],
210
+ ';': [' ', '. ', ', '],
211
+ '-': [' ', '___', ' '],
212
+ '+': [' ', ' + ', ' '],
213
+ '=': [' _ ', '___', ' '],
214
+ '/': [' ', ' /', ' / '],
215
+ "'": ['| ', ' ', ' '],
216
+ '(': [' /', '| ', ' \\'],
217
+ ')': ['\\ ', ' |', '/ '],
218
+ '_': [' ', ' ', '___'],
219
+ // The rest of printable ascii, in the same strokes. `$ % & @` are not here:
220
+ // at three rows there is no stroke that says "dollar" rather than "S with a
221
+ // line", and a stand-in that says `$` is more use than one that lies.
222
+ '"': ['||', ' ', ' '],
223
+ '#': ['_|_|', '_|_|', ' | |'],
224
+ '*': [' ', '\\|/', '/|\\'],
225
+ '<': [' /', '< ', ' \\'],
226
+ '>': ['\\ ', ' >', '/ '],
227
+ '[': ['|-', '| ', '|_'],
228
+ ']': ['-|', ' |', '_|'],
229
+ '{': [' /', '{ ', ' \\'],
230
+ '}': ['\\ ', ' }', '/ '],
231
+ '\\': ['\\ ', ' \\ ', ' \\'],
232
+ '^': ['/\\', ' ', ' '],
233
+ '`': ['\\ ', ' ', ' '],
234
+ '|': ['|', '|', '|'],
235
+ '~': [' ', '/\\/', ' '],
236
+ };
237
+ /**
238
+ * A third table: three rows of heavy box-drawing.
239
+ *
240
+ * The capitals are transcribed from a font Softov brought, character for
241
+ * character - which is why `X` is four cells wide and `I` is one, and why they
242
+ * are not going to be tidied into a grid. The digits and the punctuation are
243
+ * drawn to match rather than transcribed, because the source had none.
244
+ *
245
+ * One case, and it is the only font here that cannot be drawn at all on an
246
+ * ascii terminal - box-drawing has no `#` to fall back to the way a block does,
247
+ * so this one names `mini` as what to use instead. Same three rows, same
248
+ * stroke-drawn idea, characters a teletype could manage.
249
+ */
250
+ const TMPLT = {
251
+ A: ["┏┓", "┣┫", "┛┗"],
252
+ B: ["┳┓", "┣┫", "┻┛"],
253
+ C: ["┏┓", "┃ ", "┗┛"],
254
+ D: ["┳┓", "┃┃", "┻┛"],
255
+ E: ["┏┓", "┣ ", "┗┛"],
256
+ F: ["┏┓", "┣ ", "┻ "],
257
+ G: ["┏┓", "┃┓", "┗┛"],
258
+ H: ["┓┏", "┣┫", "┛┗"],
259
+ I: ["┳", "┃", "┻"],
260
+ J: ["┏┳", " ┃", "┗┛"],
261
+ K: ["┓┏┓", "┃┫ ", "┛┗┛"],
262
+ L: ["┓ ", "┃ ", "┗┛"],
263
+ M: ["┳┳┓", "┃┃┃", "┛ ┗"],
264
+ N: ["┳┓", "┃┃", "┛┗"],
265
+ O: ["┏┓", "┃┃", "┗┛"],
266
+ P: ["┏┓", "┃┃", "┣┛"],
267
+ Q: ["┏┓", "┃┃", "┗┻"],
268
+ R: ["┳┓", "┣┫", "┛┗"],
269
+ S: ["┏┓", "┗┓", "┗┛"],
270
+ T: ["┏┳┓", " ┃ ", " ┻ "],
271
+ U: ["┳┳", "┃┃", "┗┛"],
272
+ V: ["┓┏", "┃┃", "┗┛"],
273
+ W: ["┓ ┏", "┃┃┃", "┗┻┛"],
274
+ X: ["┏┓┏┓", " ┃┃ ", "┗┛┗┛"],
275
+ Y: ["┓┏", "┗┫", "┗┛"],
276
+ Z: ["┏┓", "┏┛", "┗┛"],
277
+ // Drawn to match, not transcribed. `0` keeps `O`'s shape, which is what a
278
+ // font this small usually does with them.
279
+ // A slash through it, or a zero and a letter `O` are the same glyph.
280
+ 0: ["┏┓", "┃╋", "┗┛"],
281
+ 1: [" ┓", " ┃", " ┻"],
282
+ 2: ["┏┓", "┏┛", "┗┻"],
283
+ 3: ["┏┓", " ┫", "┗┛"],
284
+ 4: ["┓┏", "┗╋", " ┃"],
285
+ 5: ["┏┳", "┗┓", "┗┛"],
286
+ 6: ["┏┓", "┣┓", "┗┛"],
287
+ 7: ["┳┳", " ┃", " ┛"],
288
+ 8: ["┏┓", "┣┫", "┗┛"],
289
+ 9: ["┏┓", "┗┫", "┗┛"],
290
+ 'a': ["", "┏┓", "┗┻"],
291
+ 'b': ["┓ ", "┣┓", "┗┛"],
292
+ 'c': ["", "┏", "┗"],
293
+ 'd': [" ┓", "┏┫", "┗┻"],
294
+ 'e': ["", "┏┓", "┗━"],
295
+ 'f': [" ┏", " ╋", " ┛"],
296
+ 'g': ["", "┏┓", "┗┫", " ┛"],
297
+ 'h': ["┓ ", "┣┓ ", "┛┗ ",],
298
+ 'i': ["•", "┓ ", "┗ ",],
299
+ 'j': ["• ", "┓ ", "┃ ", "┛ ",],
300
+ 'k': ["┓ ", "┃┏", "┛┗",],
301
+ 'l': ["┓ ", "┃ ", "┗ ",],
302
+ 'm': ["", "┏┳┓", "┛┗┗",],
303
+ 'n': ["", "┏┓ ", "┛┗ ",],
304
+ 'o': ["", "┏┓", "┗┛",],
305
+ 'p': ["", "┏┓", "┣┛", "┛ ",],
306
+ 'q': ["", "┏┓", "┗┫", " ┗",],
307
+ 'r': ["", "┏┓", "┛ ",],
308
+ 's': ["", "┏ ", "┛ ",],
309
+ 't': ["┃ ", "╋ ", "┗━"],
310
+ 'u': ["", "┓┏", "┗┻",],
311
+ 'v': ["", "┓┏", "┗┛",],
312
+ 'w': ["", "┓┃┏", "┗┻┛"],
313
+ 'x': ["", "┓┏", "┛┗",],
314
+ 'y': [" ", "┓┏", "┗┫", " ┛"],
315
+ 'z': ["", "━┓", "┗━"],
316
+ '.': [" ", " ", "•"],
317
+ ',': [" ", " ", "┛"],
318
+ ':': [" ", "•", "•"],
319
+ ';': [" ", "•", "┛"],
320
+ '!': ["┃", "┃", "•"],
321
+ '?': ["┏┓", " ┛", " •"],
322
+ '@': ["┏━┓", "┃┗┛", "┗━┛"],
323
+ '$': ["┏┻┓", "┗━┓", "┗┳┛"],
324
+ '&': ["•┏", " ┃", " ┛•"],
325
+ '%': ["┏┓", "┣╋", "┗┻"],
326
+ '¨': ["••"],
327
+ '*': [" ", "•"],
328
+ '-': [" ", "━━", " "],
329
+ '+': [" ", "╋ ", " "],
330
+ '=': [" ", "━━", "━━"],
331
+ '/': [" ┏", "┏┛", "┛ "],
332
+ "'": ["┃", " ", " "],
333
+ '(': ["┏", "┃", "┗"],
334
+ ')': ["┓", "┃", "┛"],
335
+ '_': [" ", " ", "━━"],
336
+ // Also drawn to match. `$ % & @ *` are left out: box-drawing has no stroke
337
+ // for any of them, and a `╋` standing in for a `*` is a plus sign lying.
338
+ '"': ["┃┃", " ", " "],
339
+ '#': ["", "╋╋", "╋╋", " "],
340
+ '<': [" ┏", "┫ ", " ┗"],
341
+ '>': ["┓ ", " ┣", "┛ "],
342
+ // The square pair takes a bar off each corner, so it is not the round pair
343
+ // drawn a second time - which is what it was until a test said so.
344
+ '[': ["┏━", "┃ ", "┗━"],
345
+ ']': ["━┓", " ┃", "━┛"],
346
+ '{': ["┏", "┫", "┗"],
347
+ '}': ["┓", "┣", "┛"],
348
+ '\\': ["┓ ", "┗┓", " ┛"],
349
+ '^': ["┏┓", " ", " "],
350
+ '`': ["┓ ", " ", " "],
351
+ '|': ["┃", "┃", "┃"],
352
+ '~': [" ", "┏┛", " "],
353
+ };
354
+ // ------------------------------------------------------------- transforms
355
+ /** Every row the same length, so a transform can index a rectangle. */
356
+ function pad(rows) {
357
+ const width = rows.reduce((w, row) => Math.max(w, row.length), 0);
358
+ return rows.map((row) => row.padEnd(width, ' '));
359
+ }
360
+ function mapGlyphs(font, fn) {
361
+ return Object.fromEntries(Object.entries(font).map(([key, rows]) => [key, pad(fn(rows))]));
362
+ }
363
+ /** Every column twice. Twice the letter, the same shape. */
364
+ function wide(rows) {
365
+ return rows.map((row) => Array.from(row).map((c) => c + c).join(''));
366
+ }
367
+ /**
368
+ * Sheared right, more the higher up the row is - an italic.
369
+ *
370
+ * Half a cell per row rather than one, because a five-row letter leaning five
371
+ * columns is a letter lying down. The shear is what a bitmap font has instead
372
+ * of a second table: the letters are the same letters.
373
+ */
374
+ function slant(rows) {
375
+ const height = rows.length;
376
+ return rows.map((row, y) => ' '.repeat(Math.floor((height - 1 - y) / 2)) + row);
377
+ }
378
+ /**
379
+ * The letter, and a second copy of it down and to the right in `%`.
380
+ *
381
+ * Two rules, and the second is the one that took a try to get right. The
382
+ * letter is drawn over the shadow, so where they overlap the letter wins. And
383
+ * the shadow is kept only *outside* the letter - the counter of an `A` is
384
+ * enclosed, a real shadow could not fall into it, and one that did turned a
385
+ * five-row capital into a smudge. `outside` is a flood fill from the border,
386
+ * which is the cheapest way to ask whether a hole is a hole.
387
+ */
388
+ function shadow(rows) {
389
+ const height = rows.length + 1;
390
+ const width = rows[0].length + 1;
391
+ const lit = (y, x) => y >= 0 && y < rows.length && x >= 0 && x < rows[y].length
392
+ && rows[y][x] !== ' ';
393
+ const open = outside(height, width, (y, x) => lit(y, x));
394
+ const out = Array.from({ length: height }, () => Array.from({ length: width }, () => ' '));
395
+ for (let y = 0; y < height; y++) {
396
+ for (let x = 0; x < width; x++) {
397
+ if (lit(y - 1, x - 1) && open[y * width + x])
398
+ out[y][x] = '%';
399
+ }
400
+ }
401
+ for (let y = 0; y < rows.length; y++) {
402
+ for (let x = 0; x < rows[y].length; x++) {
403
+ if (lit(y, x))
404
+ out[y][x] = '#';
405
+ }
406
+ }
407
+ return out.map((row) => row.join(''));
408
+ }
409
+ /** Which blank cells a flood fill from the border can reach. */
410
+ function outside(height, width, lit) {
411
+ const open = new Array(height * width).fill(false);
412
+ const queue = [];
413
+ const push = (y, x) => {
414
+ if (y < 0 || y >= height || x < 0 || x >= width)
415
+ return;
416
+ if (open[y * width + x] || lit(y, x))
417
+ return;
418
+ open[y * width + x] = true;
419
+ queue.push([y, x]);
420
+ };
421
+ for (let x = 0; x < width; x++) {
422
+ push(0, x);
423
+ push(height - 1, x);
424
+ }
425
+ for (let y = 0; y < height; y++) {
426
+ push(y, 0);
427
+ push(y, width - 1);
428
+ }
429
+ while (queue.length > 0) {
430
+ const [y, x] = queue.pop();
431
+ push(y - 1, x);
432
+ push(y + 1, x);
433
+ push(y, x - 1);
434
+ push(y, x + 1);
435
+ }
436
+ return open;
437
+ }
438
+ /**
439
+ * Two rows of the table to one row of output, as half cells.
440
+ *
441
+ * The only transform that changes the *height*: five rows become three, and a
442
+ * banner that would not fit a short terminal does. A cell is full where both
443
+ * source rows are lit, a top or a bottom half where one is, and nothing where
444
+ * neither - which is four characters standing in for four combinations, and
445
+ * why it is a substitution rather than a redrawing.
446
+ */
447
+ function half(rows) {
448
+ const width = rows[0].length;
449
+ const out = [];
450
+ for (let y = 0; y < rows.length; y += 2) {
451
+ let line = '';
452
+ for (let x = 0; x < width; x++) {
453
+ const top = rows[y][x] !== ' ';
454
+ const bottom = y + 1 < rows.length && rows[y + 1][x] !== ' ';
455
+ line += top && bottom ? '#' : top ? '^' : bottom ? 'v' : ' ';
456
+ }
457
+ out.push(line);
458
+ }
459
+ return out;
460
+ }
461
+ /**
462
+ * Drawn in dots and colons rather than in blocks.
463
+ *
464
+ * A cell takes the character of the stroke that owns it, and the two strokes
465
+ * that can both claim a cell are settled by one question: **does anything come
466
+ * down into it?**
467
+ *
468
+ * * a neighbour across, and nothing above - the bar owns it, a dot;
469
+ * * anything else with a neighbour above or below - a stem owns it, a colon;
470
+ * * neither - a dot, which is what a diagonal and a full stop are made of.
471
+ *
472
+ * Both halves of that were learnt the hard way and neither is arbitrary. Ask
473
+ * about the vertical first and the top bar of a `T` comes out `..:..`, because
474
+ * the stem hangs off its middle - but a stem hanging *below* a bar does not
475
+ * take a cell out of the bar. Ask only about the horizontal and the bottom bar
476
+ * of an `I` comes out `.....`, because the stem is above it - and a stem
477
+ * landing *on* a bar does show where it lands. A `T` and an `I` are the same
478
+ * two strokes; which one wins depends on which way the stem runs.
479
+ *
480
+ * The letters are the same letters. This is a change of pen, not of hand, which
481
+ * is why it is twenty lines.
482
+ */
483
+ function dots(rows) {
484
+ // Both bounds, on both axes. Indexing a string past its end gives
485
+ // `undefined`, and `undefined !== ' '` reads as lit - so the last column of
486
+ // every row believed it had a neighbour to the right.
487
+ const lit = (y, x) => y >= 0 && y < rows.length
488
+ && x >= 0 && x < rows[y].length
489
+ && rows[y][x] !== ' ';
490
+ return rows.map((row, y) => Array.from(row)
491
+ .map((cell, x) => {
492
+ if (cell === ' ')
493
+ return ' ';
494
+ const across = lit(y, x - 1) || lit(y, x + 1);
495
+ if (across && !lit(y - 1, x))
496
+ return '.';
497
+ return lit(y - 1, x) || lit(y + 1, x) ? ':' : '.';
498
+ })
499
+ .join(''));
500
+ }
501
+ /** Every lit cell a star with a gap after it, which is `wide` with one column of two drawn. */
502
+ function stars(rows) {
503
+ return rows.map((row) => Array.from(row).map((c) => (c === ' ' ? ' ' : '* ')).join(''));
504
+ }
505
+ export const FONTS = [
506
+ {
507
+ id: 'block',
508
+ title: 'block',
509
+ note: 'The hand-drawn table: five rows, one cell to a stroke, both cases on one baseline.',
510
+ glyphs: BLOCK,
511
+ tracking: 1,
512
+ space: 3,
513
+ placeholders: true,
514
+ cases: 'both',
515
+ },
516
+ {
517
+ id: 'wide',
518
+ title: 'wide',
519
+ note: 'The same table with every column drawn twice. Twice the letter, the same shape - and twice as much of it for a ramp to run across.',
520
+ glyphs: mapGlyphs(BLOCK, wide),
521
+ tracking: 2,
522
+ space: 4,
523
+ placeholders: true,
524
+ cases: 'both',
525
+ },
526
+ {
527
+ id: 'slant',
528
+ title: 'slant',
529
+ note: 'The same table sheared half a column per row. A bitmap font gets its italic the way it gets everything else: by moving the cells.',
530
+ glyphs: mapGlyphs(BLOCK, slant),
531
+ tracking: 1,
532
+ space: 3,
533
+ placeholders: true,
534
+ cases: 'both',
535
+ },
536
+ {
537
+ id: 'shadow',
538
+ title: 'shadow',
539
+ note: 'The same table twice: the letter, and a copy of it one cell down and right in a second glyph. Two characters in one block, which an ink colours without being told there are two.',
540
+ glyphs: mapGlyphs(BLOCK, shadow),
541
+ tracking: 1,
542
+ space: 3,
543
+ placeholders: true,
544
+ cases: 'both',
545
+ },
546
+ {
547
+ id: 'half',
548
+ title: 'half',
549
+ note: 'Two rows of the table to one row of half cells. The only transform that changes the height - five rows become three, and a banner that would not fit a short terminal does.',
550
+ glyphs: mapGlyphs(BLOCK, half),
551
+ tracking: 1,
552
+ space: 3,
553
+ placeholders: true,
554
+ cases: 'both',
555
+ },
556
+ {
557
+ id: 'dots',
558
+ title: 'dots',
559
+ note: 'A dot where a bar runs across, a colon where a stem runs down - and the junction goes to whichever one comes down into it. The same letters in a different pen.',
560
+ glyphs: mapGlyphs(BLOCK, dots),
561
+ tracking: 1,
562
+ space: 3,
563
+ cases: 'both',
564
+ },
565
+ {
566
+ id: 'stars',
567
+ title: 'stars',
568
+ note: 'Every lit cell a star with a gap after it, which is `wide` with one column of the two drawn. Airy enough that a per-cell ink reads as a pattern rather than as a wash.',
569
+ glyphs: mapGlyphs(BLOCK, stars),
570
+ tracking: 2,
571
+ space: 4,
572
+ cases: 'both',
573
+ },
574
+ {
575
+ id: 'pagga',
576
+ title: 'pagga',
577
+ note: 'Three rows of half cells on a shaded ground - the difference from half, and the point of it: the letters are knocked out of a block of texture rather than floating on the terminal.',
578
+ glyphs: PAGGA,
579
+ tracking: 0,
580
+ space: 4,
581
+ cases: 'folded',
582
+ fallback: 'mini',
583
+ },
584
+ {
585
+ id: 'gard',
586
+ title: 'gard',
587
+ note: 'Quotes, pipes and dots, transcribed glyph for glyph - the only font here with two cases of its own rather than a fold, and the only one with letters that hang below the baseline.',
588
+ glyphs: GARD,
589
+ tracking: 1,
590
+ space: 3,
591
+ baseline: 6,
592
+ cases: 'both',
593
+ },
594
+ {
595
+ id: 'tmplt',
596
+ title: 'tmplt',
597
+ note: 'Heavy box-drawing, transcribed capital for capital, with a lowercase drawn to match - three rows and a fourth for the tails of g, j, p, q and y. Nothing to fall back to on an ascii terminal, so it borrows mini there.',
598
+ glyphs: TMPLT,
599
+ tracking: 1,
600
+ space: 2,
601
+ // Row two, not row three: the box grew a fourth row for the descenders
602
+ // and a stand-in belongs on the line the letters sit on, not on the one
603
+ // their tails reach down to.
604
+ baseline: 2,
605
+ cases: 'both',
606
+ fallback: 'mini',
607
+ },
608
+ {
609
+ id: 'mini',
610
+ title: 'mini',
611
+ note: 'A second table, three rows, drawn in strokes rather than in cells - because at three rows a solid A and a solid M are the same block. One case: lowercase folds to it.',
612
+ glyphs: MINI,
613
+ tracking: 1,
614
+ space: 2,
615
+ cases: 'folded',
616
+ },
617
+ ];
618
+ export function fontAt(id) {
619
+ return FONTS.find((f) => f.id === id) ?? FONTS[0];
620
+ }
621
+ /** The font that is drawn in when a caller names none: the hand-drawn table. */
622
+ export const DEFAULT_FONT = fontAt('block');
623
+ /** The tallest glyph in a font, which is how many rows a line of it takes. */
624
+ export function heightOf(font) {
625
+ return Object.values(font.glyphs).reduce((h, rows) => Math.max(h, rows.length), 0);
626
+ }
627
+ // ----------------------------------------------------------------- render
628
+ /**
629
+ * The glyph for a character, or nothing.
630
+ *
631
+ * Its own, or the other case's - folding is what lets a font with one set of
632
+ * letters take either - and trimmed of blank edge columns, so a `1` is narrow
633
+ * and an `M` is wide. Shared by the drawing and the measuring, because a wrap
634
+ * that measured a letter differently from the way it is drawn is a wrap that
635
+ * is wrong by however much they disagree.
636
+ */
637
+ function glyphOf(font, char) {
638
+ if (invisible(char))
639
+ return undefined;
640
+ const found = font.glyphs[char]
641
+ ?? font.glyphs[char.toUpperCase()]
642
+ ?? font.glyphs[char.toLowerCase()];
643
+ if (found !== undefined)
644
+ return trim(found);
645
+ return char === ' ' ? undefined : standIn(font, char);
646
+ }
647
+ /**
648
+ * A character the font has no glyph for, drawn as itself.
649
+ *
650
+ * Not a gap. A gap is indistinguishable from a space, so a font missing its
651
+ * punctuation renders `hello, world!` as `hello world` and looks like it
652
+ * worked - the reader has no way to tell a missing glyph from a word break.
653
+ * One cell with the character in it is legible, obviously not part of the
654
+ * font, and says exactly which character is absent.
655
+ *
656
+ * On the baseline, so it sits on the line with the letters beside it rather
657
+ * than under them.
658
+ */
659
+ function standIn(font, char) {
660
+ const height = heightOf(font);
661
+ const line = font.baseline ?? height - 1;
662
+ // Padded to the cells it actually occupies. A wide character is one string
663
+ // index and two columns, and a stand-in measured by index put every letter
664
+ // after it one column to the left of where it was drawn.
665
+ const width = Math.max(1, stringWidth(char));
666
+ return Array.from({ length: height }, (_, y) => (y === line ? char : ' '.repeat(width)));
667
+ }
668
+ /**
669
+ * A character that draws nothing and takes no room, because it is not there.
670
+ *
671
+ * Zero-width and control characters arrive by paste - a byte-order mark off a
672
+ * web page, a zero-width space out of a code block - and they used to come out
673
+ * as a *word gap*, because a character with no glyph gets one. So `AB` pasted
674
+ * with a joiner between the letters rendered as `A B`, and the banner had a
675
+ * word break in it that the field it was typed in did not.
676
+ *
677
+ * Invisible in the text, invisible in the banner. It is the only reading that
678
+ * cannot surprise anyone.
679
+ */
680
+ const INVISIBLE = /^[\p{Cf}\p{Cc}\p{Mn}\p{Me}]$/u;
681
+ function invisible(char) {
682
+ return graphemeWidth(char) === 0 || INVISIBLE.test(char);
683
+ }
684
+ /**
685
+ * A character that is a space, whatever its code point.
686
+ *
687
+ * A non-breaking space is a space: it arrives by paste out of anything that
688
+ * has ever been near a web page, and it was drawing as a stand-in - one blank
689
+ * cell where a word gap belonged, so the words either side ran together.
690
+ */
691
+ const SPACE = /^\p{Zs}$/u;
692
+ /** The columns a character takes, not counting the gap before it. */
693
+ function widthOf(font, char) {
694
+ if (invisible(char))
695
+ return 0;
696
+ if (SPACE.test(char))
697
+ return font.space;
698
+ const glyph = glyphOf(font, char);
699
+ return glyph ? stringWidth(glyph[0]) : font.space;
700
+ }
701
+ /**
702
+ * Blank edge columns removed, so a `1` is narrow and an `M` is wide.
703
+ *
704
+ * The width is the widest row and not the first one. A table written by hand
705
+ * has rows of whatever length they happened to be typed at, and `gard` has a
706
+ * blank first row on every letter - so taking row zero's length trimmed every
707
+ * one of them to nothing at all.
708
+ */
709
+ function trim(rows) {
710
+ const width = rows.reduce((w, row) => Math.max(w, row.length), 0);
711
+ const padded = rows.map((row) => row.padEnd(width, ' '));
712
+ let start = 0;
713
+ let end = width - 1;
714
+ const blank = (x) => padded.every((row) => row[x] === ' ');
715
+ while (start < width && blank(start))
716
+ start++;
717
+ while (end >= start && blank(end))
718
+ end--;
719
+ return padded.map((row) => row.slice(start, end + 1));
720
+ }
721
+ export function inkGlyphs(glyphs, unicode = true) {
722
+ return {
723
+ fill: glyphs.progressFull,
724
+ shade: glyphs.progressEmpty,
725
+ top: unicode ? '▀' : '"',
726
+ bottom: unicode ? '▄' : '_',
727
+ };
728
+ }
729
+ /** What the fonts are drawn in where nothing has said otherwise. */
730
+ export const PLAIN_GLYPHS = { fill: '#', shade: '-', top: '"', bottom: '_' };
731
+ /**
732
+ * Text as block letters.
733
+ *
734
+ * Newlines are lines: each one becomes its own block of rows, stacked with
735
+ * `lineGap` blank rows between them so two lines of banner do not read as one.
736
+ * A character with no glyph becomes a word space, so a missing one shows as a
737
+ * gap the reader can see rather than silently closing up.
738
+ */
739
+ export function bannerLines(text, font, ink = PLAIN_GLYPHS, width = Infinity) {
740
+ const height = heightOf(font);
741
+ return text
742
+ .split('\n')
743
+ .flatMap((line) => wrapToWidth(line, font, width))
744
+ .map((line) => bannerLine(line, font, height, ink));
745
+ }
746
+ /**
747
+ * The same banner, one string per line.
748
+ *
749
+ * For a caller that has to place the lines itself, and the case that makes it
750
+ * necessary is centring: a multi-line block is centred once, as a block, so its
751
+ * shorter lines start at the widest line's edge and read as left-aligned. One
752
+ * `ColorText` per line - each `alignBlock` and `textAlign="center"` - centres
753
+ * every line over its own width while keeping the rows of each in step.
754
+ *
755
+ * The catch is the ink: a [`ColorText`](color-text.md) starts its ink at its
756
+ * own first cell, so a block drawn as several pieces restarts a `cycle` (and a
757
+ * `{ gradient }` measures each piece rather than the lot). That is a reason to
758
+ * keep one component for a continuous ramp, and this for centring.
759
+ */
760
+ export function banner(text, font, ink = PLAIN_GLYPHS, width = Infinity,
761
+ /**
762
+ * Blank rows between one line and the next. One by default.
763
+ *
764
+ * Counts the *empty* rows, so `0` butts two lines together and `2` leaves two
765
+ * rows of air. It applies to every line the block has, wrapped or written -
766
+ * the break between two lines of a sentence is the same break as the one
767
+ * between two paragraphs, which is what makes a wrapped banner read as a
768
+ * block of its own rather than as one long line that ran out of room.
769
+ */
770
+ lineGap = 1) {
771
+ const gap = `\n${'\n'.repeat(Math.max(0, Math.floor(lineGap)))}`;
772
+ return bannerLines(text, font, ink, width).join(gap);
773
+ }
774
+ /**
775
+ * Break a line of text into lines that fit, measured in this font.
776
+ *
777
+ * The wrapping has to happen *here*, on the text, and not on the drawn block:
778
+ * a line of block letters cut at column sixty is a line of half letters, and
779
+ * five rows each cut in a different place is not a word at all. So the letters
780
+ * are measured before they are drawn, and the break goes between them.
781
+ *
782
+ * Words first, characters only when one word cannot fit on a line of its own -
783
+ * because breaking `Deployment` in half is bad and dropping the second half is
784
+ * worse, and one of those has to happen.
785
+ */
786
+ function wrapToWidth(text, font, width) {
787
+ if (!Number.isFinite(width) || width <= 0)
788
+ return [text];
789
+ const lines = [];
790
+ let line = '';
791
+ let used = 0;
792
+ const flush = () => { lines.push(line); line = ''; used = 0; };
793
+ /**
794
+ * Put a piece on the line, or on the next one.
795
+ *
796
+ * `sep` is the gap it needs from what is already there - a word space
797
+ * between words, tracking between the letters of one word - and `gap` is
798
+ * what that gap is spelled as in the text, which is a space for the first
799
+ * and nothing for the second.
800
+ */
801
+ const add = (piece, cost, sep, gap) => {
802
+ if (line !== '' && used + sep + cost > width)
803
+ flush();
804
+ if (line === '') {
805
+ line = piece;
806
+ used = cost;
807
+ return;
808
+ }
809
+ line += gap + piece;
810
+ used += sep + cost;
811
+ };
812
+ for (const word of text.split(' ')) {
813
+ const cost = costOf(word, font);
814
+ if (cost === 0)
815
+ continue;
816
+ if (cost <= width) {
817
+ add(word, cost, font.space, ' ');
818
+ continue;
819
+ }
820
+ // Wider than a whole line even on its own, so it is spent a character at a
821
+ // time - starting in whatever room is left on this line rather than on a
822
+ // fresh one, because a line holding three letters and a lot of air is not
823
+ // an improvement on a broken word.
824
+ let first = true;
825
+ for (const char of Array.from(word)) {
826
+ add(char, widthOf(font, char), first ? font.space : font.tracking, first ? ' ' : '');
827
+ first = false;
828
+ }
829
+ }
830
+ if (line !== '' || lines.length === 0)
831
+ lines.push(line);
832
+ return lines;
833
+ }
834
+ /** The columns a word takes, letters and the tracking between them. */
835
+ function costOf(word, font) {
836
+ // Invisible characters are dropped before the tracking is counted, or a
837
+ // word of nothing but a byte-order mark would still be charged for the gaps
838
+ // between the letters it does not have.
839
+ const chars = Array.from(word).filter((char) => !invisible(char));
840
+ return chars.reduce((total, char, i) => total + (i === 0 ? 0 : font.tracking) + widthOf(font, char), 0);
841
+ }
842
+ function bannerLine(text, font, height, ink) {
843
+ const rows = Array.from({ length: height }, () => '');
844
+ let first = true;
845
+ for (const char of Array.from(text)) {
846
+ if (invisible(char))
847
+ continue;
848
+ // A space is a gap unless the font draws one. `pagga` does: its ground has
849
+ // to run through the gap between two words, and a gap would be a hole in
850
+ // it - so the table has a glyph for `' '` and that glyph wins.
851
+ const glyph = SPACE.test(char)
852
+ ? (font.glyphs[' '] === undefined ? undefined : trim(font.glyphs[' ']))
853
+ : glyphOf(font, char);
854
+ if (!glyph) {
855
+ for (let y = 0; y < height; y++)
856
+ rows[y] += ' '.repeat(font.space);
857
+ first = true;
858
+ continue;
859
+ }
860
+ // Top-aligned: row nought of a glyph is row nought of the line, and a
861
+ // glyph shorter than the font is short at the *bottom*.
862
+ //
863
+ // Which is what lets a descender be written as one row taller than
864
+ // everything else and nothing else be touched. An `a` says where it starts
865
+ // with a blank first row; a `g` says where it ends by having a fourth. Line
866
+ // them up from the bottom instead and adding a tail to `g` silently pushes
867
+ // every three-row capital down a row, so `A` comes to rest on the same line
868
+ // as the tail rather than on the same line as the `g` itself.
869
+ const width = glyph.reduce((w, row) => Math.max(w, row.length), 0);
870
+ for (let y = 0; y < height; y++) {
871
+ const row = y < glyph.length ? glyph[y] : ' '.repeat(width);
872
+ rows[y] += (first ? '' : ' '.repeat(font.tracking)) + row;
873
+ }
874
+ first = false;
875
+ }
876
+ // Blank rows off the top and the bottom, so a line of nothing but x-height
877
+ // letters is four rows rather than five with a gap over it. Per line, which
878
+ // is the unit that is stacked - the letters inside one still share a
879
+ // baseline, and that is the alignment that has to hold.
880
+ // Only where the font said its glyphs are placeholders. A hand-drawn table
881
+ // is already what it looks like, and running this over one would turn its
882
+ // literal `#` into a full block and its `v` into half a cell.
883
+ const ink_ = (row) => (font.placeholders
884
+ ? row.replace(/#/g, ink.fill).replace(/%/g, ink.shade)
885
+ .replace(/\^/g, ink.top).replace(/v/g, ink.bottom)
886
+ : row);
887
+ const drawn = rows.map((row) => ink_(row).trimEnd());
888
+ while (drawn.length > 0 && drawn[0] === '')
889
+ drawn.shift();
890
+ while (drawn.length > 0 && drawn[drawn.length - 1] === '')
891
+ drawn.pop();
892
+ return drawn.join('\n');
893
+ }