@uniflowed/tui 0.0.0-alpha.18

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.
package/capability.js ADDED
@@ -0,0 +1,366 @@
1
+ // @flow
2
+ //
3
+ // What the attached terminal can actually render.
4
+ //
5
+ // A TUI that assumes 24-bit colour, a mouse and box-drawing characters works
6
+ // on the machine it was written on and produces unreadable noise on a build
7
+ // agent, in a `TERM=dumb` shell, and in every terminal a screen reader is
8
+ // pointed at. This module is where that assumption is refused, once, before
9
+ // the first frame.
10
+ //
11
+ // # It is the CLI's answer, not a second one
12
+ //
13
+ // `crates/uf_term/src/capability.rs` already answers this question for `uf`
14
+ // itself, and it answers it after a long argument with reality — the
15
+ // precedence order below is its order, variable for variable, including the
16
+ // parts that look arbitrary. `CLICOLOR_FORCE` beating `TERM=dumb` is not an
17
+ // oversight; `FORCE_COLOR=` with an empty value meaning "on" is a convention
18
+ // three other tools already established. Reproducing it exactly is the point:
19
+ // a project whose CLI and whose TUI library disagree about whether this
20
+ // terminal takes colour is a project that will be sent a screenshot of one of
21
+ // them being wrong.
22
+ //
23
+ // # Detected once
24
+ //
25
+ // Capability is resolved at start-up from three inputs — an explicit choice,
26
+ // the environment, and whether the stream is a terminal — and then carried as
27
+ // a plain value. No write path re-reads `process.env`, which is both faster
28
+ // and the only way the result can be tested without mutating the environment
29
+ // of the process running the test.
30
+
31
+ /** How much colour a stream can carry. */
32
+ export type ColorLevel = "none" | "ansi16" | "ansi256" | "truecolor";
33
+
34
+ /** Which glyph vocabulary is safe to print. */
35
+ export type GlyphSet = "unicode" | "ascii";
36
+
37
+ /** Whether a human is looking at the stream. */
38
+ export type Tty = "interactive" | "piped";
39
+
40
+ /** What a caller asked for, before the environment gets a say. */
41
+ export type ColorChoice = "auto" | "always" | "never";
42
+
43
+ /** The environment variables that influence terminal rendering. */
44
+ export type TerminalEnv = {
45
+ readonly NO_COLOR?: string,
46
+ readonly FORCE_COLOR?: string,
47
+ readonly CLICOLOR?: string,
48
+ readonly CLICOLOR_FORCE?: string,
49
+ readonly TERM?: string,
50
+ readonly COLORTERM?: string,
51
+ readonly COLUMNS?: string,
52
+ readonly LINES?: string,
53
+ readonly LC_ALL?: string,
54
+ readonly LC_CTYPE?: string,
55
+ readonly LANG?: string,
56
+ };
57
+
58
+ /** The resolved rendering capability of one stream. */
59
+ export type Capabilities = {
60
+ readonly color: ColorLevel,
61
+ readonly glyphs: GlyphSet,
62
+ readonly tty: Tty,
63
+ };
64
+
65
+ const LEVELS: $ReadOnlyArray<ColorLevel> = ["none", "ansi16", "ansi256", "truecolor"];
66
+
67
+ /** Order colour levels so `max` and `min` mean what they say. */
68
+ const rank = (level: ColorLevel): number => LEVELS.indexOf(level);
69
+
70
+ const nonEmpty = (value: string | void): string | null =>
71
+ value != null && value !== "" ? value : null;
72
+
73
+ const has = (haystack: string, needle: string): boolean => haystack.toLowerCase().includes(needle);
74
+
75
+ /** The level `COLORTERM` and `TERM` advertise, before any switch is applied. */
76
+ function declaredLevel(env: TerminalEnv): ColorLevel {
77
+ const colorterm = nonEmpty(env.COLORTERM);
78
+ if (colorterm != null && (has(colorterm, "truecolor") || has(colorterm, "24bit"))) {
79
+ return "truecolor";
80
+ }
81
+ const term = nonEmpty(env.TERM);
82
+ if (term == null) {
83
+ return "ansi16";
84
+ }
85
+ if (has(term, "direct")) {
86
+ return "truecolor";
87
+ }
88
+ return has(term, "256") ? "ansi256" : "ansi16";
89
+ }
90
+
91
+ /** `FORCE_COLOR`, which both disables colour (`0`) and picks a level (`1`–`3`). */
92
+ function forceColorLevel(env: TerminalEnv): ColorLevel | null {
93
+ const raw = env.FORCE_COLOR;
94
+ if (raw == null) {
95
+ return null;
96
+ }
97
+ const value = raw.trim();
98
+ if (value === "0" || value === "false") {
99
+ return "none";
100
+ }
101
+ if (value === "2") {
102
+ return "ansi256";
103
+ }
104
+ if (value === "3") {
105
+ return "truecolor";
106
+ }
107
+ const declared = declaredLevel(env);
108
+ return rank(declared) > rank("ansi16") ? declared : "ansi16";
109
+ }
110
+
111
+ /**
112
+ * Whether the locale says this terminal understands UTF-8.
113
+ *
114
+ * An *unset* locale counts as yes. It is the common case on macOS and inside
115
+ * container images that render UTF-8 perfectly well, and treating it as a
116
+ * downgrade signal would give a plain-ASCII interface to most of the people
117
+ * who would rather have the box-drawing characters.
118
+ */
119
+ function utf8Locale(env: TerminalEnv): boolean {
120
+ const locale = nonEmpty(env.LC_ALL) ?? nonEmpty(env.LC_CTYPE) ?? nonEmpty(env.LANG);
121
+ if (locale == null) {
122
+ return true;
123
+ }
124
+ return has(locale, "utf-8") || has(locale, "utf8");
125
+ }
126
+
127
+ /**
128
+ * Resolve capability from a choice, a stream classification, and an
129
+ * environment.
130
+ *
131
+ * Precedence, highest first — this list is
132
+ * `crates/uf_term/src/capability.rs`'s, and changing it here alone is how the
133
+ * CLI and the library start disagreeing:
134
+ *
135
+ * 1. an explicit `"never"` or `"always"`
136
+ * 2. `NO_COLOR` (any non-empty value)
137
+ * 3. `FORCE_COLOR`
138
+ * 4. `CLICOLOR_FORCE`
139
+ * 5. `TERM=dumb`
140
+ * 6. `CLICOLOR=0`
141
+ * 7. whether the stream is a terminal
142
+ * 8. `COLORTERM` / `TERM`
143
+ */
144
+ export function detectCapabilities(choice: ColorChoice, tty: Tty, env: TerminalEnv): Capabilities {
145
+ const dumb = env.TERM === "dumb";
146
+ return {
147
+ color: detectColor(choice, tty, env, dumb),
148
+ // Glyphs do not follow colour. A terminal can be perfectly capable of
149
+ // UTF-8 while its output is being piped into a file, and replacing a box
150
+ // border with `+---+` in that file helps nobody.
151
+ glyphs: dumb || !utf8Locale(env) ? "ascii" : "unicode",
152
+ tty,
153
+ };
154
+ }
155
+
156
+ function detectColor(choice: ColorChoice, tty: Tty, env: TerminalEnv, dumb: boolean): ColorLevel {
157
+ if (choice === "never") {
158
+ return "none";
159
+ }
160
+ if (choice === "always") {
161
+ const declared = declaredLevel(env);
162
+ return rank(declared) > rank("ansi16") ? declared : "ansi16";
163
+ }
164
+ if (nonEmpty(env.NO_COLOR) != null) {
165
+ return "none";
166
+ }
167
+ const forced = forceColorLevel(env);
168
+ if (forced != null) {
169
+ return forced;
170
+ }
171
+ const clicolorForce = nonEmpty(env.CLICOLOR_FORCE);
172
+ if (clicolorForce != null && clicolorForce !== "0") {
173
+ const declared = declaredLevel(env);
174
+ return rank(declared) > rank("ansi16") ? declared : "ansi16";
175
+ }
176
+ if (dumb) {
177
+ return "none";
178
+ }
179
+ if (nonEmpty(env.CLICOLOR) === "0") {
180
+ return "none";
181
+ }
182
+ if (tty === "piped") {
183
+ return "none";
184
+ }
185
+ return declaredLevel(env);
186
+ }
187
+
188
+ /**
189
+ * The most conservative capability there is.
190
+ *
191
+ * What a redirected stream and a snapshot test both use: no escape sequences
192
+ * at all, ASCII glyphs, nobody watching.
193
+ */
194
+ export function plainCapabilities(): Capabilities {
195
+ return { color: "none", glyphs: "ascii", tty: "piped" };
196
+ }
197
+
198
+ /** How wide a terminal is assumed to be when nothing will say. */
199
+ export const FALLBACK_COLUMNS: number = 80;
200
+
201
+ /** How tall a terminal is assumed to be when nothing will say. */
202
+ export const FALLBACK_ROWS: number = 24;
203
+
204
+ /** How big the terminal is, in cells. */
205
+ export type TerminalSize = {
206
+ readonly columns: number,
207
+ readonly rows: number,
208
+ };
209
+
210
+ /**
211
+ * What the stream said about itself, if anything.
212
+ *
213
+ * `process.stdout.columns` is `undefined` on a stream that is not a terminal,
214
+ * and a stand-in a test wrote may not have the properties at all — so this is
215
+ * every field optional rather than a size, and the difference between "the
216
+ * terminal says 80" and "nothing said anything" is kept.
217
+ */
218
+ export type TerminalReport = {
219
+ readonly columns?: number,
220
+ readonly rows?: number,
221
+ ...
222
+ };
223
+
224
+ /** `COLUMNS`, when it names a usable number of columns. */
225
+ function declaredColumns(env: TerminalEnv): number | null {
226
+ return positive(env.COLUMNS);
227
+ }
228
+
229
+ /** `LINES`, when it names a usable number of rows. */
230
+ function declaredRows(env: TerminalEnv): number | null {
231
+ return positive(env.LINES);
232
+ }
233
+
234
+ /** The columns the stream reported, when it reported a usable number. */
235
+ function reportedColumns(reported: TerminalReport): number | null {
236
+ return usable(reported.columns);
237
+ }
238
+
239
+ /** The rows the stream reported, when it reported a usable number. */
240
+ function reportedRows(reported: TerminalReport): number | null {
241
+ return usable(reported.rows);
242
+ }
243
+
244
+ /**
245
+ * A variable that names a positive whole number of cells, or nothing.
246
+ *
247
+ * `COLUMNS=0` and `COLUMNS=wide` are both a variable saying nothing useful,
248
+ * and both fall through to the next rule rather than produce a terminal zero
249
+ * columns across.
250
+ */
251
+ function positive(value: string | void): number | null {
252
+ const raw = nonEmpty(value);
253
+ if (raw == null || !/^\d+$/.test(raw.trim())) {
254
+ return null;
255
+ }
256
+ return usable(Number.parseInt(raw, 10));
257
+ }
258
+
259
+ const usable = (value: number | void): number | null =>
260
+ typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : null;
261
+
262
+ /**
263
+ * Resolve how big the terminal is.
264
+ *
265
+ * Precedence, highest first — this list is `crates/uf_term/src/capability.rs`'s
266
+ * `detect_size`, chain for chain, and `tests/library/tui.test.js` compares the
267
+ * two rather than believing this sentence:
268
+ *
269
+ * 1. `COLUMNS` and `LINES`, each on its own. POSIX makes them the override,
270
+ * and they are what a `watch`, a `script` or a CI wrapper sets when the
271
+ * stream itself cannot answer.
272
+ * 2. what the stream reported, which on Node is the terminal's own answer
273
+ * 3. 80 by 24
274
+ *
275
+ * The two dimensions are resolved separately, because `COLUMNS` without
276
+ * `LINES` is the common shape: a wrapper that cares about width sets one of
277
+ * them.
278
+ */
279
+ export function detectSize(env: TerminalEnv, reported: TerminalReport): TerminalSize {
280
+ const columns = declaredColumns(env) ?? reportedColumns(reported) ?? FALLBACK_COLUMNS;
281
+ const rows = declaredRows(env) ?? reportedRows(reported) ?? FALLBACK_ROWS;
282
+ return { columns, rows };
283
+ }
284
+
285
+ /**
286
+ * The border characters this terminal can print.
287
+ *
288
+ * OpenTUI's four border styles, plus the ASCII fallback that is the whole
289
+ * reason this is a lookup rather than a constant. The ASCII set is not a
290
+ * different design, it is the same design drawn with the characters a
291
+ * `TERM=dumb` terminal will not replace with a question mark: every glyph
292
+ * below is one column wide in both vocabularies, so a box's geometry does not
293
+ * change when its characters do.
294
+ */
295
+ export type BorderStyle = "single" | "double" | "rounded" | "heavy";
296
+
297
+ /** Eight characters: the four corners, then top, right, bottom, left. */
298
+ export type BorderGlyphs = {
299
+ readonly topLeft: string,
300
+ readonly topRight: string,
301
+ readonly bottomLeft: string,
302
+ readonly bottomRight: string,
303
+ readonly top: string,
304
+ readonly right: string,
305
+ readonly bottom: string,
306
+ readonly left: string,
307
+ };
308
+
309
+ const ASCII_BORDER: BorderGlyphs = {
310
+ topLeft: "+",
311
+ topRight: "+",
312
+ bottomLeft: "+",
313
+ bottomRight: "+",
314
+ top: "-",
315
+ right: "|",
316
+ bottom: "-",
317
+ left: "|",
318
+ };
319
+
320
+ const UNICODE_BORDERS: { [BorderStyle]: BorderGlyphs } = {
321
+ single: {
322
+ topLeft: "┌",
323
+ topRight: "┐",
324
+ bottomLeft: "└",
325
+ bottomRight: "┘",
326
+ top: "─",
327
+ right: "│",
328
+ bottom: "─",
329
+ left: "│",
330
+ },
331
+ double: {
332
+ topLeft: "╔",
333
+ topRight: "╗",
334
+ bottomLeft: "╚",
335
+ bottomRight: "╝",
336
+ top: "═",
337
+ right: "║",
338
+ bottom: "═",
339
+ left: "║",
340
+ },
341
+ rounded: {
342
+ topLeft: "╭",
343
+ topRight: "╮",
344
+ bottomLeft: "╰",
345
+ bottomRight: "╯",
346
+ top: "─",
347
+ right: "│",
348
+ bottom: "─",
349
+ left: "│",
350
+ },
351
+ heavy: {
352
+ topLeft: "┏",
353
+ topRight: "┓",
354
+ bottomLeft: "┗",
355
+ bottomRight: "┛",
356
+ top: "━",
357
+ right: "┃",
358
+ bottom: "━",
359
+ left: "┃",
360
+ },
361
+ };
362
+
363
+ /** The border glyphs for a style, downgraded to ASCII when they cannot be printed. */
364
+ export function borderGlyphs(style: BorderStyle, glyphs: GlyphSet): BorderGlyphs {
365
+ return glyphs === "ascii" ? ASCII_BORDER : UNICODE_BORDERS[style];
366
+ }
package/cells.js ADDED
@@ -0,0 +1,332 @@
1
+ // @flow
2
+ //
3
+ // The cell grid: what a frame *is*.
4
+ //
5
+ // A terminal is a rectangle of cells, and this module is the only description
6
+ // of one in the package. Everything above it produces a frame — layout decides
7
+ // where boxes go, `internal/paint.js` fills them in — and everything below it consumes
8
+ // one: `diff.js` compares two frames and writes the difference, the test
9
+ // renderer reads a frame back as text. Neither end knows how a cell is stored,
10
+ // which is what let the storage change once already without either end
11
+ // noticing.
12
+ //
13
+ // # Why parallel arrays rather than objects
14
+ //
15
+ // The obvious representation is `Array<{ char, fg, bg, attributes }>`, and it
16
+ // was the first one. It is also the representation that makes the diff — the
17
+ // operation this package exists to make cheap — allocate: comparing two frames
18
+ // of a 200×60 terminal means 12,000 property loads on 12,000 objects that the
19
+ // garbage collector has to keep alive between frames, and a frame is produced
20
+ // on every keystroke.
21
+ //
22
+ // So a frame is four arrays of the same length: three typed, one not. The
23
+ // three numeric ones make the common comparison — "is this cell unchanged?" —
24
+ // three unboxed integer loads, and the string array carries the one field that
25
+ // cannot be a number, because a cell holds a *grapheme cluster* and not a code
26
+ // point. A family emoji is a dozen scalars in one cell.
27
+ //
28
+ // # The empty string means something
29
+ //
30
+ // `chars[i] === ""` is not a blank cell. A blank cell is `" "`. The empty
31
+ // string is a **continuation cell**: the right-hand column of a two-column
32
+ // grapheme whose cluster is stored in the cell to its left. The distinction
33
+ // exists because a terminal advances its cursor two columns for `界` and
34
+ // writing anything into the second of them corrupts the first, so both the
35
+ // painter and the diff have to know that a cell is not independently
36
+ // writable. Losing this distinction is the bug that makes a table of Japanese
37
+ // paths look fine until one row contains an emoji.
38
+
39
+ import { graphemeWidth } from "./widths.js";
40
+
41
+ /**
42
+ * A colour, as a packed `0xRRGGBB`, or `INHERIT`.
43
+ *
44
+ * Colours are numbers rather than strings or objects because they sit in a
45
+ * typed array beside every cell, and because the comparison the diff performs
46
+ * a million times a second is `fg[i] === fg[i]`.
47
+ */
48
+ export type Color = number;
49
+
50
+ /**
51
+ * "Whatever the terminal's default is."
52
+ *
53
+ * Distinct from black: a reader with a light terminal theme and a renderer
54
+ * that resolved `INHERIT` to `0x000000` gets black text that stops being
55
+ * readable the moment the reader switches themes, and the terminal's own
56
+ * default is the only value that follows them.
57
+ */
58
+ export const INHERIT: Color = -1;
59
+
60
+ /**
61
+ * Text attributes, as a bit mask.
62
+ *
63
+ * The bits are OpenTUI's `TextAttributes`, in its order, so a value that
64
+ * crosses between the two libraries means the same thing.
65
+ */
66
+ export const Attributes = {
67
+ NONE: 0,
68
+ BOLD: 1,
69
+ DIM: 2,
70
+ ITALIC: 4,
71
+ UNDERLINE: 8,
72
+ BLINK: 16,
73
+ INVERSE: 32,
74
+ STRIKETHROUGH: 64,
75
+ };
76
+
77
+ /** How one cell is painted. */
78
+ export type Style = {
79
+ /** Foreground colour, or `INHERIT`. */
80
+ readonly fg: Color,
81
+ /** Background colour, or `INHERIT`. */
82
+ readonly bg: Color,
83
+ /** A mask of `Attributes`. */
84
+ readonly attributes: number,
85
+ };
86
+
87
+ /** The style of a cell nobody has painted. */
88
+ export const PLAIN: Style = { fg: INHERIT, bg: INHERIT, attributes: Attributes.NONE };
89
+
90
+ /**
91
+ * The sixteen colours every terminal has, by the names people write.
92
+ *
93
+ * The values are the widely-used xterm defaults rather than a standard,
94
+ * because there is no standard: a terminal is free to render `red` as whatever
95
+ * its theme says, and it will. These exist so that a caller who writes
96
+ * `fg="red"` gets a colour on a truecolour terminal that a reader recognises,
97
+ * and so that the downgrade back to `SGR 31` on a sixteen-colour terminal
98
+ * lands on the same name it started as.
99
+ */
100
+ const NAMED: { [string]: Color } = {
101
+ black: 0x000000,
102
+ red: 0xcd0000,
103
+ green: 0x00cd00,
104
+ yellow: 0xcdcd00,
105
+ blue: 0x0000ee,
106
+ magenta: 0xcd00cd,
107
+ cyan: 0x00cdcd,
108
+ white: 0xe5e5e5,
109
+ gray: 0x7f7f7f,
110
+ grey: 0x7f7f7f,
111
+ brightblack: 0x7f7f7f,
112
+ brightred: 0xff0000,
113
+ brightgreen: 0x00ff00,
114
+ brightyellow: 0xffff00,
115
+ brightblue: 0x5c5cff,
116
+ brightmagenta: 0xff00ff,
117
+ brightcyan: 0x00ffff,
118
+ brightwhite: 0xffffff,
119
+ };
120
+
121
+ /**
122
+ * Read a colour written as a name, a hex string, or a packed number.
123
+ *
124
+ * Anything unrecognised resolves to `INHERIT` rather than raising. A colour is
125
+ * decoration: a typo in one should leave the interface readable in the
126
+ * terminal's own colours, not stop the program that was drawing it.
127
+ */
128
+ export function parseColor(value: string | number | void | null): Color {
129
+ if (value == null) {
130
+ return INHERIT;
131
+ }
132
+ if (typeof value === "number") {
133
+ return Number.isFinite(value) ? value & 0xffffff : INHERIT;
134
+ }
135
+ const text = value.trim().toLowerCase();
136
+ if (text.startsWith("#")) {
137
+ const digits = text.slice(1);
138
+ if (digits.length === 3) {
139
+ const r = Number.parseInt(digits[0] + digits[0], 16);
140
+ const g = Number.parseInt(digits[1] + digits[1], 16);
141
+ const b = Number.parseInt(digits[2] + digits[2], 16);
142
+ return Number.isNaN(r + g + b) ? INHERIT : (r << 16) | (g << 8) | b;
143
+ }
144
+ if (digits.length === 6) {
145
+ const packed = Number.parseInt(digits, 16);
146
+ return Number.isNaN(packed) ? INHERIT : packed;
147
+ }
148
+ return INHERIT;
149
+ }
150
+ return NAMED[text] ?? INHERIT;
151
+ }
152
+
153
+ /**
154
+ * One rendered frame.
155
+ *
156
+ * Mutable on purpose. A frame is filled in by one painter pass and then read
157
+ * by one diff, and copying it to keep it immutable would double the allocation
158
+ * this representation exists to avoid. The rule that keeps that safe is that a
159
+ * frame belongs to exactly one owner at a time: the painter owns it until
160
+ * `paint()` returns, the renderer owns it afterwards and never writes again.
161
+ */
162
+ export type Frame = {
163
+ /** Columns. */
164
+ readonly width: number,
165
+ /** Rows. */
166
+ readonly height: number,
167
+ /** One grapheme cluster per cell, or `""` for a wide cluster's second cell. */
168
+ readonly chars: Array<string>,
169
+ /** Foreground per cell. */
170
+ readonly fg: Int32Array,
171
+ /** Background per cell. */
172
+ readonly bg: Int32Array,
173
+ /** Attribute mask per cell. */
174
+ readonly attributes: Uint8Array,
175
+ };
176
+
177
+ /** A rectangle in frame coordinates; `x`/`y` are the top-left cell. */
178
+ export type Rect = {
179
+ readonly x: number,
180
+ readonly y: number,
181
+ readonly width: number,
182
+ readonly height: number,
183
+ };
184
+
185
+ /** A frame of blanks, `width` by `height`. */
186
+ export function createFrame(width: number, height: number): Frame {
187
+ const size = Math.max(0, width * height);
188
+ const chars = new Array(size);
189
+ for (let i = 0; i < size; i += 1) {
190
+ chars[i] = " ";
191
+ }
192
+ return {
193
+ width,
194
+ height,
195
+ chars,
196
+ fg: new Int32Array(size).fill(INHERIT),
197
+ bg: new Int32Array(size).fill(INHERIT),
198
+ attributes: new Uint8Array(size),
199
+ };
200
+ }
201
+
202
+ /** Whether two frames describe the same rectangle. */
203
+ export function sameSize(a: Frame, b: Frame): boolean {
204
+ return a.width === b.width && a.height === b.height;
205
+ }
206
+
207
+ /**
208
+ * Blank the cell at `index`, and any cell its content spans.
209
+ *
210
+ * A wide grapheme owns two cells and only one of them holds the cluster, so
211
+ * clearing "the cell at x" is not a single-index operation. Overwriting the
212
+ * *second* half of `界` and leaving the first half in place leaves a terminal
213
+ * printing half a character it has no way to draw; overwriting the first half
214
+ * and leaving the continuation marker leaves the diff convinced the second
215
+ * column is not writable. Both were bugs before this existed, and both were
216
+ * only visible with a non-ASCII string in the frame.
217
+ */
218
+ function clearSpan(frame: Frame, index: number): void {
219
+ const row = Math.floor(index / frame.width);
220
+ let start = index;
221
+ if (frame.chars[index] === "") {
222
+ // A continuation cell: its cluster is to the left, on the same row.
223
+ start = index - 1;
224
+ if (start < row * frame.width) {
225
+ start = index;
226
+ }
227
+ }
228
+ const width = frame.chars[start] === "" ? 1 : graphemeWidth(frame.chars[start]);
229
+ for (let i = start; i < start + Math.max(1, width) && i < (row + 1) * frame.width; i += 1) {
230
+ frame.chars[i] = " ";
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Write one grapheme cluster at `x`, `y`, in `style`.
236
+ *
237
+ * Returns the number of columns consumed, which is what a caller advances by:
238
+ * zero when the write fell outside the frame or outside `clip`, one or two
239
+ * otherwise. A two-column cluster that would straddle the right edge is
240
+ * written as a space instead of being split, because half of a wide character
241
+ * is not a character.
242
+ */
243
+ export function writeGrapheme(
244
+ frame: Frame,
245
+ x: number,
246
+ y: number,
247
+ cluster: string,
248
+ width: number,
249
+ style: Style,
250
+ clip: Rect,
251
+ ): number {
252
+ if (y < clip.y || y >= clip.y + clip.height || y < 0 || y >= frame.height) {
253
+ return width;
254
+ }
255
+ if (x < clip.x || x + width > clip.x + clip.width || x < 0 || x + width > frame.width) {
256
+ return width;
257
+ }
258
+ const index = y * frame.width + x;
259
+ clearSpan(frame, index);
260
+ if (width === 2) {
261
+ clearSpan(frame, index + 1);
262
+ }
263
+ frame.chars[index] = cluster;
264
+ frame.fg[index] = style.fg;
265
+ frame.bg[index] = style.bg;
266
+ frame.attributes[index] = style.attributes;
267
+ if (width === 2) {
268
+ frame.chars[index + 1] = "";
269
+ frame.fg[index + 1] = style.fg;
270
+ frame.bg[index + 1] = style.bg;
271
+ frame.attributes[index + 1] = style.attributes;
272
+ }
273
+ return width;
274
+ }
275
+
276
+ /**
277
+ * Fill a rectangle with `style`, leaving the characters in it blank.
278
+ *
279
+ * Used for a box's background. It clears rather than preserving what is under
280
+ * it: a background is opaque, and a box drawn over another box's text has to
281
+ * hide that text or the terminal shows both.
282
+ */
283
+ export function fillRect(frame: Frame, area: Rect, style: Style, clip: Rect): void {
284
+ const left = Math.max(area.x, clip.x, 0);
285
+ const top = Math.max(area.y, clip.y, 0);
286
+ const right = Math.min(area.x + area.width, clip.x + clip.width, frame.width);
287
+ const bottom = Math.min(area.y + area.height, clip.y + clip.height, frame.height);
288
+ for (let y = top; y < bottom; y += 1) {
289
+ for (let x = left; x < right; x += 1) {
290
+ const index = y * frame.width + x;
291
+ clearSpan(frame, index);
292
+ frame.chars[index] = " ";
293
+ frame.fg[index] = style.fg;
294
+ frame.bg[index] = style.bg;
295
+ frame.attributes[index] = style.attributes;
296
+ }
297
+ }
298
+ }
299
+
300
+ /** The intersection of two rectangles, empty when they do not overlap. */
301
+ export function intersect(a: Rect, b: Rect): Rect {
302
+ const x = Math.max(a.x, b.x);
303
+ const y = Math.max(a.y, b.y);
304
+ const right = Math.min(a.x + a.width, b.x + b.width);
305
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
306
+ return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) };
307
+ }
308
+
309
+ /**
310
+ * One row of a frame, as the text a reader would see.
311
+ *
312
+ * Continuation cells contribute nothing, because their cluster was already
313
+ * emitted by the cell to their left. Trailing blanks are kept: a test that
314
+ * asserts on a row is asserting on a rectangle, and trimming would make
315
+ * "painted a space here" and "painted nothing here" indistinguishable.
316
+ */
317
+ export function frameRow(frame: Frame, y: number): string {
318
+ let out = "";
319
+ for (let x = 0; x < frame.width; x += 1) {
320
+ out += frame.chars[y * frame.width + x];
321
+ }
322
+ return out;
323
+ }
324
+
325
+ /** Every row of a frame, newline separated. Snapshots and `toContain` read this. */
326
+ export function frameText(frame: Frame): string {
327
+ const rows = [];
328
+ for (let y = 0; y < frame.height; y += 1) {
329
+ rows.push(frameRow(frame, y));
330
+ }
331
+ return rows.join("\n");
332
+ }