@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 +366 -0
- package/cells.js +332 -0
- package/components.js +602 -0
- package/diff.js +286 -0
- package/index.js +206 -0
- package/internal/hits.js +156 -0
- package/internal/host.js +928 -0
- package/internal/paint.js +664 -0
- package/internal/tree.js +373 -0
- package/keys.js +585 -0
- package/layout.js +833 -0
- package/mouse.js +333 -0
- package/package.json +36 -0
- package/selection.js +101 -0
- package/terminal.js +457 -0
- package/widths.js +201 -0
package/internal/tree.js
ADDED
|
@@ -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;
|