@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.
@@ -0,0 +1,373 @@
1
+ // @flow
2
+ //
3
+ // The node tree: the thing React mutates and the renderer reads.
4
+ //
5
+ // # Internal to `@uniflowed/tui`
6
+ //
7
+ // Absent from `package.json#exports`, and the reason is the one below: props
8
+ // are turned into a layout style *here*, once, and a consumer who could reach
9
+ // `styleFromProps` could apply a different rule to a node the renderer will
10
+ // then lay out by this one. A tree whose nodes disagree about what `padding`
11
+ // means is not a tree anything can draw.
12
+ //
13
+ //
14
+ // There are exactly three participants and this is the boundary between them.
15
+ // React's reconciler creates, moves and updates nodes and knows nothing else
16
+ // about them. `layout.js` reads `style`, `children` and `borderWidth` and
17
+ // writes geometry. `paint.js`, beside this file, reads geometry and `props`
18
+ // and writes cells.
19
+ // None of the three imports another, which is what makes each of them
20
+ // testable on a tree built by hand.
21
+ //
22
+ // # Props are read once, not on every frame
23
+ //
24
+ // A node's layout style is derived when its props are set, not when layout
25
+ // runs. Layout runs on every frame and props change on a commit, so deriving
26
+ // once per commit rather than once per frame is the obvious trade — but the
27
+ // reason it is written down is the other half: it means `style` is a plain
28
+ // object with resolved numbers in it, and a layout test can build one without
29
+ // going anywhere near React.
30
+ //
31
+ // # Both spellings of a prop
32
+ //
33
+ // OpenTUI accepts `<box padding={2}>` and `<box style={{ padding: 2 }}>` and
34
+ // treats them as the same thing, so this does too. The direct prop wins when
35
+ // both are present, which is the rule a reader guesses.
36
+
37
+ import type { LayoutStyle, ScrollIndex } from "../layout.js";
38
+ import type { Color, Style } from "../cells.js";
39
+ import { Attributes, INHERIT, PLAIN, parseColor } from "../cells.js";
40
+ import type { BorderStyle } from "../capability.js";
41
+
42
+ /**
43
+ * What kind of node this is.
44
+ *
45
+ * `"chars"` is a run of literal text — what React calls a text instance, what
46
+ * a caller wrote as `{name}` inside a `<Text>`. It is a node rather than a
47
+ * string on the parent because React inserts, moves and deletes them
48
+ * individually, and a parent holding a concatenated string cannot express
49
+ * "the second of my three children changed".
50
+ */
51
+ export type TuiNodeType = "root" | "box" | "text" | "chars";
52
+
53
+ /** Anything a component put on a node. Read by `paint.js`, not by layout. */
54
+ export type TuiProps = { readonly [string]: mixed };
55
+
56
+ /** One node of the tree. Mutable: React owns its shape, layout owns its geometry. */
57
+ export type TuiNode = {
58
+ type: TuiNodeType,
59
+ props: TuiProps,
60
+ children: Array<TuiNode>,
61
+ parent: TuiNode | null,
62
+ /** The literal text of a `"chars"` node; `""` for every other kind. */
63
+ text: string,
64
+ /** Derived from `props` whenever they are set. */
65
+ style: LayoutStyle,
66
+ /** 1 when the node draws a border, 0 otherwise. Layout needs it; paint draws it. */
67
+ borderWidth: number,
68
+ /** How a leaf reports the size it wants. Only text nodes have one. */
69
+ measure:
70
+ | ((
71
+ availableWidth: number,
72
+ availableHeight: number,
73
+ ) => { readonly width: number, readonly height: number })
74
+ | null,
75
+ x: number,
76
+ y: number,
77
+ width: number,
78
+ height: number,
79
+ /** Which of its children a scrolling box laid out, as a range. */
80
+ scrollFirst: number,
81
+ scrollCount: number,
82
+ /** Rows of content this node holds, when it scrolls. */
83
+ scrollHeight: number,
84
+ /** The first row it shows, after clamping, when it scrolls. */
85
+ scrollOffset: number,
86
+ /** Where its window is: first row, how many rows, and the bar's column. */
87
+ scrollViewTop: number,
88
+ scrollViewRows: number,
89
+ scrollBarColumn: number,
90
+ /** The intrinsic size this node last reported, and what it was offered. */
91
+ measuredForWidth: number,
92
+ measuredForHeight: number,
93
+ measuredWidth: number,
94
+ measuredHeight: number,
95
+ /** The first child of a scrolling box that changed, or `-1`. See {@link invalidate}. */
96
+ scrollDirtyFrom: number,
97
+ /** A scrolling box's stack of child heights; see `layout.js`. */
98
+ scrollIndex: ScrollIndex | null,
99
+ };
100
+
101
+ /** Read a prop, preferring the direct spelling over the one inside `style`. */
102
+ function prop(props: TuiProps, name: string): mixed {
103
+ const direct = props[name];
104
+ if (direct !== undefined) {
105
+ return direct;
106
+ }
107
+ const style = props.style;
108
+ if (style != null && typeof style === "object") {
109
+ return style[name];
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ const asNumber = (value: mixed): number | void =>
115
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
116
+
117
+ const asDimension = (value: mixed): number | string | void => {
118
+ if (typeof value === "number" && Number.isFinite(value)) {
119
+ return value;
120
+ }
121
+ return typeof value === "string" ? value : undefined;
122
+ };
123
+
124
+ const asString = (value: mixed): string | void => (typeof value === "string" ? value : undefined);
125
+
126
+ /**
127
+ * Whether a node draws a border, and in which style.
128
+ *
129
+ * `border` is a boolean and `borderStyle` names one of four, exactly as
130
+ * OpenTUI documents them: `border` alone means `"single"`, and naming a style
131
+ * implies the border. The two spellings exist because `border` is what a
132
+ * caller reaches for first and `borderStyle` is what they reach for second,
133
+ * and making the second one imply the first saves the bug where a box has a
134
+ * `borderStyle` and no border.
135
+ */
136
+ export function borderOf(props: TuiProps): BorderStyle | null {
137
+ const style = asString(prop(props, "borderStyle"));
138
+ if (style === "single" || style === "double" || style === "rounded" || style === "heavy") {
139
+ return style;
140
+ }
141
+ return prop(props, "border") === true ? "single" : null;
142
+ }
143
+
144
+ /**
145
+ * The layout style a node's props describe.
146
+ *
147
+ * Only the keys that were actually given are set, so `layout.js`'s defaults —
148
+ * which are OpenTUI's — apply to everything else. Writing `flexDirection:
149
+ * props.flexDirection ?? "column"` here instead would move the defaults into
150
+ * two places and make them disagree the first time one of them changed.
151
+ */
152
+ export function styleFromProps(props: TuiProps): LayoutStyle {
153
+ const style: { [string]: mixed } = {};
154
+ const copy = (name: string, read: (mixed) => mixed) => {
155
+ const value = read(prop(props, name));
156
+ if (value !== undefined) {
157
+ style[name] = value;
158
+ }
159
+ };
160
+ for (const name of [
161
+ "width",
162
+ "height",
163
+ "minWidth",
164
+ "minHeight",
165
+ "maxWidth",
166
+ "maxHeight",
167
+ "flexBasis",
168
+ ]) {
169
+ copy(name, asDimension);
170
+ }
171
+ for (const name of [
172
+ "flexGrow",
173
+ "flexShrink",
174
+ "padding",
175
+ "paddingTop",
176
+ "paddingRight",
177
+ "paddingBottom",
178
+ "paddingLeft",
179
+ "margin",
180
+ "marginTop",
181
+ "marginRight",
182
+ "marginBottom",
183
+ "marginLeft",
184
+ "gap",
185
+ "rowGap",
186
+ "columnGap",
187
+ "scrollTop",
188
+ ]) {
189
+ copy(name, asNumber);
190
+ }
191
+ for (const name of ["flexDirection", "justifyContent", "alignItems", "alignSelf", "overflow"]) {
192
+ copy(name, asString);
193
+ }
194
+
195
+ // `paddingX` and `paddingY`, which OpenTUI documents and which are the two
196
+ // shorthands a terminal layout actually reaches for: a box is padded a
197
+ // column on each side far more often than it is padded on all four.
198
+ const paddingX = asNumber(prop(props, "paddingX"));
199
+ if (paddingX !== undefined) {
200
+ style.paddingLeft = style.paddingLeft ?? paddingX;
201
+ style.paddingRight = style.paddingRight ?? paddingX;
202
+ }
203
+ const paddingY = asNumber(prop(props, "paddingY"));
204
+ if (paddingY !== undefined) {
205
+ style.paddingTop = style.paddingTop ?? paddingY;
206
+ style.paddingBottom = style.paddingBottom ?? paddingY;
207
+ }
208
+
209
+ return style;
210
+ }
211
+
212
+ /** The text style a node's props describe, layered onto what it inherited. */
213
+ export function textStyleFromProps(props: TuiProps, inherited: Style): Style {
214
+ const fg = colorProp(props, ["fg", "color", "foregroundColor"], inherited.fg);
215
+ const bg = colorProp(props, ["bg", "backgroundColor"], inherited.bg);
216
+ let attributes = inherited.attributes;
217
+ const set = (name: string, bit: number) => {
218
+ const value = prop(props, name);
219
+ if (value === true) {
220
+ attributes |= bit;
221
+ } else if (value === false) {
222
+ attributes &= ~bit;
223
+ }
224
+ };
225
+ set("bold", Attributes.BOLD);
226
+ set("dim", Attributes.DIM);
227
+ set("italic", Attributes.ITALIC);
228
+ set("underline", Attributes.UNDERLINE);
229
+ set("blink", Attributes.BLINK);
230
+ set("inverse", Attributes.INVERSE);
231
+ set("strikethrough", Attributes.STRIKETHROUGH);
232
+ const explicit = asNumber(prop(props, "attributes"));
233
+ if (explicit !== undefined) {
234
+ attributes |= explicit;
235
+ }
236
+ return { fg, bg, attributes };
237
+ }
238
+
239
+ function colorProp(props: TuiProps, names: $ReadOnlyArray<string>, fallback: Color): Color {
240
+ for (const name of names) {
241
+ const value = prop(props, name);
242
+ if (typeof value === "string" || typeof value === "number") {
243
+ const parsed = parseColor(value);
244
+ if (parsed !== INHERIT) {
245
+ return parsed;
246
+ }
247
+ }
248
+ }
249
+ return fallback;
250
+ }
251
+
252
+ /** One stretch of text that shares a style. */
253
+ export type TextRun = {
254
+ readonly text: string,
255
+ readonly style: Style,
256
+ };
257
+
258
+ /**
259
+ * Flatten a text subtree into runs.
260
+ *
261
+ * Nesting is how a caller writes `<Text>ready in <Text bold>{ms}ms</Text></Text>`,
262
+ * and the inner node inherits the outer node's colour while overriding its
263
+ * weight — the same rule CSS has, because it is the rule a reader expects and
264
+ * because the alternative is repeating the colour on every fragment.
265
+ *
266
+ * A `<Box>` inside a `<Text>` is dropped rather than laid out. A box has a
267
+ * geometry and a run of text has a position in a line; there is no answer to
268
+ * what the two mean together that is better than refusing.
269
+ */
270
+ export function textRuns(node: TuiNode, inherited: Style): Array<TextRun> {
271
+ const out: Array<TextRun> = [];
272
+ const walk = (current: TuiNode, style: Style) => {
273
+ for (const child of current.children) {
274
+ if (child.type === "chars") {
275
+ if (child.text !== "") {
276
+ out.push({ text: child.text, style });
277
+ }
278
+ } else if (child.type === "text") {
279
+ walk(child, textStyleFromProps(child.props, style));
280
+ }
281
+ }
282
+ };
283
+ walk(node, inherited);
284
+ return out;
285
+ }
286
+
287
+ /** A fresh node of the given kind. */
288
+ export function createNode(type: TuiNodeType, props: TuiProps): TuiNode {
289
+ const node: TuiNode = {
290
+ type,
291
+ props,
292
+ children: [],
293
+ parent: null,
294
+ text: "",
295
+ style: {},
296
+ borderWidth: 0,
297
+ measure: null,
298
+ x: 0,
299
+ y: 0,
300
+ width: 0,
301
+ height: 0,
302
+ scrollFirst: 0,
303
+ scrollCount: 0,
304
+ scrollHeight: 0,
305
+ scrollOffset: 0,
306
+ scrollViewTop: 0,
307
+ scrollViewRows: 0,
308
+ scrollBarColumn: 0,
309
+ measuredForWidth: -1,
310
+ measuredForHeight: -1,
311
+ measuredWidth: 0,
312
+ measuredHeight: 0,
313
+ scrollDirtyFrom: 0,
314
+ scrollIndex: null,
315
+ };
316
+ applyProps(node, props);
317
+ return node;
318
+ }
319
+
320
+ /** Re-derive everything layout reads from a node's props. */
321
+ export function applyProps(node: TuiNode, props: TuiProps): void {
322
+ node.props = props;
323
+ node.style = styleFromProps(props);
324
+ node.borderWidth = node.type === "box" && borderOf(props) != null ? 1 : 0;
325
+ invalidate(node);
326
+ }
327
+
328
+ /**
329
+ * Say that something under `node` changed, so layout may not reuse what it
330
+ * measured last time.
331
+ *
332
+ * Layout keeps two answers between frames: what a node's intrinsic size came
333
+ * out as, and — for a scrolling box — where each of its children sits in the
334
+ * stack. Both are only wrong when the tree changed, and React is the only
335
+ * participant that knows when it did; a layout that worked it out for itself
336
+ * would have to compare this frame's tree with the last one's, which is the
337
+ * walk over every child that the stack exists to avoid.
338
+ *
339
+ * The walk goes to the root because an intrinsic size is a fact about a
340
+ * subtree: a character added to a `"chars"` node can widen the `<Text>` above
341
+ * it, which can lengthen the box above that. It is bounded by the depth of the
342
+ * tree, and a terminal's tree is as deep as what fits on a screen.
343
+ *
344
+ * `from` is the first child index of `node` that changed. Appending a line to
345
+ * a log leaves every line above it where it was, which is what makes appending
346
+ * cost one measurement rather than the log; above `node` nothing is known that
347
+ * precisely, so every scrolling ancestor is invalidated whole.
348
+ *
349
+ * `null` — the default, and what a change to the node's *own* props or text
350
+ * means — leaves the node's own stack alone, because a scrolling box's props
351
+ * cannot move its children except through the width, the viewport height and
352
+ * the gap it gives them, and all three are part of what the stack is keyed on.
353
+ * That exemption is not a nicety: `scrollTop` is a prop on that box, so
354
+ * without it every scroll would invalidate the very thing that makes scrolling
355
+ * cheap, and a hundred thousand rows would rebuild their stack on each notch.
356
+ */
357
+ export function invalidate(node: TuiNode, from: number | null = null): void {
358
+ let current: TuiNode | null = node;
359
+ let first = from;
360
+ while (current != null) {
361
+ current.measuredForWidth = -1;
362
+ current.measuredForHeight = -1;
363
+ if (first != null) {
364
+ current.scrollDirtyFrom =
365
+ current.scrollDirtyFrom < 0 ? first : Math.min(current.scrollDirtyFrom, first);
366
+ }
367
+ first = 0;
368
+ current = current.parent;
369
+ }
370
+ }
371
+
372
+ /** The style a text node's runs start from when nothing above it said otherwise. */
373
+ export const ROOT_TEXT_STYLE: Style = PLAIN;