@uniflowed/tui 0.0.0-alpha.18 → 0.15.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.
package/index.js CHANGED
@@ -23,7 +23,7 @@
23
23
  // **Rust was rejected for three reasons, in order of weight.**
24
24
  //
25
25
  // *First, there is no bridge, and building one is a bigger and different
26
- // project than this.* Every `nativeRuntimeRequired` in `packages/` is a
26
+ // project than this.* Every `nativeRuntimeRequired` in `npm/` is a
27
27
  // promise of a JavaScript-to-native boundary that this repository does not
28
28
  // have: no Node-API addon, no FFI, no prebuilt platform binaries, no loader.
29
29
  // Choosing Rust here would have meant that the first deliverable was that
@@ -67,22 +67,25 @@
67
67
  // # What is here, and what is not
68
68
  //
69
69
  // Implemented, tested, and true: a component tree, flexbox layout in whole
70
- // cells, a cell buffer with correct wide-grapheme handling, a diff that emits
71
- // only changed cells, keyboard input with OpenTUI's key names and propagation
72
- // rules, bracketed paste, declarative focus, mouse input — press, release,
73
- // hover, drag with capture, drop and wheel, routed by a hit grid the painter
74
- // records — text selection by drag, one per renderer, read back with
75
- // `getSelectedText()`, a scrolling window onto content taller than it,
76
- // terminal capability and *size* detection that agrees with the CLI's, and an
70
+ // cells — wrapping, `auto` margins, absolute positioning against the nearest
71
+ // positioned ancestor and `zIndex` included — a cell buffer with correct wide-grapheme handling, a diff that emits
72
+ // only changed cells, keyboard input with OpenTUI's key names, propagation
73
+ // rules and Kitty press/repeat/release event types, bracketed paste,
74
+ // declarative focus, mouse input — press, release, hover, drag with capture,
75
+ // drop and wheel, routed by a hit grid the painter records — text selection by
76
+ // drag, one per renderer, read back with
77
+ // `getSelectedText()`, a scrolling window onto content taller than it, a
78
+ // list, a row of tabs and a multi-line editor with OpenTUI's keys, terminal
79
+ // capability and *size* detection that agrees with the CLI's, and an
77
80
  // in-memory renderer that runs the same code the terminal one does.
78
81
  //
79
- // Not here: key *release* (which needs the Kitty keyboard protocol), the
80
- // repeated-press gestures that widen a selection to a word or a line, images,
81
- // the rich content components, and everything under OpenTUI's "application
82
- // APIs" — including the clipboard, which is why `getSelectedText()` hands a
83
- // string back rather than putting it somewhere. They are
84
- // ubugeeei-prod/uf#314, and they are absent rather than present as functions
85
- // that throw — because a stub is what this package used to be.
82
+ // Not here: the repeated-press gestures that widen a selection to a word or a
83
+ // line, images, the rich content components, `Slider` and `ScrollBar`, and
84
+ // everything under OpenTUI's
85
+ // "application APIs" — except for the write-only OSC 52 clipboard — which is
86
+ // why `getSelectedText()` hands a string back and leaves policy to the caller.
87
+ // They are ubugeeei-prod/uf#314, and they are absent rather than present as
88
+ // functions that throw — because a stub is what this package used to be.
86
89
  //
87
90
  // Also not here, and worth saying because ubugeeei-prod/uf#247 asked for it:
88
91
  // uf's own CLI does not draw through this. It cannot — `crates/uf_term` is
@@ -100,7 +103,7 @@
100
103
  // and there are three guards rather than a promise. `capability.js` reproduces
101
104
  // `crates/uf_term/src/capability.rs`'s colour precedence and its size
102
105
  // precedence, `widths.js` holds the same Unicode tables as
103
- // `crates/uf_term/src/text/tables.rs`, and `tests/library/tui.test.js` reads
106
+ // `crates/uf_term/src/text/tables.rs`, and `npm/tui/tui.test.js` reads
104
107
  // both Rust files and fails when either side is edited alone.
105
108
  //
106
109
  // # How the package is laid out
@@ -117,13 +120,15 @@
117
120
  // - `capability.js` — what this terminal can render and how big it is, by the
118
121
  // CLI's own rules.
119
122
  // - `terminal.js` — a real terminal, and the in-memory one tests use.
120
- // - `components.js` — `Box`, `Text`, `Input`, `ScrollBox`, and the hooks.
123
+ // - `components.js` — `Box`, `Text`, `Input`, `ScrollBox`, `Select`,
124
+ // `TabSelect`, `Textarea`, and the hooks.
121
125
  //
122
- // `internal/` holds the four that a consumer must not be able to reach past:
126
+ // `internal/` holds the five that a consumer must not be able to reach past:
123
127
  // `tree.js` (props become a layout style once, here), `paint.js` (both passes
124
- // must break lines the same way), `hits.js` (what is under the pointer is only
125
- // true for the frame that recorded it) and `host.js` (one React root, one
126
- // terminal, one owner). Each says so in its own header. There is no
128
+ // must break lines the same way), `widgets.js` (the same, for a textarea's
129
+ // cursor and its painter), `hits.js` (what is under the pointer is only true
130
+ // for the frame that recorded it) and `host.js` (one React root, one terminal,
131
+ // one owner). Each says so in its own header. There is no
127
132
  // `internal/util.js`: a module that cannot say what it is about does not
128
133
  // belong in this package.
129
134
 
@@ -151,21 +156,26 @@ export {
151
156
  export type { Color, Frame, Rect, Style } from "./cells.js";
152
157
  export { Attributes, INHERIT, frameRow, frameText, parseColor } from "./cells.js";
153
158
 
159
+ export type { Clipboard } from "./clipboard.js";
160
+
154
161
  export type {
162
+ AlignContent,
155
163
  AlignItems,
156
164
  AlignSelf,
157
165
  Dimension,
158
166
  FlexDirection,
167
+ FlexWrap,
159
168
  JustifyContent,
160
169
  LayoutStyle,
161
170
  Overflow,
171
+ Position,
162
172
  } from "./layout.js";
163
173
 
164
174
  export type { WrapMode } from "./internal/paint.js";
165
175
 
166
176
  export type { Update } from "./diff.js";
167
177
 
168
- export type { InputDecoder, InputEvent, KeyEvent, KeySource } from "./keys.js";
178
+ export type { InputDecoder, InputEvent, KeyEvent, KeyEventType, KeySource } from "./keys.js";
169
179
  export { createInputDecoder, decodeInput, decodeKeys } from "./keys.js";
170
180
 
171
181
  export type { MouseEvent, MouseEventType, Scroll, ScrollDirection } from "./mouse.js";
@@ -191,15 +201,24 @@ export type {
191
201
  InputProps,
192
202
  MouseProps,
193
203
  ScrollBoxProps,
204
+ SelectColorProps,
205
+ SelectProps,
206
+ TabSelectProps,
194
207
  TextProps,
195
208
  TextStyleProps,
209
+ TextareaProps,
196
210
  TitleAlignment,
197
211
  } from "./components.js";
212
+ export type { EditWrapMode, SelectOption } from "./internal/widgets.js";
198
213
  export {
199
214
  Box,
200
215
  Input,
201
216
  ScrollBox,
217
+ Select,
218
+ TabSelect,
202
219
  Text,
220
+ Textarea,
221
+ useClipboard,
203
222
  useKeyboard,
204
223
  useRenderer,
205
224
  useTerminalSize,
package/internal/hits.js CHANGED
@@ -74,8 +74,8 @@ export function createHitGrid(width: number, height: number): HitGrid {
74
74
  return {
75
75
  width,
76
76
  height,
77
- nodes: new Array(size).fill(null),
78
- text: new Array(size).fill(null),
77
+ nodes: new Array<TuiNode | null>(size).fill(null),
78
+ text: new Array<TuiNode | null>(size).fill(null),
79
79
  };
80
80
  }
81
81
 
package/internal/host.js CHANGED
@@ -62,22 +62,25 @@ import { DefaultEventPriority, DiscreteEventPriority } from "react-reconciler/co
62
62
  import type { Capabilities } from "../capability.js";
63
63
  import type { Frame, Rect } from "../cells.js";
64
64
  import { createFrame } from "../cells.js";
65
+ import type { Clipboard } from "../clipboard.js";
66
+ import { unsupportedClipboard } from "../clipboard.js";
65
67
  import type { Update } from "../diff.js";
66
68
  import { diffFrames } from "../diff.js";
67
69
  import type { KeyEvent } from "../keys.js";
68
70
  import { layout } from "../layout.js";
69
- import type { MouseEvent } from "../mouse.js";
71
+ import type { MouseEvent, MouseEventType } from "../mouse.js";
70
72
  import { MouseButton, derive } from "../mouse.js";
71
73
  import type { Selection, SelectionPoint } from "../selection.js";
72
74
  import { selectionBetween } from "../selection.js";
73
75
  import type { HitGrid } from "./hits.js";
74
76
  import { createHitGrid, hitAt, textAt } from "./hits.js";
75
77
  import { measureText, paint, paintSelection, selectionText, wrapModeOf } from "./paint.js";
76
- import type { TuiNode, TuiProps } from "./tree.js";
78
+ import type { TuiNode, TuiProps, Widget } from "./tree.js";
77
79
  import { applyProps, createNode, invalidate } from "./tree.js";
80
+ import { measureWidget } from "./widgets.js";
78
81
 
79
82
  /** A global key handler, as `useKeyboard` registers one. */
80
- export type KeyHandler = (key: KeyEvent) => void;
83
+ export type KeyHandler = (key: KeyEvent) => mixed;
81
84
 
82
85
  /**
83
86
  * Everything one mounted application owns.
@@ -152,6 +155,15 @@ export type Renderer = {
152
155
  hasSelection(): boolean,
153
156
  clearSelection(): void,
154
157
  getSelectedText(): string,
158
+ /**
159
+ * The terminal clipboard transport.
160
+ *
161
+ * It is installed by `terminal.js`, not by the reconciler. A renderer that
162
+ * lives in memory or behind a pipe has no terminal that can receive OSC 52,
163
+ * and the unsupported object says so explicitly instead of accepting text
164
+ * and dropping it.
165
+ */
166
+ clipboard: Clipboard,
155
167
  };
156
168
 
157
169
  /**
@@ -206,6 +218,7 @@ export function createRenderer(
206
218
  getSelectedText() {
207
219
  return selectedText(renderer);
208
220
  },
221
+ clipboard: unsupportedClipboard,
209
222
  };
210
223
  return renderer;
211
224
  }
@@ -360,16 +373,18 @@ export function dispatchKey(renderer: Renderer, key: KeyEvent): void {
360
373
  * page finds its handler called here. `onMouse` is not in this table because
361
374
  * it is called for every type, after the specific one.
362
375
  */
363
- const MOUSE_HANDLERS: { readonly [string]: string } = {
364
- down: "onMouseDown",
365
- up: "onMouseUp",
366
- move: "onMouseMove",
367
- drag: "onMouseDrag",
368
- "drag-end": "onMouseDragEnd",
369
- drop: "onMouseDrop",
370
- over: "onMouseOver",
371
- out: "onMouseOut",
372
- scroll: "onMouseScroll",
376
+ const MOUSE_HANDLERS: {
377
+ readonly [MouseEventType]: (props: TuiProps) => ?(event: MouseEvent) => mixed,
378
+ } = {
379
+ down: (props) => props.onMouseDown,
380
+ up: (props) => props.onMouseUp,
381
+ move: (props) => props.onMouseMove,
382
+ drag: (props) => props.onMouseDrag,
383
+ "drag-end": (props) => props.onMouseDragEnd,
384
+ drop: (props) => props.onMouseDrop,
385
+ over: (props) => props.onMouseOver,
386
+ out: (props) => props.onMouseOut,
387
+ scroll: (props) => props.onMouseScroll,
373
388
  };
374
389
 
375
390
  /** The `id` a box was given, which is the only name an event can carry. */
@@ -426,7 +441,7 @@ function bubble(
426
441
  while (current != null) {
427
442
  if (current.type === "box") {
428
443
  event.currentTarget = nodeId(current);
429
- const specific = current.props[MOUSE_HANDLERS[event.type]];
444
+ const specific = MOUSE_HANDLERS[event.type](current.props);
430
445
  if (typeof specific === "function") {
431
446
  specific(event);
432
447
  }
@@ -606,6 +621,30 @@ let currentPriority: number = DefaultEventPriority;
606
621
 
607
622
  const noop = () => {};
608
623
 
624
+ /** The host elements `components.js` creates for the three self-drawn widgets. */
625
+ const WIDGETS: { readonly [string]: Widget } = {
626
+ "uf-select": "select",
627
+ "uf-tab-select": "tab-select",
628
+ "uf-textarea": "textarea",
629
+ };
630
+
631
+ /**
632
+ * Move or insert `child` before `before` among `parent`'s children, or at the
633
+ * end when `before` is not one of them.
634
+ *
635
+ * A function of its own because two host-config entries need it: the one React
636
+ * calls for a box and the one it calls for the root. Reaching it through
637
+ * `hostConfig` from inside `hostConfig` made the object's type depend on
638
+ * itself.
639
+ */
640
+ function insertBefore(parent: TuiNode, child: TuiNode, before: TuiNode): void {
641
+ child.parent = parent;
642
+ invalidate(parent, 0);
643
+ remove(parent.children, child);
644
+ const at = parent.children.indexOf(before);
645
+ parent.children.splice(at < 0 ? parent.children.length : at, 0, child);
646
+ }
647
+
609
648
  const hostConfig = {
610
649
  supportsMutation: true,
611
650
  supportsPersistence: false,
@@ -639,6 +678,14 @@ const hostConfig = {
639
678
 
640
679
  createInstance: (type: string, props: TuiProps): TuiNode => {
641
680
  const node = createNode(type === "uf-text" ? "text" : "box", props);
681
+ const widget = WIDGETS[type];
682
+ if (widget != null) {
683
+ // A box that draws itself. Its size is its own to report, the way a
684
+ // text node's is, and for the same reason: nothing inside it can.
685
+ node.widget = widget;
686
+ node.measure = (available: number) => measureWidget(node, widget, available);
687
+ invalidate(node);
688
+ }
642
689
  if (node.type === "text") {
643
690
  // A text node is the only leaf that knows its own size, and it only
644
691
  // knows it once it is told how wide it may be. This is Yoga's measure
@@ -682,15 +729,9 @@ const hostConfig = {
682
729
  invalidate(renderer.root, renderer.root.children.length);
683
730
  renderer.root.children.push(child);
684
731
  },
685
- insertBefore: (parent: TuiNode, child: TuiNode, before: TuiNode): void => {
686
- child.parent = parent;
687
- invalidate(parent, 0);
688
- remove(parent.children, child);
689
- const at = parent.children.indexOf(before);
690
- parent.children.splice(at < 0 ? parent.children.length : at, 0, child);
691
- },
732
+ insertBefore,
692
733
  insertInContainerBefore: (renderer: Renderer, child: TuiNode, before: TuiNode): void => {
693
- hostConfig.insertBefore(renderer.root, child, before);
734
+ insertBefore(renderer.root, child, before);
694
735
  },
695
736
  removeChild: (parent: TuiNode, child: TuiNode): void => {
696
737
  invalidate(parent, 0);
package/internal/paint.js CHANGED
@@ -50,6 +50,7 @@ import type { Selection } from "../selection.js";
50
50
  import type { HitGrid } from "./hits.js";
51
51
  import { recordHit, recordText } from "./hits.js";
52
52
  import type { TuiNode } from "./tree.js";
53
+ import { paintWidget } from "./widgets.js";
53
54
  import { ROOT_TEXT_STYLE, borderOf, textRuns, textStyleFromProps } from "./tree.js";
54
55
  import type { Grapheme } from "../widths.js";
55
56
  import { graphemes } from "../widths.js";
@@ -194,7 +195,7 @@ export function paint(
194
195
  const inherited = selectableOf(node, selectable);
195
196
  switch (node.type) {
196
197
  case "root":
197
- for (const child of node.children) {
198
+ for (const child of paintOrder(node.children)) {
198
199
  paint(child, frame, capabilities, clip, hits, inherited);
199
200
  }
200
201
  return;
@@ -213,6 +214,38 @@ export function paint(
213
214
  }
214
215
  }
215
216
 
217
+ /** A node's `zIndex`, from either spelling; `0` when it has none. */
218
+ function zIndexOf(node: TuiNode): number {
219
+ const own = node.props.zIndex;
220
+ if (typeof own === "number" && Number.isFinite(own)) {
221
+ return own;
222
+ }
223
+ const style = node.props.style;
224
+ const nested = style != null && typeof style === "object" ? style.zIndex : undefined;
225
+ return typeof nested === "number" && Number.isFinite(nested) ? nested : 0;
226
+ }
227
+
228
+ /**
229
+ * The order siblings are painted in: by `zIndex`, and in tree order within one.
230
+ *
231
+ * Painting is destructive, so the order *is* the stacking: what is painted
232
+ * last is on top, both in the frame and in the hit grid, and a higher `zIndex`
233
+ * is painted later. It is OpenTUI's prop and its rule, and it matters as soon
234
+ * as `position: "absolute"` lets two siblings share cells. The sort is stable,
235
+ * so siblings that do not say keep the order the tree gives them, and it is
236
+ * skipped outright when none of them says anything — the common case pays for
237
+ * one look at each child and no array.
238
+ */
239
+ function paintOrder(children: Array<TuiNode>): $ReadOnlyArray<TuiNode> {
240
+ if (!children.some((child) => zIndexOf(child) !== 0)) {
241
+ return children;
242
+ }
243
+ return children
244
+ .map((child, index) => ({ child, index, z: zIndexOf(child) }))
245
+ .sort((a, b) => a.z - b.z || a.index - b.index)
246
+ .map((entry) => entry.child);
247
+ }
248
+
216
249
  /**
217
250
  * Whether text under `node` may be selected.
218
251
  *
@@ -254,6 +287,14 @@ function paintBox(
254
287
  paintBorder(node, frame, capabilities, clip, border, background);
255
288
  }
256
289
 
290
+ if (node.widget != null) {
291
+ // A `Select`, a `TabSelect` or a `Textarea`, which draws what it shows
292
+ // itself because what it shows depends on the size just laid out. It has
293
+ // no children to walk; `widgets.js` says why.
294
+ paintWidget(node, node.widget, frame, capabilities, clip, hits, selectable);
295
+ return;
296
+ }
297
+
257
298
  // `overflow: "hidden"` clips children to what is inside the border and
258
299
  // padding. `"visible"` — the default — lets them draw over the border,
259
300
  // which is how a badge sits on a box's top edge. `"scroll"` clips like
@@ -281,7 +322,7 @@ function paintBox(
281
322
  paint(node.children[index], frame, capabilities, childClip, hits, selectable);
282
323
  }
283
324
  } else {
284
- for (const child of node.children) {
325
+ for (const child of paintOrder(node.children)) {
285
326
  paint(child, frame, capabilities, childClip, hits, selectable);
286
327
  }
287
328
  }
package/internal/tree.js CHANGED
@@ -34,10 +34,23 @@
34
34
  // treats them as the same thing, so this does too. The direct prop wins when
35
35
  // both are present, which is the rule a reader guesses.
36
36
 
37
- import type { LayoutStyle, ScrollIndex } from "../layout.js";
37
+ import type {
38
+ AlignContent,
39
+ AlignItems,
40
+ AlignSelf,
41
+ FlexDirection,
42
+ FlexWrap,
43
+ JustifyContent,
44
+ LayoutStyle,
45
+ Overflow,
46
+ Position,
47
+ ScrollIndex,
48
+ } from "../layout.js";
38
49
  import type { Color, Style } from "../cells.js";
39
50
  import { Attributes, INHERIT, PLAIN, parseColor } from "../cells.js";
40
51
  import type { BorderStyle } from "../capability.js";
52
+ import type { MouseProps } from "../components.js";
53
+ import type { KeyEvent } from "../keys.js";
41
54
 
42
55
  /**
43
56
  * What kind of node this is.
@@ -50,8 +63,24 @@ import type { BorderStyle } from "../capability.js";
50
63
  */
51
64
  export type TuiNodeType = "root" | "box" | "text" | "chars";
52
65
 
53
- /** Anything a component put on a node. Read by `paint.js`, not by layout. */
54
- export type TuiProps = { readonly [string]: mixed };
66
+ /**
67
+ * The three kinds of box that draw themselves: a `Select`, a `TabSelect` and a
68
+ * `Textarea`. `widgets.js` draws them and says why they are drawn rather than
69
+ * composed.
70
+ */
71
+ export type Widget = "select" | "tab-select" | "textarea";
72
+
73
+ /**
74
+ * Anything a component put on a node. Read by `paint.js`, not by layout.
75
+ *
76
+ * The event handlers are named so the renderer can call them with the event
77
+ * `Box` declares for them; everything else is read and checked by name.
78
+ */
79
+ export type TuiProps = {
80
+ ...MouseProps,
81
+ readonly onKeyDown?: (key: KeyEvent) => mixed,
82
+ readonly [string]: mixed,
83
+ };
55
84
 
56
85
  /** One node of the tree. Mutable: React owns its shape, layout owns its geometry. */
57
86
  export type TuiNode = {
@@ -96,6 +125,18 @@ export type TuiNode = {
96
125
  scrollDirtyFrom: number,
97
126
  /** A scrolling box's stack of child heights; see `layout.js`. */
98
127
  scrollIndex: ScrollIndex | null,
128
+ /**
129
+ * What this box draws in place of children, when it is a `Select`, a
130
+ * `TabSelect` or a `Textarea`; `null` for every other node. See
131
+ * `widgets.js`, which is also the only reader of the two fields below.
132
+ */
133
+ widget: Widget | null,
134
+ /**
135
+ * The first line and column a `Textarea` shows, kept between frames so the
136
+ * window moves only as far as the cursor makes it. Written by the painter.
137
+ */
138
+ viewTop: number,
139
+ viewLeft: number,
99
140
  };
100
141
 
101
142
  /** Read a prop, preferring the direct spelling over the one inside `style`. */
@@ -114,6 +155,9 @@ function prop(props: TuiProps, name: string): mixed {
114
155
  const asNumber = (value: mixed): number | void =>
115
156
  typeof value === "number" && Number.isFinite(value) ? value : undefined;
116
157
 
158
+ const asMargin = (value: mixed): number | "auto" | void =>
159
+ value === "auto" ? "auto" : asNumber(value);
160
+
117
161
  const asDimension = (value: mixed): number | string | void => {
118
162
  if (typeof value === "number" && Number.isFinite(value)) {
119
163
  return value;
@@ -141,6 +185,73 @@ export function borderOf(props: TuiProps): BorderStyle | null {
141
185
  return prop(props, "border") === true ? "single" : null;
142
186
  }
143
187
 
188
+ /** The style keys whose value is a cell count or a percentage. */
189
+ const DIMENSION_KEYS = [
190
+ "width",
191
+ "height",
192
+ "minWidth",
193
+ "minHeight",
194
+ "maxWidth",
195
+ "maxHeight",
196
+ "flexBasis",
197
+ "top",
198
+ "right",
199
+ "bottom",
200
+ "left",
201
+ ] as const;
202
+
203
+ /** The style keys whose value is a plain number. */
204
+ const NUMBER_KEYS = [
205
+ "flexGrow",
206
+ "flexShrink",
207
+ "padding",
208
+ "paddingTop",
209
+ "paddingRight",
210
+ "paddingBottom",
211
+ "paddingLeft",
212
+ "gap",
213
+ "rowGap",
214
+ "columnGap",
215
+ "scrollTop",
216
+ ] as const;
217
+
218
+ /** The style keys whose value is a margin: cells, or `"auto"`. */
219
+ const MARGIN_KEYS = ["margin", "marginTop", "marginRight", "marginBottom", "marginLeft"] as const;
220
+
221
+ const FLEX_DIRECTIONS: $ReadOnlyArray<FlexDirection> = [
222
+ "row",
223
+ "row-reverse",
224
+ "column",
225
+ "column-reverse",
226
+ ];
227
+ const JUSTIFY_CONTENTS: $ReadOnlyArray<JustifyContent> = [
228
+ "flex-start",
229
+ "center",
230
+ "flex-end",
231
+ "space-between",
232
+ "space-around",
233
+ "space-evenly",
234
+ ];
235
+ const ALIGN_ITEMS: $ReadOnlyArray<AlignItems> = ["flex-start", "center", "flex-end", "stretch"];
236
+ const ALIGN_SELVES: $ReadOnlyArray<AlignSelf> = ["auto", ...ALIGN_ITEMS];
237
+ const OVERFLOWS: $ReadOnlyArray<Overflow> = ["visible", "hidden", "scroll"];
238
+ const POSITIONS: $ReadOnlyArray<Position> = ["static", "relative", "absolute"];
239
+ const FLEX_WRAPS: $ReadOnlyArray<FlexWrap> = ["no-wrap", "wrap", "wrap-reverse"];
240
+ const ALIGN_CONTENTS: $ReadOnlyArray<AlignContent> = [
241
+ "flex-start",
242
+ "center",
243
+ "flex-end",
244
+ "stretch",
245
+ "space-between",
246
+ "space-around",
247
+ "space-evenly",
248
+ ];
249
+
250
+ /** `value` when it is one of `words`, and `undefined` for anything else. */
251
+ function oneOf<T>(value: mixed, words: $ReadOnlyArray<T>): T | void {
252
+ return words.find((word) => word === value);
253
+ }
254
+
144
255
  /**
145
256
  * The layout style a node's props describe.
146
257
  *
@@ -150,46 +261,62 @@ export function borderOf(props: TuiProps): BorderStyle | null {
150
261
  * two places and make them disagree the first time one of them changed.
151
262
  */
152
263
  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));
264
+ // Writable while it is being built, and the same keys and value types as the
265
+ // `LayoutStyle` it is returned as.
266
+ const style: { [K in $Keys<LayoutStyle>]?: LayoutStyle[K] } = {};
267
+ for (const name of DIMENSION_KEYS) {
268
+ const value = asDimension(prop(props, name));
156
269
  if (value !== undefined) {
157
270
  style[name] = value;
158
271
  }
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
272
  }
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);
273
+ for (const name of NUMBER_KEYS) {
274
+ const value = asNumber(prop(props, name));
275
+ if (value !== undefined) {
276
+ style[name] = value;
277
+ }
278
+ }
279
+ for (const name of MARGIN_KEYS) {
280
+ const value = asMargin(prop(props, name));
281
+ if (value !== undefined) {
282
+ style[name] = value;
283
+ }
284
+ }
285
+
286
+ // The keyword props, each read against the words `layout.js` knows. A word
287
+ // it does not know is left unset, so layout's default applies — which is
288
+ // what a misspelt keyword already did there, now said here instead.
289
+ const flexDirection = oneOf(prop(props, "flexDirection"), FLEX_DIRECTIONS);
290
+ if (flexDirection !== undefined) {
291
+ style.flexDirection = flexDirection;
292
+ }
293
+ const justifyContent = oneOf(prop(props, "justifyContent"), JUSTIFY_CONTENTS);
294
+ if (justifyContent !== undefined) {
295
+ style.justifyContent = justifyContent;
296
+ }
297
+ const alignItems = oneOf(prop(props, "alignItems"), ALIGN_ITEMS);
298
+ if (alignItems !== undefined) {
299
+ style.alignItems = alignItems;
300
+ }
301
+ const alignSelf = oneOf(prop(props, "alignSelf"), ALIGN_SELVES);
302
+ if (alignSelf !== undefined) {
303
+ style.alignSelf = alignSelf;
304
+ }
305
+ const overflow = oneOf(prop(props, "overflow"), OVERFLOWS);
306
+ if (overflow !== undefined) {
307
+ style.overflow = overflow;
308
+ }
309
+ const position = oneOf(prop(props, "position"), POSITIONS);
310
+ if (position !== undefined) {
311
+ style.position = position;
312
+ }
313
+ const flexWrap = oneOf(prop(props, "flexWrap"), FLEX_WRAPS);
314
+ if (flexWrap !== undefined) {
315
+ style.flexWrap = flexWrap;
190
316
  }
191
- for (const name of ["flexDirection", "justifyContent", "alignItems", "alignSelf", "overflow"]) {
192
- copy(name, asString);
317
+ const alignContent = oneOf(prop(props, "alignContent"), ALIGN_CONTENTS);
318
+ if (alignContent !== undefined) {
319
+ style.alignContent = alignContent;
193
320
  }
194
321
 
195
322
  // `paddingX` and `paddingY`, which OpenTUI documents and which are the two
@@ -312,6 +439,9 @@ export function createNode(type: TuiNodeType, props: TuiProps): TuiNode {
312
439
  measuredHeight: 0,
313
440
  scrollDirtyFrom: 0,
314
441
  scrollIndex: null,
442
+ widget: null,
443
+ viewTop: 0,
444
+ viewLeft: 0,
315
445
  };
316
446
  applyProps(node, props);
317
447
  return node;