@bindtty/layout 0.1.0-alpha.1

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/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # @bindtty/layout
2
+
3
+ Layout tree generation for BindTTY mounted nodes.
4
+
5
+ 将 `MountedNode` 树转换为带绝对坐标的 `LayoutNode` 树,供 `@bindtty/renderer-terminal` 绘制。
6
+
7
+ ## API
8
+
9
+ ```ts
10
+ import { layoutRoot, createBasicLayoutEngine } from "@bindtty/layout";
11
+
12
+ const layoutTree = layoutRoot(runtime.root, {
13
+ viewport: { width: 80, height: 24 }
14
+ });
15
+ ```
16
+
17
+ ## 已支持的 intrinsic 布局
18
+
19
+ `screen`、`box`、`vstack`、`hstack`、`text`、`spacer`,以及 `fragment` / `show` / `for` 结构节点。
20
+
21
+ intrinsic `button` / `input` 在 schema 有定义,但 layout 会抛 `Unsupported layout element`;交互控件请使用 `@bindtty/widgets` 的组合实现。
22
+
23
+ 高级 layout props(width、height、gap、flex、clip/scroll)见 [doc/specs/YOGA_AND_TEXT.md](../../doc/specs/YOGA_AND_TEXT.md) 与 [doc/packages/LAYOUT.md](../../doc/packages/LAYOUT.md)。默认 engine 为 `YogaLayoutEngine`。
24
+
25
+ ## 文档
26
+
27
+ - [doc/packages/LAYOUT.md](../../doc/packages/LAYOUT.md) — 包落地设计
28
+ - [doc/specs/YOGA_AND_TEXT.md](../../doc/specs/YOGA_AND_TEXT.md) — text + Yoga 摘要
29
+ - [doc/specs/SCROLL_VIEWPORT.md](../../doc/specs/SCROLL_VIEWPORT.md) — clip / scroll
@@ -0,0 +1,2 @@
1
+ import type { LayoutEngine } from "./types.js";
2
+ export declare function createBasicLayoutEngine(): LayoutEngine;
@@ -0,0 +1,418 @@
1
+ import { layoutText, readTextWrapMode } from "@bindtty/text";
2
+ import { clampNonNegative, toNonNegativeNumber } from "./measure.js";
3
+ const supportedPropsByTag = {
4
+ screen: new Set(),
5
+ vstack: new Set(),
6
+ hstack: new Set(),
7
+ box: new Set([
8
+ "padding",
9
+ "border",
10
+ "height",
11
+ "width",
12
+ "overflow",
13
+ "scrollX",
14
+ "scrollY"
15
+ ]),
16
+ text: new Set(["value", "wrap", "color", "bold"]),
17
+ spacer: new Set(["size"]),
18
+ button: new Set(["value", "disabled"]),
19
+ input: new Set(["value", "placeholder"])
20
+ };
21
+ const futureLayoutProps = new Set([
22
+ "width",
23
+ "height",
24
+ "minWidth",
25
+ "minHeight",
26
+ "maxWidth",
27
+ "maxHeight",
28
+ "paddingX",
29
+ "paddingY",
30
+ "paddingTop",
31
+ "paddingRight",
32
+ "paddingBottom",
33
+ "paddingLeft",
34
+ "margin",
35
+ "marginX",
36
+ "marginY",
37
+ "marginTop",
38
+ "marginRight",
39
+ "marginBottom",
40
+ "marginLeft",
41
+ "gap",
42
+ "flexDirection",
43
+ "flexWrap",
44
+ "justifyContent",
45
+ "alignItems",
46
+ "flexGrow",
47
+ "flexShrink"
48
+ ]);
49
+ const layoutPropAliases = new Map([
50
+ ["padding-top", "paddingTop"],
51
+ ["padding-right", "paddingRight"],
52
+ ["padding-bottom", "paddingBottom"],
53
+ ["padding-left", "paddingLeft"],
54
+ ["padding-x", "paddingX"],
55
+ ["padding-y", "paddingY"],
56
+ ["margin-top", "marginTop"],
57
+ ["margin-right", "marginRight"],
58
+ ["margin-bottom", "marginBottom"],
59
+ ["margin-left", "marginLeft"],
60
+ ["margin-x", "marginX"],
61
+ ["margin-y", "marginY"],
62
+ ["flex-direction", "flexDirection"],
63
+ ["flex-wrap", "flexWrap"],
64
+ ["justify-content", "justifyContent"],
65
+ ["align-items", "alignItems"],
66
+ ["flex-grow", "flexGrow"],
67
+ ["flex-shrink", "flexShrink"],
68
+ ["min-width", "minWidth"],
69
+ ["min-height", "minHeight"],
70
+ ["max-width", "maxWidth"],
71
+ ["max-height", "maxHeight"]
72
+ ]);
73
+ const nonLayoutProps = new Set([
74
+ "id",
75
+ "focusStyle",
76
+ "onKey",
77
+ "onFocusChange"
78
+ ]);
79
+ export function createBasicLayoutEngine() {
80
+ return {
81
+ layout(root, options) {
82
+ if (!root) {
83
+ return null;
84
+ }
85
+ const constraint = {
86
+ width: options.viewport.width,
87
+ height: options.viewport.height,
88
+ flow: "column"
89
+ };
90
+ const measured = measureNode(root, constraint);
91
+ const rect = createRootRect(root, measured, options);
92
+ return arrangeNode(root, rect, constraint);
93
+ }
94
+ };
95
+ }
96
+ function measureNode(node, constraint) {
97
+ switch (node.kind) {
98
+ case "element":
99
+ return measureElement(node, constraint);
100
+ case "fragment":
101
+ case "show":
102
+ case "for":
103
+ return measureFlowChildren(getStructureChildren(node), constraint.flow, constraint);
104
+ }
105
+ }
106
+ function measureElement(node, constraint) {
107
+ validateElementProps(node);
108
+ switch (node.tag) {
109
+ case "screen":
110
+ return {
111
+ width: constraint.width,
112
+ height: constraint.height
113
+ };
114
+ case "text":
115
+ return measureTextElement(node, constraint);
116
+ case "spacer":
117
+ return measureSpacer(node, constraint);
118
+ case "vstack":
119
+ return measureFlowChildren(node.children, "column", constraint);
120
+ case "hstack":
121
+ return measureFlowChildren(node.children, "row", constraint);
122
+ case "box":
123
+ return measureBox(node, constraint);
124
+ case "button":
125
+ case "input":
126
+ throw new Error(`Unsupported layout element: ${node.tag}`);
127
+ }
128
+ }
129
+ function measureTextElement(node, constraint) {
130
+ const text = String(node.props.value ?? "");
131
+ const wrap = readTextWrapMode(node.props.wrap);
132
+ const layout = layoutText(text, {
133
+ width: wrap === "legacy" ? undefined : constraint.width,
134
+ wrap
135
+ });
136
+ return {
137
+ width: layout.width,
138
+ height: layout.height
139
+ };
140
+ }
141
+ function measureSpacer(node, constraint) {
142
+ const size = toNonNegativeNumber(node.props.size);
143
+ if (constraint.flow === "row") {
144
+ return {
145
+ width: size,
146
+ height: constraint.height
147
+ };
148
+ }
149
+ return {
150
+ width: constraint.width,
151
+ height: size
152
+ };
153
+ }
154
+ function measureBox(node, constraint) {
155
+ const edges = getBoxEdges(node);
156
+ const inset = getBoxInset(edges);
157
+ const width = readOptionalSize(node.props.width);
158
+ const height = readOptionalSize(node.props.height);
159
+ const contentConstraint = {
160
+ ...shrinkConstraint(constraint, edges),
161
+ ...(width === undefined
162
+ ? {}
163
+ : { width: clampNonNegative(width - inset * 2) }),
164
+ ...(height === undefined
165
+ ? {}
166
+ : { height: clampNonNegative(height - inset * 2) })
167
+ };
168
+ const childrenSize = measureFlowChildren(node.children, "column", contentConstraint);
169
+ return {
170
+ width: width ?? childrenSize.width + inset * 2,
171
+ height: height ?? childrenSize.height + inset * 2
172
+ };
173
+ }
174
+ function measureFlowChildren(children, flow, constraint) {
175
+ let width = 0;
176
+ let height = 0;
177
+ for (const child of children) {
178
+ const childSize = measureNode(child, {
179
+ ...constraint,
180
+ flow
181
+ });
182
+ if (flow === "row") {
183
+ width += childSize.width;
184
+ height = Math.max(height, childSize.height);
185
+ }
186
+ else {
187
+ width = Math.max(width, childSize.width);
188
+ height += childSize.height;
189
+ }
190
+ }
191
+ return { width, height };
192
+ }
193
+ function arrangeNode(node, rect, constraint) {
194
+ switch (node.kind) {
195
+ case "element":
196
+ return arrangeElement(node, rect, constraint);
197
+ case "fragment":
198
+ case "show":
199
+ case "for":
200
+ return arrangeStructureNode(node, rect, constraint);
201
+ }
202
+ }
203
+ function arrangeStructureNode(node, rect, constraint) {
204
+ return {
205
+ mounted: node,
206
+ rect,
207
+ contentRect: rect,
208
+ children: arrangeFlowChildren(getStructureChildren(node), rect, constraint.flow, constraint)
209
+ };
210
+ }
211
+ function arrangeElement(node, rect, constraint) {
212
+ validateElementProps(node);
213
+ switch (node.tag) {
214
+ case "screen":
215
+ return arrangeFlowElement(node, rect, rect, "column", constraint);
216
+ case "vstack":
217
+ return arrangeFlowElement(node, rect, rect, "column", constraint);
218
+ case "hstack":
219
+ return arrangeFlowElement(node, rect, rect, "row", constraint);
220
+ case "box":
221
+ return arrangeBox(node, rect, constraint);
222
+ case "text":
223
+ case "spacer":
224
+ return createLeafLayout(node, rect);
225
+ case "button":
226
+ case "input":
227
+ throw new Error(`Unsupported layout element: ${node.tag}`);
228
+ }
229
+ }
230
+ function arrangeBox(node, rect, constraint) {
231
+ const edges = getBoxEdges(node);
232
+ const inset = getBoxInset(edges);
233
+ const contentRect = {
234
+ x: rect.x + inset,
235
+ y: rect.y + inset,
236
+ width: clampNonNegative(rect.width - inset * 2),
237
+ height: clampNonNegative(rect.height - inset * 2)
238
+ };
239
+ const contentConstraint = {
240
+ ...constraint,
241
+ width: contentRect.width,
242
+ height: contentRect.height
243
+ };
244
+ const layout = arrangeFlowElement(node, rect, contentRect, "column", contentConstraint);
245
+ const overflow = readOverflow(node.props.overflow);
246
+ const hasScrollX = hasOwn(node.props, "scrollX");
247
+ const hasScrollY = hasOwn(node.props, "scrollY");
248
+ if (overflow === "clip") {
249
+ layout.clip = contentRect;
250
+ }
251
+ if (overflow === "clip" || hasScrollX || hasScrollY) {
252
+ const naturalContentSize = measureFlowChildren(node.children, "column", contentConstraint);
253
+ layout.contentSize = {
254
+ width: Math.max(contentRect.width, naturalContentSize.width),
255
+ height: Math.max(contentRect.height, naturalContentSize.height)
256
+ };
257
+ }
258
+ if (hasScrollX || hasScrollY) {
259
+ const contentSize = layout.contentSize ?? {
260
+ width: contentRect.width,
261
+ height: contentRect.height
262
+ };
263
+ const maxY = clampNonNegative(contentSize.height - contentRect.height);
264
+ layout.scrollOffset = {
265
+ x: 0,
266
+ y: clamp(readScrollOffset(node.props.scrollY), 0, maxY)
267
+ };
268
+ }
269
+ return layout;
270
+ }
271
+ function arrangeFlowElement(node, rect, contentRect, flow, constraint) {
272
+ return {
273
+ mounted: node,
274
+ rect,
275
+ contentRect,
276
+ children: arrangeFlowChildren(node.children, contentRect, flow, constraint)
277
+ };
278
+ }
279
+ function arrangeFlowChildren(children, contentRect, flow, constraint) {
280
+ const arrangedChildren = [];
281
+ let cursorX = contentRect.x;
282
+ let cursorY = contentRect.y;
283
+ for (const child of children) {
284
+ const childConstraint = {
285
+ width: contentRect.width,
286
+ height: contentRect.height,
287
+ flow
288
+ };
289
+ const childSize = measureNode(child, childConstraint);
290
+ const childRect = flow === "row"
291
+ ? {
292
+ x: cursorX,
293
+ y: contentRect.y,
294
+ width: childSize.width,
295
+ height: childSize.height
296
+ }
297
+ : {
298
+ x: contentRect.x,
299
+ y: cursorY,
300
+ width: childSize.width,
301
+ height: childSize.height
302
+ };
303
+ arrangedChildren.push(arrangeNode(child, childRect, childConstraint));
304
+ if (flow === "row") {
305
+ cursorX += childSize.width;
306
+ }
307
+ else {
308
+ cursorY += childSize.height;
309
+ }
310
+ }
311
+ return arrangedChildren;
312
+ }
313
+ function createLeafLayout(node, rect) {
314
+ return {
315
+ mounted: node,
316
+ rect,
317
+ contentRect: rect,
318
+ children: []
319
+ };
320
+ }
321
+ function createRootRect(root, measured, options) {
322
+ if (root.kind === "element" && root.tag === "screen") {
323
+ return {
324
+ x: 0,
325
+ y: 0,
326
+ width: options.viewport.width,
327
+ height: options.viewport.height
328
+ };
329
+ }
330
+ return {
331
+ x: 0,
332
+ y: 0,
333
+ width: measured.width,
334
+ height: measured.height
335
+ };
336
+ }
337
+ function getBoxEdges(node) {
338
+ return {
339
+ border: node.props.border ? 1 : 0,
340
+ padding: toNonNegativeNumber(node.props.padding)
341
+ };
342
+ }
343
+ function getBoxInset(edges) {
344
+ return edges.border + edges.padding;
345
+ }
346
+ function shrinkConstraint(constraint, edges) {
347
+ const inset = getBoxInset(edges) * 2;
348
+ return {
349
+ ...constraint,
350
+ width: clampNonNegative(constraint.width - inset),
351
+ height: clampNonNegative(constraint.height - inset)
352
+ };
353
+ }
354
+ function getStructureChildren(node) {
355
+ switch (node.kind) {
356
+ case "fragment":
357
+ return node.children;
358
+ case "show":
359
+ return node.activeBranch ? [node.activeBranch] : [];
360
+ case "for":
361
+ return node.items.map((item) => item.node);
362
+ case "element":
363
+ return node.children;
364
+ }
365
+ }
366
+ function validateElementProps(node) {
367
+ const supportedProps = supportedPropsByTag[node.tag];
368
+ const seenCanonicalProps = new Map();
369
+ const canonicalProps = [];
370
+ for (const propName of Object.keys(node.props)) {
371
+ if (nonLayoutProps.has(propName)) {
372
+ continue;
373
+ }
374
+ const canonicalName = layoutPropAliases.get(propName) ?? propName;
375
+ const previousName = seenCanonicalProps.get(canonicalName);
376
+ if (previousName && previousName !== propName) {
377
+ throw new Error(`Duplicate layout prop: ${canonicalName} / ${propName}`);
378
+ }
379
+ seenCanonicalProps.set(canonicalName, propName);
380
+ canonicalProps.push(canonicalName);
381
+ }
382
+ for (const canonicalName of canonicalProps) {
383
+ if (!supportedProps.has(canonicalName) &&
384
+ futureLayoutProps.has(canonicalName)) {
385
+ throw new Error(`Unsupported layout prop: ${canonicalName}`);
386
+ }
387
+ }
388
+ if (node.tag === "box") {
389
+ readOverflow(node.props.overflow);
390
+ }
391
+ if (node.tag === "text") {
392
+ readTextWrapMode(node.props.wrap);
393
+ }
394
+ }
395
+ function readOptionalSize(value) {
396
+ if (value === null || value === undefined) {
397
+ return undefined;
398
+ }
399
+ return toNonNegativeNumber(value);
400
+ }
401
+ function readOverflow(value) {
402
+ if (value === null || value === undefined) {
403
+ return "visible";
404
+ }
405
+ if (value === "visible" || value === "clip") {
406
+ return value;
407
+ }
408
+ throw new Error(`Unsupported overflow value: ${String(value)}`);
409
+ }
410
+ function readScrollOffset(value) {
411
+ return Math.floor(toNonNegativeNumber(value));
412
+ }
413
+ function clamp(value, min, max) {
414
+ return Math.min(Math.max(value, min), max);
415
+ }
416
+ function hasOwn(object, key) {
417
+ return Object.prototype.hasOwnProperty.call(object, key);
418
+ }
@@ -0,0 +1,3 @@
1
+ export { createBasicLayoutEngine } from "./basic-engine.js";
2
+ export { layoutRoot } from "./layout.js";
3
+ export type { LayoutEngine, LayoutEngineOptions, LayoutOptions } from "./types.js";
package/dist/engine.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createBasicLayoutEngine } from "./basic-engine.js";
2
+ export { layoutRoot } from "./layout.js";
@@ -0,0 +1,6 @@
1
+ export { createBasicLayoutEngine } from "./basic-engine.js";
2
+ export { createYogaLayoutEngine } from "./yoga-engine.js";
3
+ export { layoutRoot } from "./layout.js";
4
+ export { createZeroRect } from "./measure.js";
5
+ export type { LayoutFlow } from "./intrinsic.js";
6
+ export type { LayoutEngine, LayoutEngineOptions, LayoutNode, LayoutOptions, LayoutRect, LayoutScrollOffset, LayoutSize, LayoutViewport } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { createBasicLayoutEngine } from "./basic-engine.js";
2
+ export { createYogaLayoutEngine } from "./yoga-engine.js";
3
+ export { layoutRoot } from "./layout.js";
4
+ export { createZeroRect } from "./measure.js";
@@ -0,0 +1,5 @@
1
+ export type LayoutFlow = "row" | "column";
2
+ export interface LayoutSize {
3
+ width: number;
4
+ height: number;
5
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { LayoutNode, LayoutOptions } from "./types.js";
2
+ import type { MountedNode } from "@bindtty/vnode";
3
+ export declare function layoutRoot(root: MountedNode | null, options: LayoutOptions): LayoutNode | null;
package/dist/layout.js ADDED
@@ -0,0 +1,7 @@
1
+ import { createYogaLayoutEngine } from "./yoga-engine.js";
2
+ const defaultEngine = createYogaLayoutEngine();
3
+ export function layoutRoot(root, options) {
4
+ return (options.engine ?? defaultEngine).layout(root, {
5
+ viewport: options.viewport
6
+ });
7
+ }
@@ -0,0 +1,4 @@
1
+ import type { LayoutRect } from "./types.js";
2
+ export declare function createZeroRect(): LayoutRect;
3
+ export declare function clampNonNegative(value: number): number;
4
+ export declare function toNonNegativeNumber(value: unknown, fallback?: number): number;
@@ -0,0 +1,16 @@
1
+ export function createZeroRect() {
2
+ return {
3
+ x: 0,
4
+ y: 0,
5
+ width: 0,
6
+ height: 0
7
+ };
8
+ }
9
+ export function clampNonNegative(value) {
10
+ return Math.max(0, value);
11
+ }
12
+ export function toNonNegativeNumber(value, fallback = 0) {
13
+ return typeof value === "number" && Number.isFinite(value)
14
+ ? Math.max(0, value)
15
+ : fallback;
16
+ }
@@ -0,0 +1,37 @@
1
+ import type { MountedNode } from "@bindtty/vnode";
2
+ export interface LayoutRect {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ }
8
+ export interface LayoutViewport {
9
+ width: number;
10
+ height: number;
11
+ }
12
+ export interface LayoutSize {
13
+ width: number;
14
+ height: number;
15
+ }
16
+ export interface LayoutScrollOffset {
17
+ x: number;
18
+ y: number;
19
+ }
20
+ export interface LayoutNode {
21
+ mounted: MountedNode;
22
+ rect: LayoutRect;
23
+ contentRect: LayoutRect;
24
+ clip?: LayoutRect;
25
+ scrollOffset?: LayoutScrollOffset;
26
+ contentSize?: LayoutSize;
27
+ children: LayoutNode[];
28
+ }
29
+ export interface LayoutEngineOptions {
30
+ viewport: LayoutViewport;
31
+ }
32
+ export interface LayoutEngine {
33
+ layout(root: MountedNode | null, options: LayoutEngineOptions): LayoutNode | null;
34
+ }
35
+ export interface LayoutOptions extends LayoutEngineOptions {
36
+ engine?: LayoutEngine;
37
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { LayoutEngine } from "./types.js";
2
+ export declare function createYogaLayoutEngine(): LayoutEngine;
@@ -0,0 +1,521 @@
1
+ import Yoga from "yoga-layout";
2
+ import { layoutText, readTextWrapMode } from "@bindtty/text";
3
+ import { clampNonNegative, toNonNegativeNumber } from "./measure.js";
4
+ const supportedPropsByTag = {
5
+ screen: new Set([
6
+ "gap",
7
+ "flexWrap",
8
+ "justifyContent",
9
+ "alignItems",
10
+ "flexGrow",
11
+ "flexShrink"
12
+ ]),
13
+ vstack: new Set([
14
+ "gap",
15
+ "flexWrap",
16
+ "justifyContent",
17
+ "alignItems",
18
+ "flexGrow",
19
+ "flexShrink"
20
+ ]),
21
+ hstack: new Set([
22
+ "gap",
23
+ "flexWrap",
24
+ "justifyContent",
25
+ "alignItems",
26
+ "flexGrow",
27
+ "flexShrink"
28
+ ]),
29
+ box: new Set([
30
+ "padding",
31
+ "border",
32
+ "height",
33
+ "width",
34
+ "overflow",
35
+ "scrollX",
36
+ "scrollY",
37
+ "gap",
38
+ "flexWrap",
39
+ "justifyContent",
40
+ "alignItems",
41
+ "flexGrow",
42
+ "flexShrink"
43
+ ]),
44
+ text: new Set(["value", "wrap", "color", "bold", "flexGrow", "flexShrink"]),
45
+ spacer: new Set(["size", "flexGrow", "flexShrink"]),
46
+ button: new Set(["value", "disabled", "flexGrow", "flexShrink"]),
47
+ input: new Set(["value", "placeholder", "flexGrow", "flexShrink"])
48
+ };
49
+ const futureLayoutProps = new Set([
50
+ "width",
51
+ "height",
52
+ "minWidth",
53
+ "minHeight",
54
+ "maxWidth",
55
+ "maxHeight",
56
+ "paddingX",
57
+ "paddingY",
58
+ "paddingTop",
59
+ "paddingRight",
60
+ "paddingBottom",
61
+ "paddingLeft",
62
+ "margin",
63
+ "marginX",
64
+ "marginY",
65
+ "marginTop",
66
+ "marginRight",
67
+ "marginBottom",
68
+ "marginLeft",
69
+ "gap",
70
+ "flexDirection",
71
+ "flexWrap",
72
+ "justifyContent",
73
+ "alignItems",
74
+ "flexGrow",
75
+ "flexShrink"
76
+ ]);
77
+ const layoutPropAliases = new Map([
78
+ ["padding-top", "paddingTop"],
79
+ ["padding-right", "paddingRight"],
80
+ ["padding-bottom", "paddingBottom"],
81
+ ["padding-left", "paddingLeft"],
82
+ ["padding-x", "paddingX"],
83
+ ["padding-y", "paddingY"],
84
+ ["margin-top", "marginTop"],
85
+ ["margin-right", "marginRight"],
86
+ ["margin-bottom", "marginBottom"],
87
+ ["margin-left", "marginLeft"],
88
+ ["margin-x", "marginX"],
89
+ ["margin-y", "marginY"],
90
+ ["flex-direction", "flexDirection"],
91
+ ["flex-wrap", "flexWrap"],
92
+ ["justify-content", "justifyContent"],
93
+ ["align-items", "alignItems"],
94
+ ["flex-grow", "flexGrow"],
95
+ ["flex-shrink", "flexShrink"],
96
+ ["min-width", "minWidth"],
97
+ ["min-height", "minHeight"],
98
+ ["max-width", "maxWidth"],
99
+ ["max-height", "maxHeight"]
100
+ ]);
101
+ const nonLayoutProps = new Set([
102
+ "id",
103
+ "focusStyle",
104
+ "onKey",
105
+ "onFocusChange"
106
+ ]);
107
+ const defaultYogaAdapter = {
108
+ createNode() {
109
+ return Yoga.Node.create();
110
+ },
111
+ calculateLayout(node, width, height) {
112
+ node.calculateLayout(width, height, Yoga.DIRECTION_LTR);
113
+ },
114
+ freeRecursive(node) {
115
+ node.freeRecursive();
116
+ }
117
+ };
118
+ export function createYogaLayoutEngine() {
119
+ return {
120
+ layout(root, options) {
121
+ if (!root) {
122
+ return null;
123
+ }
124
+ const entry = buildYogaTree(root, "column", options, defaultYogaAdapter);
125
+ try {
126
+ defaultYogaAdapter.calculateLayout(entry.yogaNode, getRootLayoutWidth(root, options), getRootLayoutHeight(root, options));
127
+ return readLayoutTree(entry, options);
128
+ }
129
+ finally {
130
+ defaultYogaAdapter.freeRecursive(entry.yogaNode);
131
+ }
132
+ }
133
+ };
134
+ }
135
+ function buildYogaTree(node, inheritedFlow, options, adapter) {
136
+ const yogaNode = adapter.createNode();
137
+ const entry = {
138
+ mounted: node,
139
+ yogaNode,
140
+ children: []
141
+ };
142
+ try {
143
+ configureYogaNode(entry, inheritedFlow, options);
144
+ const childFlow = getChildFlow(node, inheritedFlow);
145
+ const children = getStructureChildren(node);
146
+ for (const child of children) {
147
+ const childEntry = buildYogaTree(child, childFlow, options, adapter);
148
+ entry.children.push(childEntry);
149
+ yogaNode.insertChild(childEntry.yogaNode, entry.children.length - 1);
150
+ }
151
+ return entry;
152
+ }
153
+ catch (error) {
154
+ yogaNode.freeRecursive();
155
+ throw error;
156
+ }
157
+ }
158
+ function configureYogaNode(entry, inheritedFlow, options) {
159
+ const { mounted: node, yogaNode } = entry;
160
+ yogaNode.setBoxSizing(Yoga.BOX_SIZING_BORDER_BOX);
161
+ switch (node.kind) {
162
+ case "fragment":
163
+ case "show":
164
+ case "for":
165
+ yogaNode.setFlexDirection(readYogaFlexDirection(inheritedFlow));
166
+ return;
167
+ case "element":
168
+ configureYogaElement(node, yogaNode, inheritedFlow, options);
169
+ return;
170
+ }
171
+ }
172
+ function configureYogaElement(node, yogaNode, inheritedFlow, options) {
173
+ validateElementProps(node);
174
+ applyYogaItemProps(node, yogaNode);
175
+ switch (node.tag) {
176
+ case "screen":
177
+ yogaNode.setWidth(options.viewport.width);
178
+ yogaNode.setHeight(options.viewport.height);
179
+ yogaNode.setFlexDirection(Yoga.FLEX_DIRECTION_COLUMN);
180
+ applyYogaContainerProps(node, yogaNode);
181
+ return;
182
+ case "vstack":
183
+ yogaNode.setFlexDirection(Yoga.FLEX_DIRECTION_COLUMN);
184
+ applyYogaContainerProps(node, yogaNode);
185
+ return;
186
+ case "hstack":
187
+ yogaNode.setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
188
+ applyYogaContainerProps(node, yogaNode);
189
+ return;
190
+ case "box":
191
+ configureYogaBox(node, yogaNode);
192
+ return;
193
+ case "text":
194
+ configureYogaText(node, yogaNode);
195
+ return;
196
+ case "spacer":
197
+ configureYogaSpacer(node, yogaNode, inheritedFlow);
198
+ return;
199
+ case "button":
200
+ case "input":
201
+ throw new Error(`Unsupported layout element: ${node.tag}`);
202
+ }
203
+ }
204
+ function configureYogaBox(node, yogaNode) {
205
+ const border = node.props.border ? 1 : 0;
206
+ const padding = toNonNegativeNumber(node.props.padding);
207
+ const width = readOptionalSize(node.props.width);
208
+ const height = readOptionalSize(node.props.height);
209
+ const overflow = readOverflow(node.props.overflow);
210
+ yogaNode.setFlexDirection(Yoga.FLEX_DIRECTION_COLUMN);
211
+ applyYogaContainerProps(node, yogaNode);
212
+ if (width !== undefined) {
213
+ yogaNode.setWidth(width);
214
+ }
215
+ if (height !== undefined) {
216
+ yogaNode.setHeight(height);
217
+ }
218
+ if (border > 0) {
219
+ yogaNode.setBorder(Yoga.EDGE_ALL, border);
220
+ }
221
+ if (padding > 0) {
222
+ yogaNode.setPadding(Yoga.EDGE_ALL, padding);
223
+ }
224
+ if (overflow === "clip" || hasOwn(node.props, "scrollX") || hasOwn(node.props, "scrollY")) {
225
+ yogaNode.setOverflow(Yoga.OVERFLOW_HIDDEN);
226
+ }
227
+ }
228
+ function configureYogaText(node, yogaNode) {
229
+ const text = String(node.props.value ?? "");
230
+ const wrap = readTextWrapMode(node.props.wrap);
231
+ yogaNode.setMeasureFunc((width, widthMode, _height, _heightMode) => {
232
+ const measured = layoutText(text, {
233
+ width: getMeasureWidth(width, widthMode, wrap),
234
+ wrap
235
+ });
236
+ return measured;
237
+ });
238
+ }
239
+ function configureYogaSpacer(node, yogaNode, inheritedFlow) {
240
+ const size = toNonNegativeNumber(node.props.size);
241
+ if (inheritedFlow === "row") {
242
+ yogaNode.setWidth(size);
243
+ }
244
+ else {
245
+ yogaNode.setHeight(size);
246
+ }
247
+ }
248
+ function readLayoutTree(entry, options) {
249
+ const rootRect = createRootRect(entry, options);
250
+ return readLayoutEntry(entry, rootRect);
251
+ }
252
+ function readLayoutEntry(entry, rect) {
253
+ const contentRect = readContentRect(entry, rect);
254
+ const children = entry.children.map((child) => {
255
+ const childRect = readChildRect(child, rect);
256
+ return readLayoutEntry(child, childRect);
257
+ });
258
+ const layout = {
259
+ mounted: entry.mounted,
260
+ rect,
261
+ contentRect,
262
+ children
263
+ };
264
+ if (entry.mounted.kind === "element" && entry.mounted.tag === "box") {
265
+ applyBoxMetadata(entry.mounted, layout);
266
+ }
267
+ return layout;
268
+ }
269
+ function createRootRect(entry, options) {
270
+ if (entry.mounted.kind === "element" && entry.mounted.tag === "screen") {
271
+ return {
272
+ x: 0,
273
+ y: 0,
274
+ width: options.viewport.width,
275
+ height: options.viewport.height
276
+ };
277
+ }
278
+ return {
279
+ x: 0,
280
+ y: 0,
281
+ width: entry.yogaNode.getComputedWidth(),
282
+ height: entry.yogaNode.getComputedHeight()
283
+ };
284
+ }
285
+ function readChildRect(entry, parentRect) {
286
+ return {
287
+ x: parentRect.x + entry.yogaNode.getComputedLeft(),
288
+ y: parentRect.y + entry.yogaNode.getComputedTop(),
289
+ width: entry.yogaNode.getComputedWidth(),
290
+ height: entry.yogaNode.getComputedHeight()
291
+ };
292
+ }
293
+ function readContentRect(entry, rect) {
294
+ if (entry.mounted.kind !== "element" || entry.mounted.tag !== "box") {
295
+ return rect;
296
+ }
297
+ const inset = getBoxInset(entry.mounted);
298
+ return {
299
+ x: rect.x + inset,
300
+ y: rect.y + inset,
301
+ width: clampNonNegative(rect.width - inset * 2),
302
+ height: clampNonNegative(rect.height - inset * 2)
303
+ };
304
+ }
305
+ function applyBoxMetadata(node, layout) {
306
+ const overflow = readOverflow(node.props.overflow);
307
+ const hasScrollX = hasOwn(node.props, "scrollX");
308
+ const hasScrollY = hasOwn(node.props, "scrollY");
309
+ if (overflow === "clip") {
310
+ layout.clip = layout.contentRect;
311
+ }
312
+ if (overflow === "clip" || hasScrollX || hasScrollY) {
313
+ layout.contentSize = readContentSize(layout);
314
+ }
315
+ if (hasScrollX || hasScrollY) {
316
+ const contentSize = layout.contentSize ?? {
317
+ width: layout.contentRect.width,
318
+ height: layout.contentRect.height
319
+ };
320
+ layout.scrollOffset = {
321
+ x: clamp(readScrollOffset(node.props.scrollX), 0, clampNonNegative(contentSize.width - layout.contentRect.width)),
322
+ y: clamp(readScrollOffset(node.props.scrollY), 0, clampNonNegative(contentSize.height - layout.contentRect.height))
323
+ };
324
+ }
325
+ }
326
+ function readContentSize(layout) {
327
+ let width = layout.contentRect.width;
328
+ let height = layout.contentRect.height;
329
+ for (const child of layout.children) {
330
+ width = Math.max(width, child.rect.x + child.rect.width - layout.contentRect.x);
331
+ height = Math.max(height, child.rect.y + child.rect.height - layout.contentRect.y);
332
+ }
333
+ return {
334
+ width: clampNonNegative(width),
335
+ height: clampNonNegative(height)
336
+ };
337
+ }
338
+ function getChildFlow(node, inheritedFlow) {
339
+ if (node.kind !== "element") {
340
+ return inheritedFlow;
341
+ }
342
+ if (node.tag === "hstack") {
343
+ return "row";
344
+ }
345
+ if (node.tag === "screen" || node.tag === "vstack" || node.tag === "box") {
346
+ return "column";
347
+ }
348
+ return inheritedFlow;
349
+ }
350
+ function getStructureChildren(node) {
351
+ switch (node.kind) {
352
+ case "fragment":
353
+ return node.children;
354
+ case "show":
355
+ return node.activeBranch ? [node.activeBranch] : [];
356
+ case "for":
357
+ return node.items.map((item) => item.node);
358
+ case "element":
359
+ return node.children;
360
+ }
361
+ }
362
+ function validateElementProps(node) {
363
+ const supportedProps = supportedPropsByTag[node.tag];
364
+ const seenCanonicalProps = new Map();
365
+ const canonicalProps = [];
366
+ for (const propName of Object.keys(node.props)) {
367
+ if (nonLayoutProps.has(propName)) {
368
+ continue;
369
+ }
370
+ const canonicalName = layoutPropAliases.get(propName) ?? propName;
371
+ const previousName = seenCanonicalProps.get(canonicalName);
372
+ if (previousName && previousName !== propName) {
373
+ throw new Error(`Duplicate layout prop: ${canonicalName} / ${propName}`);
374
+ }
375
+ seenCanonicalProps.set(canonicalName, propName);
376
+ canonicalProps.push(canonicalName);
377
+ }
378
+ for (const canonicalName of canonicalProps) {
379
+ if (!supportedProps.has(canonicalName) &&
380
+ futureLayoutProps.has(canonicalName)) {
381
+ throw new Error(`Unsupported layout prop: ${canonicalName}`);
382
+ }
383
+ }
384
+ if (node.tag === "box") {
385
+ readOverflow(node.props.overflow);
386
+ }
387
+ if (node.tag === "text") {
388
+ readTextWrapMode(node.props.wrap);
389
+ }
390
+ }
391
+ function getMeasureWidth(width, widthMode, wrap) {
392
+ if (wrap === "legacy" || widthMode === Yoga.MEASURE_MODE_UNDEFINED) {
393
+ return undefined;
394
+ }
395
+ return width;
396
+ }
397
+ function readYogaFlexDirection(flow) {
398
+ return flow === "row" ? Yoga.FLEX_DIRECTION_ROW : Yoga.FLEX_DIRECTION_COLUMN;
399
+ }
400
+ function getRootLayoutWidth(root, options) {
401
+ return root.kind === "element" && root.tag === "screen"
402
+ ? options.viewport.width
403
+ : undefined;
404
+ }
405
+ function getRootLayoutHeight(root, options) {
406
+ return root.kind === "element" && root.tag === "screen"
407
+ ? options.viewport.height
408
+ : undefined;
409
+ }
410
+ function applyYogaItemProps(node, yogaNode) {
411
+ const flexGrow = readOptionalSize(readLayoutProp(node.props, "flexGrow"));
412
+ const flexShrink = readOptionalSize(readLayoutProp(node.props, "flexShrink"));
413
+ if (flexGrow !== undefined) {
414
+ yogaNode.setFlexGrow(flexGrow);
415
+ }
416
+ if (flexShrink !== undefined) {
417
+ yogaNode.setFlexShrink(flexShrink);
418
+ }
419
+ }
420
+ function applyYogaContainerProps(node, yogaNode) {
421
+ const gap = readOptionalSize(readLayoutProp(node.props, "gap"));
422
+ const flexWrap = readLayoutProp(node.props, "flexWrap");
423
+ const alignItems = readLayoutProp(node.props, "alignItems");
424
+ const justifyContent = readLayoutProp(node.props, "justifyContent");
425
+ if (gap !== undefined) {
426
+ yogaNode.setGap(Yoga.GUTTER_ALL, gap);
427
+ }
428
+ if (flexWrap !== undefined) {
429
+ yogaNode.setFlexWrap(readYogaFlexWrap(flexWrap));
430
+ }
431
+ if (alignItems !== undefined) {
432
+ yogaNode.setAlignItems(readYogaAlignItems(alignItems));
433
+ }
434
+ if (justifyContent !== undefined) {
435
+ yogaNode.setJustifyContent(readYogaJustifyContent(justifyContent));
436
+ }
437
+ }
438
+ function readYogaFlexWrap(value) {
439
+ switch (value) {
440
+ case "nowrap":
441
+ return Yoga.WRAP_NO_WRAP;
442
+ case "wrap":
443
+ return Yoga.WRAP_WRAP;
444
+ case "wrap-reverse":
445
+ return Yoga.WRAP_WRAP_REVERSE;
446
+ default:
447
+ throw new Error(`Unsupported flexWrap value: ${String(value)}`);
448
+ }
449
+ }
450
+ function readYogaAlignItems(value) {
451
+ switch (value) {
452
+ case "stretch":
453
+ return Yoga.ALIGN_STRETCH;
454
+ case "flex-start":
455
+ return Yoga.ALIGN_FLEX_START;
456
+ case "center":
457
+ return Yoga.ALIGN_CENTER;
458
+ case "flex-end":
459
+ return Yoga.ALIGN_FLEX_END;
460
+ case "baseline":
461
+ return Yoga.ALIGN_BASELINE;
462
+ default:
463
+ throw new Error(`Unsupported alignItems value: ${String(value)}`);
464
+ }
465
+ }
466
+ function readYogaJustifyContent(value) {
467
+ switch (value) {
468
+ case "flex-start":
469
+ return Yoga.JUSTIFY_FLEX_START;
470
+ case "center":
471
+ return Yoga.JUSTIFY_CENTER;
472
+ case "flex-end":
473
+ return Yoga.JUSTIFY_FLEX_END;
474
+ case "space-between":
475
+ return Yoga.JUSTIFY_SPACE_BETWEEN;
476
+ case "space-around":
477
+ return Yoga.JUSTIFY_SPACE_AROUND;
478
+ case "space-evenly":
479
+ return Yoga.JUSTIFY_SPACE_EVENLY;
480
+ default:
481
+ throw new Error(`Unsupported justifyContent value: ${String(value)}`);
482
+ }
483
+ }
484
+ function readLayoutProp(props, canonicalName) {
485
+ if (hasOwn(props, canonicalName)) {
486
+ return props[canonicalName];
487
+ }
488
+ for (const [alias, canonical] of layoutPropAliases) {
489
+ if (canonical === canonicalName && hasOwn(props, alias)) {
490
+ return props[alias];
491
+ }
492
+ }
493
+ return undefined;
494
+ }
495
+ function getBoxInset(node) {
496
+ return (node.props.border ? 1 : 0) + toNonNegativeNumber(node.props.padding);
497
+ }
498
+ function readOptionalSize(value) {
499
+ if (value === null || value === undefined) {
500
+ return undefined;
501
+ }
502
+ return toNonNegativeNumber(value);
503
+ }
504
+ function readOverflow(value) {
505
+ if (value === null || value === undefined) {
506
+ return "visible";
507
+ }
508
+ if (value === "visible" || value === "clip") {
509
+ return value;
510
+ }
511
+ throw new Error(`Unsupported overflow value: ${String(value)}`);
512
+ }
513
+ function readScrollOffset(value) {
514
+ return Math.floor(toNonNegativeNumber(value));
515
+ }
516
+ function clamp(value, min, max) {
517
+ return Math.min(Math.max(value, min), max);
518
+ }
519
+ function hasOwn(object, key) {
520
+ return Object.prototype.hasOwnProperty.call(object, key);
521
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@bindtty/layout",
3
+ "version": "0.1.0-alpha.1",
4
+ "type": "module",
5
+ "description": "Layout tree generation for BindTTY mounted nodes.",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "prepublishOnly": "npm run build",
21
+ "test": "npm run build --workspace @bindtty/text && npm run build --workspace @bindtty/vnode && npm run build --workspace @bindtty/jsx-runtime && npm run build --workspace @bindtty/runtime && npm run build && tsc -p test/tsconfig.json && node --test test/dist/*.test.js"
22
+ },
23
+ "dependencies": {
24
+ "@bindtty/text": "0.1.0-alpha.1",
25
+ "@bindtty/vnode": "0.1.0-alpha.1",
26
+ "yoga-layout": "3.2.1"
27
+ },
28
+ "devDependencies": {
29
+ "@bindtty/jsx-runtime": "0.1.0-alpha.1",
30
+ "@bindtty/runtime": "0.1.0-alpha.1",
31
+ "@bindtty/signal": "0.1.0-alpha.1",
32
+ "@types/node": "^22.0.0",
33
+ "typescript": "^5.5.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/lithdoo/BindTTY.git",
45
+ "directory": "packages/layout"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/lithdoo/BindTTY/issues"
49
+ },
50
+ "homepage": "https://github.com/lithdoo/BindTTY/tree/main/packages/layout#readme"
51
+ }