@valyrianjs/terminal 0.2.2 → 0.2.4

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/src/render.ts CHANGED
@@ -1,13 +1,9 @@
1
- import { constrainFrame, createFrame, cropFrame, fitFrame, getFrameHeight, getFrameWidth, mergeHorizontal, mergeVertical, overlayFrame, shiftFrame } from "./layout.js";
2
- import { getSelectionRange, normalizeInputState } from "./events.js";
3
- import { markFullFrameSpan, markFullRowSpan } from "./frame-style.js";
4
- import { createEditorState } from "./editor-state.js";
5
- import { isFocusable, textContent } from "./tree.js";
6
1
  import { renderValyrianTerminal } from "./runtime.js";
7
- import { cursorCellOffset, padEndTerminalCells, plainText, sliceTerminalCells, terminalCellToStringIndex, terminalCellWidth, terminalGraphemes } from "./text.js";
8
- import { resolveTerminalStyle } from "./theme.js";
2
+ import { renderTerminalFrame as renderTerminalFrameInternal } from "./render-internal.js";
9
3
 
10
- import type { InputInteractionState, TerminalElementNode, TerminalFocusNode, TerminalFrame, TerminalHitbox, TerminalNode, TerminalSpacing, TerminalSplitBreakpoint, TerminalSplitSize, TerminalStyleDefinition, TerminalStyleSpan, TerminalTheme, TerminalVisualState } from "./types.js";
4
+ import type { TerminalFrame, TerminalHitbox, TerminalNode, TerminalTheme } from "./types.js";
5
+
6
+ const MODAL_OVERLAY_HITBOX_ID = "\u0000valyrian-overlay-modal-shield";
11
7
 
12
8
  export interface TerminalRenderContext {
13
9
  cols: number;
@@ -34,1375 +30,29 @@ function validateRenderContext(context: TerminalRenderContext | undefined) {
34
30
  };
35
31
  }
36
32
 
37
- const VISUAL_STATE_ORDER: TerminalVisualState[] = [
38
- "disabled", "readonly", "loading", "empty", "muted", "error", "warning", "success", "invalid", "valid", "placeholder", "selection", "selected", "current", "expanded", "collapsed", "checked", "unchecked", "indeterminate", "editing", "submitted", "dragging", "dropTarget", "capturing", "focus", "hover", "pressed"
39
- ];
40
-
41
- type SpacingSides = { top: number; right: number; bottom: number; left: number };
42
- type BorderSides = { top: boolean; right: boolean; bottom: boolean; left: boolean; style: string; color?: string };
43
-
44
- function numeric(value: unknown, fallback = 0) {
45
- const result = Number(value);
46
- return Number.isFinite(result) ? result : fallback;
47
- }
48
-
49
- function addFocusableHitbox(frame: TerminalFrame, node: TerminalFocusNode) {
50
- if (!node.props.id) {
51
- return frame;
52
- }
53
- const width = Math.max(1, getFrameWidth(frame));
54
- const height = Math.max(1, getFrameHeight(frame));
55
- frame.hitboxes.unshift({ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height });
56
- return frame;
57
- }
58
-
59
- function positiveDimension(value: unknown, prop: "width" | "height") {
60
- if (typeof value === "undefined") {
61
- return undefined;
62
- }
63
- const size = Number(value);
64
- if (!Number.isFinite(size) || size <= 0) {
65
- throw new RangeError(`${prop} must be greater than zero`);
66
- }
67
- return size;
68
- }
69
-
70
- function positiveInteger(value: unknown, prop: string) {
71
- const size = Number(value);
72
- if (!Number.isFinite(size) || !Number.isInteger(size) || size <= 0) {
73
- throw new RangeError(`${prop} must be a positive finite integer`);
74
- }
75
- return size;
76
- }
77
-
78
- function nonNegativeInteger(value: unknown, prop: string) {
79
- const size = typeof value === "undefined" ? 0 : Number(value);
80
- if (!Number.isFinite(size) || !Number.isInteger(size) || size < 0) {
81
- throw new RangeError(`${prop} must be a non-negative finite integer`);
82
- }
83
- return size;
84
- }
85
-
86
- function normalizeSpacing(value: TerminalSpacing | undefined, prop: string): SpacingSides {
87
- if (typeof value === "undefined") return { top: 0, right: 0, bottom: 0, left: 0 };
88
- if (typeof value === "number") {
89
- const size = nonNegativeInteger(value, prop);
90
- return { top: size, right: size, bottom: size, left: size };
91
- }
92
- const x = nonNegativeInteger(value.x, `${prop}.x`);
93
- const y = nonNegativeInteger(value.y, `${prop}.y`);
94
- return {
95
- top: typeof value.top === "undefined" ? y : nonNegativeInteger(value.top, `${prop}.top`),
96
- right: typeof value.right === "undefined" ? x : nonNegativeInteger(value.right, `${prop}.right`),
97
- bottom: typeof value.bottom === "undefined" ? y : nonNegativeInteger(value.bottom, `${prop}.bottom`),
98
- left: typeof value.left === "undefined" ? x : nonNegativeInteger(value.left, `${prop}.left`)
99
- };
100
- }
101
-
102
- function normalizeBorder(value: TerminalStyleDefinition["border"] | undefined): BorderSides {
103
- if (value === true) return { top: true, right: true, bottom: true, left: true, style: "solid" };
104
- if (typeof value === "string") return { top: true, right: true, bottom: true, left: true, style: value };
105
- if (value && typeof value === "object") {
106
- return { top: value.top === true, right: value.right === true, bottom: value.bottom === true, left: value.left === true, style: value.style || "solid", color: value.color };
107
- }
108
- return { top: false, right: false, bottom: false, left: false, style: "solid" };
109
- }
110
-
111
- function borderChars(style: string) {
112
- if (style === "solid") return { topLeft: "┌", topRight: "┐", bottomLeft: "└", bottomRight: "┘", horizontal: "─", vertical: "│" };
113
- if (style === "dotted") return { topLeft: "+", topRight: "+", bottomLeft: "+", bottomRight: "+", horizontal: "·", vertical: "⋮" };
114
- if (style === "double") return { topLeft: "╔", topRight: "╗", bottomLeft: "╚", bottomRight: "╝", horizontal: "═", vertical: "║" };
115
- throw new RangeError(`Unknown terminal border style: ${style}`);
116
- }
117
-
118
- function mergeStyleDefinitions(base: TerminalStyleDefinition | undefined, next: TerminalStyleDefinition | undefined): TerminalStyleDefinition {
119
- return { ...base, ...next };
120
- }
121
-
122
- type ResolvedStyleSpan = {
123
- kind: string;
124
- style?: TerminalStyleDefinition;
125
- };
126
-
127
- function styleSpan(kind: string, style?: TerminalStyleDefinition): ResolvedStyleSpan {
128
- return typeof style === "undefined" ? { kind } : { kind, style };
129
- }
130
-
131
- const BASE_STYLE_KIND_BY_TAG: Partial<Record<TerminalElementNode["tag"], string>> = {
132
- "terminal-button": "button.base",
133
- "terminal-input": "input.base",
134
- "terminal-editor": "editor.base",
135
- "terminal-list": "list.base",
136
- "terminal-scroll": "scroll.base",
137
- "terminal-log-view": "log.base",
138
- "terminal-overlay": "overlay.base"
139
- };
140
-
141
- function baseStyleKindForNode(node: TerminalElementNode) {
142
- return BASE_STYLE_KIND_BY_TAG[node.tag];
143
- }
144
-
145
- function nodeStates(node: TerminalElementNode) {
146
- const declared = Array.isArray(node.props.state) ? node.props.state : typeof node.props.state === "string" ? [node.props.state] : [];
147
- const states = new Set<TerminalVisualState>();
148
- for (const state of declared) states.add(state as TerminalVisualState);
149
- if (node.props.disabled === true) states.add("disabled");
150
- if (node.props.pressed === true) states.add("pressed");
151
- if (node.props.__focused === true) states.add("focus");
152
- if (typeof node.props.__hoveredIndex === "number" || typeof node.props.__hoveredRow === "number") states.add("hover");
153
- if (Array.isArray(node.props.items) && node.props.items.length === 0) states.add("empty");
154
- if (Array.isArray(node.props.entries) && node.props.entries.length === 0) states.add("empty");
155
- return VISUAL_STATE_ORDER.filter((state) => states.has(state));
156
- }
157
-
158
-
159
- function resolveLayoutStyle(baseKind: string, node: TerminalElementNode, context?: TerminalRenderContext) {
160
- return mergeStyleDefinitions(resolveTerminalStyle(baseKind, context?.theme), resolveTerminalStyle(node.props.style, context?.theme));
161
- }
162
-
163
- function resolveNodeLayoutStyle(node: TerminalElementNode, context?: TerminalRenderContext) {
164
- return mergeStyleDefinitions(resolveTerminalStyle(baseStyleKindForNode(node), context?.theme), resolveTerminalStyle(node.props.style, context?.theme));
165
- }
166
-
167
- function decoratedControlFrame(content: string[], style: TerminalStyleDefinition | undefined) {
168
- const padding = normalizeSpacing(style?.padding, "Control padding");
169
- const border = normalizeBorder(style?.border);
170
- return addBorder(padFrameSides(createFrame(content), padding), border);
171
- }
172
-
173
- function fullFrameSpans(kinds: string[], width: number, height: number): TerminalStyleSpan[] {
174
- const spans: TerminalStyleSpan[] = [];
175
- for (const kind of kinds) {
176
- for (let y = 1; y <= height; y += 1) {
177
- spans.push(markFullFrameSpan({ kind, x1: 1, x2: width + 1, y }));
33
+ function stripDoubleUnderscoreFields<T extends Record<string, any>>(value: T): Record<string, any> {
34
+ const publicValue: Record<string, any> = {};
35
+ for (const [key, fieldValue] of Object.entries(value)) {
36
+ if (!key.startsWith("__")) {
37
+ publicValue[key] = fieldValue;
178
38
  }
179
39
  }
180
- return spans;
40
+ return publicValue;
181
41
  }
182
42
 
183
- function resolveNodeStyle(node: TerminalElementNode, context?: TerminalRenderContext, options: { includeBase?: boolean } = {}) {
184
- const baseKind = options.includeBase === false ? undefined : baseStyleKindForNode(node);
185
- let style = resolveTerminalStyle(baseKind, context?.theme);
186
- const spanKinds: ResolvedStyleSpan[] = typeof baseKind === "undefined" ? [] : [styleSpan(baseKind)];
187
- const explicitStyle = resolveTerminalStyle(node.props.style, context?.theme);
188
- if (typeof node.props.style === "string") {
189
- spanKinds.push(styleSpan(node.props.style));
190
- } else if (explicitStyle) {
191
- spanKinds.push(styleSpan("#style", explicitStyle));
192
- }
193
- style = mergeStyleDefinitions(style, explicitStyle);
194
- for (const state of nodeStates(node)) {
195
- const stateStyle = node.props.styles?.[state];
196
- if (stateStyle) {
197
- const resolvedStateStyle = resolveTerminalStyle(stateStyle, context?.theme);
198
- style = mergeStyleDefinitions(style, resolvedStateStyle);
199
- spanKinds.push(typeof stateStyle === "string" ? styleSpan(stateStyle) : styleSpan("#style", resolvedStateStyle));
200
- }
201
- }
202
- return { style, spanKinds };
203
- }
204
-
205
- function padFrameSides(frame: TerminalFrame, padding: SpacingSides) {
206
- if (!padding.top && !padding.right && !padding.bottom && !padding.left) return frame;
207
- const width = Math.max(1, getFrameWidth(frame));
208
- const contentWidth = width + padding.left + padding.right;
209
- const topLines = new Array<string>(padding.top).fill(" ".repeat(contentWidth));
210
- const bottomLines = new Array<string>(padding.bottom).fill(" ".repeat(contentWidth));
211
- const lines = [
212
- ...topLines,
213
- ...frame.lines.map((line) => `${" ".repeat(padding.left)}${padEndTerminalCells(line, width)}${" ".repeat(padding.right)}`),
214
- ...bottomLines
215
- ];
216
- return shiftFrame(createFrame(lines, frame.hitboxes, frame.cursor, frame.spans), padding.left, padding.top);
217
- }
218
-
219
- function addBorder(frame: TerminalFrame, border: BorderSides) {
220
- if (!border.top && !border.right && !border.bottom && !border.left) return frame;
221
- const chars = borderChars(border.style);
222
- const innerWidth = getFrameWidth(frame);
223
- const left = border.left ? 1 : 0;
224
- const right = border.right ? 1 : 0;
225
- const width = innerWidth + left + right;
226
- const lines: string[] = [];
227
- if (border.top) {
228
- lines.push(`${border.left ? chars.topLeft : chars.horizontal}${chars.horizontal.repeat(Math.max(0, width - left - right))}${border.right ? chars.topRight : chars.horizontal}`.slice(0, width));
229
- }
230
- for (const line of frame.lines) {
231
- lines.push(`${border.left ? chars.vertical : ""}${padEndTerminalCells(line, innerWidth)}${border.right ? chars.vertical : ""}`);
232
- }
233
- if (border.bottom) {
234
- lines.push(`${border.left ? chars.bottomLeft : chars.horizontal}${chars.horizontal.repeat(Math.max(0, width - left - right))}${border.right ? chars.bottomRight : chars.horizontal}`.slice(0, width));
235
- }
236
- return shiftFrame(createFrame(lines, frame.hitboxes, frame.cursor, frame.spans), left, border.top ? 1 : 0);
237
- }
238
-
239
- function containerDecorationSize(node: TerminalElementNode, context?: TerminalRenderContext) {
240
- const layoutStyle = resolveNodeLayoutStyle(node, context);
241
- const padding = normalizeSpacing(layoutStyle?.padding ?? node.props.padding, `${node.tag} padding`);
242
- const border = normalizeBorder(layoutStyle?.border);
43
+ function publicTerminalFrame(frame: TerminalFrame): TerminalFrame {
243
44
  return {
244
- horizontal: padding.left + padding.right + (border.left ? 1 : 0) + (border.right ? 1 : 0),
245
- vertical: padding.top + padding.bottom + (border.top ? 1 : 0) + (border.bottom ? 1 : 0)
45
+ lines: frame.lines,
46
+ hitboxes: frame.hitboxes
47
+ .filter((hitbox) => hitbox.id !== MODAL_OVERLAY_HITBOX_ID)
48
+ .map((hitbox) => stripDoubleUnderscoreFields(hitbox) as TerminalHitbox),
49
+ cursor: frame.cursor ? { ...frame.cursor } : null,
50
+ spans: frame.spans
246
51
  };
247
52
  }
248
53
 
249
- function addFullFrameSpans(frame: TerminalFrame, kinds: ResolvedStyleSpan[]) {
250
- const width = Math.max(1, getFrameWidth(frame));
251
- const height = getFrameHeight(frame);
252
- if (width <= 0 || height <= 0 || kinds.length === 0) return frame;
253
- const spans = frame.spans.slice();
254
- for (const span of kinds) {
255
- for (let y = 1; y <= height; y += 1) {
256
- spans.push(markFullFrameSpan({ ...span, x1: 1, x2: width + 1, y }));
257
- }
258
- }
259
- return createFrame(frame.lines, frame.hitboxes, frame.cursor, spans);
260
- }
261
-
262
- function listViewportRows(node: TerminalElementNode, itemCount: number, context?: TerminalRenderContext) {
263
- const explicitHeight = positiveDimension(node.props.height, "height");
264
- const viewportSourceRows = explicitHeight ?? context?.rows ?? (itemCount || 1);
265
- return Math.max(1, Math.min(itemCount || 1, positiveInteger(viewportSourceRows, "List viewport height")));
266
- }
267
-
268
- function clampListIndex(index: number, itemCount: number) {
269
- if (itemCount <= 0) {
270
- return 0;
271
- }
272
- return Math.max(0, Math.min(itemCount - 1, index));
273
- }
274
-
275
- function listVirtualRange(node: TerminalElementNode, itemCount: number, context?: TerminalRenderContext) {
276
- if (!node.props.virtualized) {
277
- return { start: 0, end: itemCount, visibleStart: 0, viewportRows: itemCount || 1 };
278
- }
279
-
280
- if (typeof node.props.itemHeight !== "undefined" && node.props.itemHeight !== 1) {
281
- throw new RangeError("List itemHeight must be 1");
282
- }
283
-
284
- const overscan = nonNegativeInteger(node.props.overscan, "List overscan");
285
- const viewportRows = listViewportRows(node, itemCount, context);
286
- const maxOffset = Math.max(0, itemCount - viewportRows);
287
- let visibleStart = Math.max(0, Math.min(maxOffset, nonNegativeInteger(node.props.__scrollOffset, "List viewport offset")));
288
- const start = Math.max(0, visibleStart - overscan);
289
- const end = Math.min(itemCount, visibleStart + viewportRows + overscan);
290
-
291
- return { start, end, visibleStart, viewportRows };
292
- }
293
-
294
- function listItemKey(node: TerminalElementNode, item: unknown, index: number) {
295
- if (typeof node.props.itemKey === "function") {
296
- const key = node.props.itemKey(item, index);
297
- if (typeof key !== "string" && typeof key !== "number") {
298
- throw new RangeError("List itemKey must return a string or number");
299
- }
300
- return String(key);
301
- }
302
- return String(index);
303
- }
304
-
305
- function listItemRenderer(node: TerminalElementNode) {
306
- if (typeof node.props.__childrenRenderer === "function") {
307
- return { type: "children" as const, render: node.props.__childrenRenderer };
308
- }
309
- if (typeof node.props.renderItem === "function") {
310
- return { type: "renderItem" as const, render: node.props.renderItem };
311
- }
312
- return undefined;
313
- }
314
-
315
- function wrapPlainText(value: string, width: number) {
316
- if (!Number.isFinite(width) || !Number.isInteger(width) || width <= 0) {
317
- return [""];
318
- }
319
-
320
- const rows: string[] = [];
321
- const sourceRows = value.split("\n");
322
- for (const sourceRow of sourceRows) {
323
- if (sourceRow.length === 0) {
324
- rows.push("");
325
- continue;
326
- }
327
-
328
- let remaining = sourceRow;
329
- while (terminalCellWidth(remaining) > width) {
330
- const slice = sliceTerminalCells(remaining, width);
331
- if (slice.length === 0) {
332
- const [firstGrapheme = ""] = terminalGraphemes(remaining);
333
- rows.push(firstGrapheme);
334
- remaining = remaining.slice(firstGrapheme.length);
335
- continue;
336
- }
337
-
338
- const breakAt = slice.lastIndexOf(" ");
339
- if (breakAt > 0 && breakAt >= Math.floor(width * 0.6)) {
340
- rows.push(remaining.slice(0, breakAt));
341
- remaining = remaining.slice(breakAt + 1);
342
- } else {
343
- rows.push(slice);
344
- remaining = remaining.slice(slice.length);
345
- }
346
- }
347
- rows.push(remaining);
348
- }
349
-
350
- return rows.length ? rows : [""];
351
- }
352
-
353
- function renderListItemFrame(node: TerminalElementNode, item: unknown, index: number, viewportIndex: number, activeIndex: number, selectedIndex: number | null, wrapWidth: number, context?: TerminalRenderContext) {
354
- const key = listItemKey(node, item, index);
355
- const renderer = listItemRenderer(node);
356
- if (!renderer) {
357
- const label = plainText(item);
358
- return createFrame(node.props.wrap === true ? wrapPlainText(label, wrapWidth) : label.split("\n"));
359
- }
360
-
361
- const ctx = {
362
- index,
363
- key,
364
- active: index === activeIndex,
365
- selected: selectedIndex !== null && index === selectedIndex,
366
- viewportIndex,
367
- item
368
- };
369
- const rendered = renderer.type === "children" ? renderer.render(item, ctx) : renderer.render(item, index);
370
-
371
- if (typeof rendered === "string" || typeof rendered === "number") {
372
- const label = plainText(rendered);
373
- return createFrame(node.props.wrap === true ? wrapPlainText(label, wrapWidth) : label.split("\n"));
374
- }
375
-
376
- const frame = mergeVertical(renderValyrianTerminal(rendered).map((child) => renderTerminalFrame(child, context)));
377
- if (node.props.wrap === true && frame.hitboxes.length === 0) {
378
- return createFrame(frame.lines.flatMap((line) => wrapPlainText(line, wrapWidth)));
379
- }
380
- return frame;
381
- }
382
-
383
- function fixedPosition(value: unknown) {
384
- if (value === "top" || value === "bottom" || value === "left" || value === "right") {
385
- return value;
386
- }
387
- throw new RangeError("Fixed position must be top, bottom, left, or right");
388
- }
389
-
390
- function fillContextDimension(context: TerminalRenderContext | undefined, prop: "width" | "height", label: string) {
391
- if (!context) {
392
- throw new RangeError(`${label} fill requires render context to resolve ${prop}`);
393
- }
394
- return positiveInteger(prop === "width" ? context.cols : context.rows, `${label} ${prop}`);
395
- }
396
-
397
- function resolveLayoutDimension(node: TerminalElementNode, prop: "width" | "height", label: string, context?: TerminalRenderContext) {
398
- const explicit = positiveDimension(node.props[prop], prop);
399
- if (typeof explicit !== "undefined") {
400
- return explicit;
401
- }
402
- if (node.props.fill === true) {
403
- return fillContextDimension(context, prop, label);
404
- }
405
- return undefined;
406
- }
407
-
408
- function resolveLayoutDimensions(node: TerminalElementNode, label: string, context?: TerminalRenderContext) {
409
- return {
410
- width: resolveLayoutDimension(node, "width", label, context),
411
- height: resolveLayoutDimension(node, "height", label, context)
412
- };
413
- }
414
-
415
- function contextBackedDimension(context: TerminalRenderContext | undefined, prop: "width" | "height", label: string) {
416
- if (!context) {
417
- return undefined;
418
- }
419
- return positiveInteger(prop === "width" ? context.cols : context.rows, `${label} ${prop}`);
420
- }
421
-
422
- function resolveBlockLayoutDimensions(
423
- node: TerminalElementNode,
424
- label: string,
425
- context?: TerminalRenderContext,
426
- options: { exactHeightFromContext?: boolean } = {}
427
- ) {
428
- const explicitWidth = positiveDimension(node.props.width, "width");
429
- const explicitHeight = positiveDimension(node.props.height, "height");
430
- return {
431
- width: typeof explicitWidth !== "undefined"
432
- ? explicitWidth
433
- : node.props.fill === true
434
- ? fillContextDimension(context, "width", label)
435
- : contextBackedDimension(context, "width", label),
436
- height: typeof explicitHeight !== "undefined"
437
- ? explicitHeight
438
- : node.props.fill === true
439
- ? fillContextDimension(context, "height", label)
440
- : options.exactHeightFromContext === true
441
- ? contextBackedDimension(context, "height", label)
442
- : undefined
443
- };
444
- }
445
-
446
- function resolveSplitDimension(node: TerminalElementNode, prop: "width" | "height", context?: TerminalRenderContext) {
447
- const explicit = positiveDimension(node.props[prop], prop);
448
- if (typeof explicit !== "undefined") {
449
- return explicit;
450
- }
451
- if (node.props.fill === true) {
452
- return fillContextDimension(context, prop, "Split");
453
- }
454
- const fromContext = contextBackedDimension(context, prop, "Split");
455
- if (typeof fromContext !== "undefined") {
456
- return fromContext;
457
- }
458
- throw new RangeError("Split requires width/height or render context");
459
- }
460
-
461
- function decorateContainerFrame(frame: TerminalFrame, node: TerminalElementNode, options: { constrain?: boolean; width?: number; height?: number } = {}, context?: TerminalRenderContext) {
462
- let next = frame;
463
- const resolved = resolveNodeStyle(node, context);
464
- const layoutStyle = resolveNodeLayoutStyle(node, context);
465
- const padding = normalizeSpacing(layoutStyle?.padding ?? node.props.padding, `${node.tag} padding`);
466
- const border = normalizeBorder(layoutStyle?.border);
467
- const decoration = containerDecorationSize(node, context);
468
- if (!options.constrain) {
469
- next = fitFrame(next, numeric(options.width ?? node.props.width, 0), numeric(options.height ?? node.props.height, 0));
470
- } else if (node.tag === "terminal-pane" && (typeof node.props.width !== "undefined" || typeof node.props.height !== "undefined")) {
471
- const width = typeof node.props.width === "undefined" || typeof options.width === "undefined" ? undefined : options.width - decoration.horizontal;
472
- const height = typeof node.props.height === "undefined" || typeof options.height === "undefined" ? undefined : options.height - decoration.vertical;
473
- if ((typeof width === "number" && width > 0) || (typeof height === "number" && height > 0)) {
474
- next = constrainFrame(next, {
475
- width: typeof width === "number" && width > 0 ? width : undefined,
476
- height: typeof height === "number" && height > 0 ? height : undefined,
477
- expandFullFrameSpans: true
478
- });
479
- }
480
- }
481
- next = padFrameSides(next, padding);
482
- next = addBorder(next, border);
483
- if (options.constrain) {
484
- next = constrainFrame(next, {
485
- width: typeof options.width === "undefined" ? positiveDimension(node.props.width, "width") : options.width,
486
- height: typeof options.height === "undefined" ? positiveDimension(node.props.height, "height") : options.height,
487
- expandFullFrameSpans: true
488
- });
489
- }
490
- next = addContainerStyleSpans(next, node, resolved);
491
- if (node.props.id && isFocusable(node)) {
492
- next = addFocusableHitbox(next, node);
493
- }
494
- return next;
495
- }
496
-
497
- function addContainerStyleSpans(frame: TerminalFrame, node: TerminalElementNode, resolved = resolveNodeStyle(node)) {
498
- const width = getFrameWidth(frame);
499
- const height = getFrameHeight(frame);
500
- if (width <= 0 || height <= 0) {
501
- return frame;
502
- }
503
-
504
- const spans = frame.spans.slice();
505
- const containerSpans = resolved.spanKinds.slice();
506
- const border = normalizeBorder(resolved.style?.border);
507
-
508
- if (!resolved.style?.color && border.color) {
509
- containerSpans.push(styleSpan("#style", { color: border.color }));
510
- }
511
-
512
- for (const span of containerSpans) {
513
- for (let y = 1; y <= height; y += 1) {
514
- spans.push({ ...span, x1: 1, x2: width + 1, y });
515
- }
516
- }
517
-
518
- if (node.props.__focused && isFocusable(node)) {
519
- for (let y = 1; y <= height; y += 1) {
520
- spans.push({ kind: "focus", x1: 1, x2: width + 1, y });
521
- }
522
- }
523
-
524
- return createFrame(frame.lines, frame.hitboxes, frame.cursor, spans);
525
- }
526
-
527
- function decoratedContentDimension(node: TerminalElementNode, prop: "width" | "height", outer: number, context?: TerminalRenderContext) {
528
- positiveInteger(outer, `Pane ${prop}`);
529
- const decoration = containerDecorationSize(node, context);
530
- const inner = outer - (prop === "width" ? decoration.horizontal : decoration.vertical);
531
-
532
- if (!Number.isFinite(inner) || !Number.isInteger(inner) || inner <= 0) {
533
- throw new RangeError(`Pane ${prop} leaves no room for Fixed content`);
534
- }
535
-
536
- return inner;
537
- }
538
-
539
- function resolveContainerChildContext(node: TerminalElementNode, dimensions: { width?: number; height?: number }, context?: TerminalRenderContext) {
540
- if (typeof dimensions.width !== "number" && typeof dimensions.height !== "number") {
541
- return context;
542
- }
543
-
544
- return {
545
- cols: typeof dimensions.width === "number" ? decoratedContentDimension(node, "width", dimensions.width, context) : context?.cols ?? 1,
546
- rows: typeof dimensions.height === "number" ? decoratedContentDimension(node, "height", dimensions.height, context) : context?.rows ?? 1,
547
- theme: context?.theme
548
- };
549
- }
550
-
551
- function interactiveTextMetadata(value: string) {
552
- const textLength = value.length;
553
- const textCellToStringIndex = terminalCellToStringIndex(value);
554
- const usesLinearIndexes = textCellToStringIndex.length === textLength + 1
555
- && textCellToStringIndex.every((index, cellOffset) => index === cellOffset);
556
-
557
- if (usesLinearIndexes) {
558
- return { textLength };
559
- }
560
-
561
- return { textLength, textCellToStringIndex };
562
- }
563
-
564
- function renderInputLine(value: string, inputState: InputInteractionState, padding: SpacingSides = { top: 0, right: 0, bottom: 0, left: 0 }) {
565
- const state = normalizeInputState(inputState, value.length);
566
- const { start, end } = getSelectionRange(state);
567
- const line = `${value.slice(0, state.cursor)}|${value.slice(state.cursor)}`;
568
- const paddedLine = `${" ".repeat(padding.left)}${line}${" ".repeat(padding.right)}`;
569
- const textStart = padding.left + 1;
570
- return {
571
- line: paddedLine,
572
- cursor: { x: textStart + cursorCellOffset(value, state.cursor), y: 1 },
573
- spans: start === end ? [] : [{ kind: "input.selection", x1: textStart + cursorCellOffset(value, start), x2: textStart + cursorCellOffset(value, end), y: 1 }]
574
- };
575
- }
576
-
577
- function resolveEditorDimensions(node: TerminalElementNode, context?: TerminalRenderContext) {
578
- const explicitWidth = positiveDimension(node.props.width, "width");
579
- const explicitHeight = positiveDimension(node.props.height, "height");
580
- return {
581
- width: typeof explicitWidth !== "undefined"
582
- ? explicitWidth
583
- : node.props.fill === true
584
- ? fillContextDimension(context, "width", "Editor")
585
- : undefined,
586
- height: typeof explicitHeight !== "undefined"
587
- ? explicitHeight
588
- : node.props.fill === true
589
- ? fillContextDimension(context, "height", "Editor")
590
- : undefined
591
- };
592
- }
593
-
594
- function renderEditorFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
595
- const value = typeof node.props.value !== "undefined" ? plainText(node.props.value) : "";
596
- const placeholder = typeof node.props.placeholder !== "undefined" ? plainText(node.props.placeholder) : "";
597
- const displayValue = value.length === 0 && !node.props.__focused && placeholder ? placeholder : value;
598
- const state = createEditorState(displayValue, node.props.__editorState?.cursor);
599
- const focusedState = createEditorState(value, node.props.__editorState?.cursor);
600
- const focusedLine = focusedState.cursor.line;
601
- const focusedColumn = focusedState.cursor.column;
602
- const lines = state.lines.map((line, index) => {
603
- if (!node.props.__focused || index !== focusedLine) {
604
- return ` ${line}`;
605
- }
606
-
607
- return `> ${line.slice(0, focusedColumn)}|${line.slice(focusedColumn)}`;
608
- });
609
- const width = lines.reduce((max, line) => Math.max(max, terminalCellWidth(line)), 0);
610
- const cursor = node.props.__focused ? { x: 3 + cursorCellOffset(focusedState.lines[focusedLine], focusedColumn), y: focusedLine + 1 } : null;
611
- const spans: TerminalStyleSpan[] = node.props.__focused ? [{ kind: "focus", x1: 1, x2: Math.max(2, terminalCellWidth(lines[focusedLine]) + 1), y: focusedLine + 1 }] : [];
612
- const dimensions = resolveEditorDimensions(node, context);
613
- const frame = createFrame(lines, [], cursor, spans);
614
- const scrollOffset = node.props.__focused && typeof dimensions.height !== "undefined"
615
- ? Math.min(Math.max(0, focusedLine - dimensions.height + 1), Math.max(0, lines.length - dimensions.height))
616
- : 0;
617
- const croppedFrame = typeof dimensions.height === "undefined"
618
- ? frame
619
- : scrollOffset > 0
620
- ? cropFrame(frame, scrollOffset, dimensions.height)
621
- : frame;
622
- const constrainedFrame = typeof dimensions.width === "undefined" && typeof dimensions.height === "undefined"
623
- ? croppedFrame
624
- : constrainFrame(croppedFrame, { ...dimensions, expandFullFrameSpans: true });
625
-
626
- if (!node.props.id) {
627
- return constrainedFrame;
628
- }
629
-
630
- return createFrame(constrainedFrame.lines, [{ id: node.props.id, tag: node.tag, x1: 1, x2: Math.max(1, getFrameWidth(constrainedFrame)), y1: 1, y2: getFrameHeight(constrainedFrame), textStartX: 3, ...interactiveTextMetadata(value) }], constrainedFrame.cursor, constrainedFrame.spans);
631
- }
632
-
633
- function notifyLayoutContextProbe(node: TerminalElementNode, context?: TerminalRenderContext) {
634
- if (context && typeof node.props.__layoutContextProbe === "function") {
635
- node.props.__layoutContextProbe(context);
636
- }
637
- }
638
-
639
- function renderTableFrame(node: TerminalElementNode, context?: TerminalRenderContext): TerminalFrame {
640
- const rowNodes = node.children.filter((child): child is TerminalElementNode => child.type === "element");
641
- if (!rowNodes.length) {
642
- return createFrame([""]);
643
- }
644
-
645
- const rowFrames = rowNodes.map((row) => row.children.map((child) => renderTerminalFrame(child, context)));
646
- const columnCount = rowFrames.reduce((max, row) => Math.max(max, row.length), 0);
647
- const columnWidths = new Array<number>(columnCount).fill(0);
648
-
649
- for (const row of rowFrames) {
650
- for (let index = 0; index < columnCount; index += 1) {
651
- const cell = row[index] || createFrame([""]);
652
- columnWidths[index] = Math.max(columnWidths[index], getFrameWidth(cell));
653
- }
654
- }
655
-
656
- const lines: string[] = [];
657
- const hitboxes = [] as TerminalFrame["hitboxes"];
658
- const spans = [] as TerminalStyleSpan[];
659
- let cursor: TerminalFrame["cursor"] = null;
660
- let yOffset = 0;
661
-
662
- for (const row of rowFrames) {
663
- const cells = new Array<TerminalFrame>(columnCount).fill(createFrame([""])).map((cell, index) => row[index] || cell);
664
- const rowHeight = cells.reduce((max, cell) => Math.max(max, getFrameHeight(cell)), 0);
665
- const normalized = cells.map((cell, index) => ({
666
- frame: fitFrame(cell, columnWidths[index], rowHeight, { expandFullFrameSpans: true }),
667
- width: columnWidths[index]
668
- }));
669
-
670
- for (let rowIndex = 0; rowIndex < rowHeight; rowIndex += 1) {
671
- lines.push(normalized.map((cell) => cell.frame.lines[rowIndex]).join(" | "));
672
- }
673
-
674
- let xOffset = 0;
675
- for (let index = 0; index < normalized.length; index += 1) {
676
- const cell = normalized[index];
677
- const shifted = shiftFrame(cell.frame, xOffset, yOffset);
678
- hitboxes.push(...shifted.hitboxes);
679
- spans.push(...shifted.spans);
680
- if (!cursor && cell.frame.cursor) {
681
- cursor = { x: cell.frame.cursor.x + xOffset, y: cell.frame.cursor.y + yOffset };
682
- }
683
- xOffset += cell.width + (index < normalized.length - 1 ? 3 : 0);
684
- }
685
-
686
- yOffset += rowHeight;
687
- }
688
-
689
- return createFrame(lines, hitboxes, cursor, spans);
690
- }
691
-
692
- function resolveSplitBreakpoint(node: TerminalElementNode, width: number, height: number) {
693
- const breakpoints = Array.isArray(node.props.breakpoints) ? node.props.breakpoints as TerminalSplitBreakpoint[] : [];
694
- for (const breakpoint of breakpoints) {
695
- if (typeof breakpoint.maxCols === "number" && width > breakpoint.maxCols) continue;
696
- if (typeof breakpoint.maxRows === "number" && height > breakpoint.maxRows) continue;
697
- return breakpoint;
698
- }
699
- return undefined;
700
- }
701
-
702
- function parseSplitSize(value: TerminalSplitSize, index: number) {
703
- if (typeof value === "number") {
704
- return { type: "absolute" as const, value: positiveInteger(value, `Split sizes[${index}]`) };
705
- }
706
- if (typeof value !== "string") {
707
- throw new RangeError(`Split sizes[${index}] must be a number, percent, or fr value`);
708
- }
709
- const percent = value.match(/^([+-]?\d+(?:\.\d+)?)%$/);
710
- if (percent) {
711
- const amount = Number(percent[1]);
712
- if (!Number.isFinite(amount) || amount <= 0) throw new RangeError(`Split sizes[${index}] percent must be greater than zero`);
713
- return { type: "percent" as const, value: amount };
714
- }
715
- const fraction = value.match(/^([+-]?\d+(?:\.\d+)?)fr$/);
716
- if (fraction) {
717
- const amount = Number(fraction[1]);
718
- if (!Number.isFinite(amount) || amount <= 0) throw new RangeError(`Split sizes[${index}] fr must be greater than zero`);
719
- return { type: "fr" as const, value: amount };
720
- }
721
- throw new RangeError(`Invalid Split sizes[${index}]: ${value}`);
722
- }
723
-
724
- function allocateDecimals(ideals: number[], total: number) {
725
- const sizes = ideals.map((value) => Math.floor(value));
726
- let remainder = total - sizes.reduce((sum, value) => sum + value, 0);
727
- const order = ideals.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
728
- .sort((a, b) => b.fraction - a.fraction || a.index - b.index);
729
- for (let index = 0; index < order.length && remainder > 0; index += 1) {
730
- sizes[order[index].index] += 1;
731
- remainder -= 1;
732
- }
733
- return sizes;
734
- }
735
-
736
- function resolveSplitSizes(sizesInput: TerminalSplitSize[] | undefined, childCount: number, available: number) {
737
- if (!Array.isArray(sizesInput)) {
738
- const base = Math.floor(available / childCount);
739
- const remainder = available % childCount;
740
- const sizes = new Array<number>(childCount);
741
- for (let index = 0; index < childCount; index += 1) sizes[index] = base + (index < remainder ? 1 : 0);
742
- return sizes;
743
- }
744
- if (sizesInput.length !== childCount) throw new RangeError("Split sizes length must match child count");
745
- const parsed = sizesInput.map(parseSplitSize);
746
- const percentTotal = parsed.reduce((sum, size) => sum + (size.type === "percent" ? size.value : 0), 0);
747
- if (percentTotal > 100) throw new RangeError("Split percentage sizes must not exceed 100%");
748
- const ideals = new Array<number>(childCount).fill(0);
749
- let absoluteUsed = 0;
750
- for (let index = 0; index < parsed.length; index += 1) {
751
- const size = parsed[index];
752
- if (size.type === "absolute") {
753
- ideals[index] = size.value;
754
- absoluteUsed += size.value;
755
- } else if (size.type === "percent") {
756
- ideals[index] = available * (size.value / 100);
757
- }
758
- }
759
- const percentUsed = ideals.reduce((sum, value, index) => sum + (parsed[index].type === "percent" ? value : 0), 0);
760
- const remaining = available - absoluteUsed - percentUsed;
761
- if (remaining < -0.000001) throw new RangeError("Split sizes plus gaps must fit within the major axis");
762
- const frTotal = parsed.reduce((sum, size) => sum + (size.type === "fr" ? size.value : 0), 0);
763
- for (let index = 0; index < parsed.length; index += 1) {
764
- const size = parsed[index];
765
- if (size.type === "fr") ideals[index] = frTotal > 0 ? remaining * (size.value / frTotal) : 0;
766
- }
767
- const allocationTotal = frTotal > 0 ? available : Math.round(ideals.reduce((sum, value) => sum + value, 0));
768
- const allocated = allocateDecimals(ideals, allocationTotal);
769
- if (allocated.reduce((sum, size) => sum + size, 0) > available) throw new RangeError("Split sizes plus gaps must fit within the major axis");
770
- return allocated;
771
- }
772
-
773
- function isDirectSplitLayoutContainer(node: TerminalNode): node is TerminalElementNode {
774
- return node.type === "element" && (node.tag === "terminal-pane" || node.tag === "terminal-box" || node.tag === "terminal-view" || node.tag === "terminal-scroll");
775
- }
776
-
777
- function renderSplitChildFrame(child: TerminalNode, cellWidth: number, cellHeight: number, context?: TerminalRenderContext): TerminalFrame {
778
- const childContext = { cols: cellWidth, rows: cellHeight, theme: context?.theme };
779
- if (!isDirectSplitLayoutContainer(child)) {
780
- return renderTerminalFrame(child, childContext);
781
- }
782
-
783
- const props = { ...child.props };
784
- if (typeof props.width === "undefined" && props.fill !== true) {
785
- props.width = cellWidth;
786
- }
787
- if (typeof props.height === "undefined" && props.fill !== true) {
788
- props.height = cellHeight;
789
- }
790
-
791
- return renderTerminalFrame({ ...child, props }, childContext);
792
- }
793
-
794
- function renderSplitFrame(node: TerminalElementNode, context?: TerminalRenderContext): TerminalFrame {
795
- const width = resolveSplitDimension(node, "width", context);
796
- const height = resolveSplitDimension(node, "height", context);
797
- const breakpoint = resolveSplitBreakpoint(node, width, height);
798
- const gap = nonNegativeInteger(breakpoint?.gap ?? node.props.gap, "Split gap");
799
- const direction = (breakpoint?.direction ?? node.props.direction) === "column" ? "column" : "row";
800
- const childCount = node.children.length;
801
-
802
- if (!childCount) {
803
- const frame = createFrame(new Array<string>(height).fill(" ".repeat(width)));
804
- return node.props.id && isFocusable(node) ? addFocusableHitbox(frame, node as TerminalFocusNode) : frame;
805
- }
806
-
807
- const majorSize = direction === "row" ? width : height;
808
- const available = majorSize - gap * (childCount - 1);
809
- if (available < 0) {
810
- throw new RangeError("Split gap leaves insufficient space for children");
811
- }
812
-
813
- const sizes = resolveSplitSizes(breakpoint?.sizes ?? node.props.sizes, childCount, available);
814
-
815
- const frames: TerminalFrame[] = [];
816
- for (let index = 0; index < childCount; index += 1) {
817
- const cellWidth = direction === "row" ? sizes[index] : width;
818
- const cellHeight = direction === "row" ? height : sizes[index];
819
- if (cellWidth <= 0 || cellHeight <= 0) {
820
- continue;
821
- }
822
- frames.push(constrainFrame(renderSplitChildFrame(node.children[index], cellWidth, cellHeight, context), { width: cellWidth, height: cellHeight, expandFullFrameSpans: true }));
823
- }
824
-
825
- const frame = frames.length
826
- ? direction === "row" ? mergeHorizontal(frames, { gap }) : mergeVertical(frames, { gap })
827
- : createFrame(new Array<string>(height).fill(" ".repeat(width)));
828
- const constrained = constrainFrame(frame, { width, height });
829
- return node.props.id && isFocusable(node) ? addFocusableHitbox(constrained, node as TerminalFocusNode) : constrained;
830
- }
831
-
832
- function hasDirectFixedChildren(node: TerminalElementNode) {
833
- return node.children.some((child) => child.type === "element" && child.tag === "terminal-fixed");
834
- }
835
-
836
- function hasDirectOverlayChildren(node: TerminalElementNode) {
837
- return node.children.some((child) => child.type === "element" && child.tag === "terminal-overlay");
838
- }
839
-
840
- function splitOverlayChildren(children: TerminalNode[]) {
841
- const baseChildren: TerminalNode[] = [];
842
- const overlays: TerminalElementNode[] = [];
843
-
844
- for (const child of children) {
845
- if (child.type === "element" && child.tag === "terminal-overlay") {
846
- overlays.push(child);
847
- } else {
848
- baseChildren.push(child);
849
- }
850
- }
851
-
852
- return { baseChildren, overlays };
853
- }
854
-
855
- function renderBodyFrame(children: TerminalNode[], props: TerminalElementNode["props"], context?: TerminalRenderContext) {
856
- const direction = props.direction === "row" ? "row" : "column";
857
- const gap = numeric(props.gap, 0);
858
- const frames = children.map((child) => renderTerminalFrame(child, context));
859
- return direction === "row" ? mergeHorizontal(frames, { gap }) : mergeVertical(frames, { gap });
860
- }
861
-
862
- function renderFixedChildFrame(node: TerminalElementNode, width: number, height: number) {
863
- return constrainFrame(renderBodyFrame(node.children, {}, { cols: width, rows: height }), { width, height, expandFullFrameSpans: true });
864
- }
865
-
866
- function renderFixedCompositionFrame(node: TerminalElementNode, width: number, height: number): TerminalFrame {
867
- if (!Number.isFinite(width) || !Number.isInteger(width) || width <= 0 || !Number.isFinite(height) || !Number.isInteger(height) || height <= 0) {
868
- throw new RangeError("Fixed composition requires exact positive parent dimensions");
869
- }
870
-
871
- const fixedNodes: Record<"top" | "bottom" | "left" | "right", TerminalElementNode[]> = { top: [], bottom: [], left: [], right: [] };
872
- const bodyChildren: TerminalNode[] = [];
873
-
874
- for (const child of node.children) {
875
- if (child.type === "element" && child.tag === "terminal-fixed") {
876
- fixedNodes[fixedPosition(child.props.position)].push(child);
877
- } else if (child.type === "element" && child.tag === "terminal-overlay") {
878
- continue;
879
- } else {
880
- bodyChildren.push(child);
881
- }
882
- }
883
-
884
- const topSize = fixedNodes.top.reduce((total, fixed) => total + positiveInteger(fixed.props.size, "Fixed size"), 0);
885
- const bottomSize = fixedNodes.bottom.reduce((total, fixed) => total + positiveInteger(fixed.props.size, "Fixed size"), 0);
886
- const middleHeight = height - topSize - bottomSize;
887
- if (middleHeight < 0) {
888
- throw new RangeError("Fixed top and bottom regions exceed parent height");
889
- }
890
-
891
- const leftSize = fixedNodes.left.reduce((total, fixed) => total + positiveInteger(fixed.props.size, "Fixed size"), 0);
892
- const rightSize = fixedNodes.right.reduce((total, fixed) => total + positiveInteger(fixed.props.size, "Fixed size"), 0);
893
- const bodyWidth = width - leftSize - rightSize;
894
- if (bodyWidth < 0) {
895
- throw new RangeError("Fixed left and right regions exceed parent width");
896
- }
897
-
898
- const topFrames = fixedNodes.top.map((fixed) => renderFixedChildFrame(fixed, width, positiveInteger(fixed.props.size, "Fixed size")));
899
- const bottomFrames = fixedNodes.bottom.map((fixed) => renderFixedChildFrame(fixed, width, positiveInteger(fixed.props.size, "Fixed size")));
900
- const middleFrames: TerminalFrame[] = [];
901
-
902
- if (middleHeight > 0) {
903
- const leftFrames = fixedNodes.left.map((fixed) => renderFixedChildFrame(fixed, positiveInteger(fixed.props.size, "Fixed size"), middleHeight));
904
- const rightFrames = fixedNodes.right.map((fixed) => renderFixedChildFrame(fixed, positiveInteger(fixed.props.size, "Fixed size"), middleHeight));
905
- const bodyFrames = bodyWidth > 0
906
- ? [constrainFrame(renderBodyFrame(bodyChildren, node.props, { cols: bodyWidth, rows: middleHeight }), { width: bodyWidth, height: middleHeight, expandFullRowSpans: true })]
907
- : [];
908
-
909
- middleFrames.push(constrainFrame(mergeHorizontal([...leftFrames, ...bodyFrames, ...rightFrames]), { width, height: middleHeight, expandFullRowSpans: true }));
910
- }
911
-
912
- const frame = mergeVertical([...topFrames, ...middleFrames, ...bottomFrames]);
913
- return constrainFrame(frame, { width, height, expandFullFrameSpans: true });
914
- }
915
-
916
- function renderStandaloneFixedFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
917
- const position = fixedPosition(node.props.position);
918
- const size = positiveInteger(node.props.size, "Fixed size");
919
- const frame = renderBodyFrame(node.children, {}, context);
920
- return position === "top" || position === "bottom"
921
- ? constrainFrame(frame, { height: size })
922
- : constrainFrame(frame, { width: size });
923
- }
924
-
925
- function overlayMarginValue(value: unknown, axisSize: number, label: string) {
926
- if (typeof value === "number") {
927
- if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
928
- throw new RangeError(`${label} must be a non-negative finite integer or percentage string`);
929
- }
930
- return value;
931
- }
932
-
933
- if (typeof value === "string") {
934
- const match = value.match(/^(\d+(?:\.\d+)?)%$/);
935
- if (!match) {
936
- throw new RangeError(`${label} must be a non-negative finite integer or percentage string`);
937
- }
938
-
939
- const percent = Number(match[1]);
940
- if (!Number.isFinite(percent) || percent < 0) {
941
- throw new RangeError(`${label} must be a non-negative finite integer or percentage string`);
942
- }
943
-
944
- return Math.round(axisSize * percent / 100);
945
- }
946
-
947
- throw new RangeError(`${label} must be a non-negative finite integer or percentage string`);
948
- }
949
-
950
- function overlayMargins(margin: unknown, width: number, height: number) {
951
- if (typeof margin === "number" || typeof margin === "string") {
952
- const x = overlayMarginValue(margin, width, "Overlay margin");
953
- const y = overlayMarginValue(margin, height, "Overlay margin");
954
- return { x, y };
955
- }
956
-
957
- if (margin && typeof margin === "object" && !Array.isArray(margin)) {
958
- const axes = margin as { x?: unknown; y?: unknown };
959
- return {
960
- x: overlayMarginValue(axes.x, width, "Overlay margin x"),
961
- y: overlayMarginValue(axes.y, height, "Overlay margin y")
962
- };
963
- }
964
-
965
- throw new RangeError("Overlay margin is required");
966
- }
967
-
968
- function overlayGeometry(node: TerminalElementNode, width: number, height: number) {
969
- const margin = overlayMargins(node.props.margin, width, height);
970
- const overlayWidth = width - margin.x * 2;
971
- const overlayHeight = height - margin.y * 2;
972
-
973
- if (overlayWidth < 1 || overlayHeight < 1) {
974
- throw new RangeError("Overlay margin leaves no renderable area");
975
- }
976
-
977
- return {
978
- x: margin.x + 1,
979
- y: margin.y + 1,
980
- width: overlayWidth,
981
- height: overlayHeight
982
- };
983
- }
984
-
985
- function renderOverlayChildFrame(node: TerminalElementNode, width: number, height: number, context?: TerminalRenderContext) {
986
- const geometry = overlayGeometry(node, width, height);
987
- let frame = constrainFrame(renderBodyFrame(node.children, {}, { cols: geometry.width, rows: geometry.height, theme: context?.theme }), { width: geometry.width, height: geometry.height, expandFullRowSpans: true });
988
- frame = addContainerStyleSpans(frame, node, resolveNodeStyle(node, context));
989
- if (node.props.id && isFocusable(node)) {
990
- frame = addFocusableHitbox(frame, node as TerminalFocusNode);
991
- }
992
- return { frame, geometry };
993
- }
994
-
995
- function orderedDirectOverlays(overlays: TerminalElementNode[]) {
996
- return overlays.map((overlay, sourceOrder) => ({ overlay, sourceOrder })).sort((a, b) => a.sourceOrder - b.sourceOrder).map(({ overlay }) => overlay);
997
- }
998
-
999
- function applyDirectOverlays(base: TerminalFrame, overlays: TerminalElementNode[], context?: TerminalRenderContext) {
1000
- let frame = base;
1001
- const width = Math.max(1, getFrameWidth(base));
1002
- const height = Math.max(1, getFrameHeight(base));
1003
- for (const overlay of orderedDirectOverlays(overlays)) {
1004
- const rendered = renderOverlayChildFrame(overlay, width, height, context);
1005
- frame = overlayFrame(frame, rendered.frame, rendered.geometry);
1006
- }
1007
- return frame;
1008
- }
1009
-
1010
- function renderScreenFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
1011
- const { baseChildren, overlays } = splitOverlayChildren(node.children);
1012
- let base: TerminalFrame;
1013
-
1014
- if (hasDirectFixedChildren(node)) {
1015
- if (!context) {
1016
- throw new RangeError("Screen with direct Fixed children requires exact render context dimensions");
1017
- }
1018
- if (node.props.title) {
1019
- const title = constrainFrame(createFrame([plainText(node.props.title)]), { width: context.cols, height: 1 });
1020
- const remainingRows = context.rows - 1;
1021
- base = remainingRows > 0
1022
- ? constrainFrame(mergeVertical([title, renderFixedCompositionFrame(node, context.cols, remainingRows)]), { width: context.cols, height: context.rows })
1023
- : constrainFrame(title, { width: context.cols, height: context.rows });
1024
- } else {
1025
- base = renderFixedCompositionFrame(node, context.cols, context.rows);
1026
- }
1027
- } else {
1028
- const parts: TerminalFrame[] = [];
1029
- if (node.props.title) {
1030
- parts.push(createFrame([plainText(node.props.title)]));
1031
- }
1032
- const childContext = context && node.props.title
1033
- ? { cols: context.cols, rows: context.rows - 1, theme: context.theme }
1034
- : context;
1035
- if (!context || !node.props.title || context.rows > 1) {
1036
- parts.push(...baseChildren.map((child) => renderTerminalFrame(child, childContext)));
1037
- }
1038
- base = mergeVertical(parts);
1039
- if (context && overlays.length) {
1040
- base = constrainFrame(base, { width: context.cols, height: context.rows, expandFullRowSpans: true });
1041
- }
1042
- }
1043
-
1044
- return overlays.length ? applyDirectOverlays(base, overlays, context) : base;
1045
- }
1046
-
1047
- function renderPaneFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
1048
- const { baseChildren, overlays } = splitOverlayChildren(node.children);
1049
- const dimensions = resolveBlockLayoutDimensions(node, "Pane", context, { exactHeightFromContext: hasDirectFixedChildren(node) });
1050
- let frame: TerminalFrame;
1051
-
1052
- if (hasDirectFixedChildren(node)) {
1053
- const width = decoratedContentDimension(node, "width", dimensions.width ?? positiveInteger(node.props.width, "Pane width"), context);
1054
- const height = decoratedContentDimension(node, "height", dimensions.height ?? positiveInteger(node.props.height, "Pane height"), context);
1055
- frame = renderFixedCompositionFrame(node, width, height);
1056
- frame = decorateContainerFrame(frame, node, { constrain: true, ...dimensions }, context);
1057
- } else {
1058
- const childContext = resolveContainerChildContext(node, dimensions, context);
1059
- frame = renderBodyFrame(baseChildren, node.props, childContext);
1060
- frame = decorateContainerFrame(frame, node, { constrain: true, ...dimensions }, context);
1061
- }
1062
-
1063
- return overlays.length ? applyDirectOverlays(frame, overlays, context) : frame;
1064
- }
1065
-
1066
- function renderStandaloneOverlayFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
1067
- if (!context) {
1068
- throw new RangeError("Standalone Overlay requires exact render context dimensions");
1069
- }
1070
-
1071
- const rendered = renderOverlayChildFrame(node, context.cols, context.rows, context);
1072
- return rendered.frame;
1073
- }
1074
-
1075
- function renderLogViewFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
1076
- const dimensions = resolveLayoutDimensions(node, "LogView", context);
1077
- const entries = Array.isArray(node.props.entries) ? node.props.entries : [];
1078
- const lines: string[] = [];
1079
-
1080
- if (entries.length === 0) {
1081
- lines.push(plainText(node.props.emptyText ?? ""));
1082
- } else {
1083
- for (let index = 0; index < entries.length; index += 1) {
1084
- const entry = entries[index];
1085
- const value = typeof node.props.renderEntry === "function"
1086
- ? node.props.renderEntry(entry, index)
1087
- : entry?.content ?? "";
1088
- lines.push(...plainText(value).split("\n"));
1089
- }
1090
- }
1091
-
1092
- const resolved = resolveNodeStyle(node, context);
1093
- const layoutStyle = resolveNodeLayoutStyle(node, context);
1094
- const padding = normalizeSpacing(layoutStyle?.padding ?? node.props.padding, "LogView padding");
1095
- const border = normalizeBorder(layoutStyle?.border);
1096
- const horizontalDecoration = padding.left + padding.right + (border.left ? 1 : 0) + (border.right ? 1 : 0);
1097
- const verticalDecoration = padding.top + padding.bottom + (border.top ? 1 : 0) + (border.bottom ? 1 : 0);
1098
- const innerWidth = typeof dimensions.width === "undefined" ? undefined : dimensions.width - horizontalDecoration;
1099
- const innerHeight = typeof dimensions.height === "undefined" ? undefined : dimensions.height - verticalDecoration;
1100
-
1101
- let frame = createFrame(lines);
1102
- if ((typeof innerWidth === "number" && innerWidth <= 0) || (typeof innerHeight === "number" && innerHeight <= 0)) {
1103
- frame = createFrame([""]);
1104
- } else {
1105
- if (typeof innerHeight !== "undefined") {
1106
- const offset = node.props.followTail === true ? Math.max(0, getFrameHeight(frame) - innerHeight) : 0;
1107
- frame = cropFrame(frame, offset, innerHeight);
1108
- while (getFrameHeight(frame) < innerHeight) {
1109
- frame = createFrame([...frame.lines, ""], frame.hitboxes, frame.cursor, frame.spans);
1110
- }
1111
- }
1112
- if (typeof innerWidth !== "undefined") {
1113
- frame = constrainFrame(frame, { width: innerWidth, height: innerHeight, expandFullFrameSpans: true });
1114
- }
1115
- }
1116
- frame = padFrameSides(frame, padding);
1117
- frame = addBorder(frame, border);
1118
- if (typeof dimensions.width !== "undefined") {
1119
- frame = constrainFrame(frame, { width: dimensions.width, height: dimensions.height, expandFullFrameSpans: true });
1120
- } else if (typeof dimensions.height !== "undefined") {
1121
- frame = cropFrame(frame, 0, dimensions.height);
1122
- while (typeof dimensions.height !== "undefined" && getFrameHeight(frame) < dimensions.height) {
1123
- frame = createFrame([...frame.lines, ""], frame.hitboxes, frame.cursor, frame.spans);
1124
- }
1125
- }
1126
- frame = addContainerStyleSpans(frame, node, resolved);
1127
- if (node.props.id && isFocusable(node)) {
1128
- frame = addFocusableHitbox(frame, node as TerminalFocusNode);
1129
- }
1130
- return frame;
1131
- }
1132
-
1133
- function renderSeparatedRowFrame(frames: TerminalFrame[], separator = " | ") {
1134
- if (!frames.length) {
1135
- return createFrame([""]);
1136
- }
1137
-
1138
- const height = frames.reduce((max, frame) => Math.max(max, getFrameHeight(frame)), 0);
1139
- const widths = frames.map(getFrameWidth);
1140
- const lines = new Array<string>(height).fill("");
1141
- const hitboxes = [] as TerminalFrame["hitboxes"];
1142
- const spans = [] as TerminalStyleSpan[];
1143
- let cursor: TerminalFrame["cursor"] = null;
1144
- let xOffset = 0;
1145
-
1146
- for (let index = 0; index < frames.length; index += 1) {
1147
- const frame = frames[index];
1148
- const width = widths[index];
1149
- for (let row = 0; row < height; row += 1) {
1150
- lines[row] += padEndTerminalCells(frame.lines[row] || "", width);
1151
- if (index < frames.length - 1) {
1152
- lines[row] += separator;
1153
- }
1154
- }
1155
- const shifted = shiftFrame(fitFrame(frame, width, height, { expandFullFrameSpans: true }), xOffset, 0);
1156
- hitboxes.push(...shifted.hitboxes);
1157
- spans.push(...shifted.spans);
1158
- if (!cursor && frame.cursor) {
1159
- cursor = { x: frame.cursor.x + xOffset, y: frame.cursor.y };
1160
- }
1161
- xOffset += width + (index < frames.length - 1 ? terminalCellWidth(separator) : 0);
1162
- }
1163
-
1164
- return createFrame(lines, hitboxes, cursor, spans);
1165
- }
1166
-
1167
- function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderContext): TerminalFrame {
1168
- notifyLayoutContextProbe(node, context);
1169
-
1170
- switch (node.tag) {
1171
- case "terminal-screen": {
1172
- return renderScreenFrame(node, context);
1173
- }
1174
- case "terminal-box":
1175
- case "terminal-view":
1176
- case "terminal-pane":
1177
- case "terminal-scroll": {
1178
- let frame: TerminalFrame;
1179
-
1180
- if (node.tag === "terminal-pane" && (hasDirectFixedChildren(node) || hasDirectOverlayChildren(node))) {
1181
- return renderPaneFrame(node, context);
1182
- }
1183
-
1184
- const label = node.tag === "terminal-box" ? "Box" : node.tag === "terminal-view" ? "View" : node.tag === "terminal-scroll" ? "ScrollView" : "Pane";
1185
- const dimensions = node.tag === "terminal-scroll"
1186
- ? resolveLayoutDimensions(node, label, context)
1187
- : resolveBlockLayoutDimensions(node, label, context);
1188
-
1189
- const childContext = resolveContainerChildContext(node, dimensions, context);
1190
- frame = renderBodyFrame(node.children, node.props, childContext);
1191
-
1192
- frame = decorateContainerFrame(frame, node, { constrain: node.tag === "terminal-box" || node.tag === "terminal-view" || node.tag === "terminal-pane", ...dimensions }, context);
1193
-
1194
- if (node.tag === "terminal-scroll") {
1195
- const offset = numeric(node.props.__scrollOffset, 0);
1196
- if (typeof dimensions.width !== "undefined") {
1197
- frame = constrainFrame(frame, { width: dimensions.width, expandFullFrameSpans: true });
1198
- }
1199
- const height = numeric(dimensions.height ?? node.props.height, getFrameHeight(frame));
1200
- frame = cropFrame(frame, offset, height || getFrameHeight(frame));
1201
- const highlightRows = Array.isArray(node.props.highlightRows) ? node.props.highlightRows.map((value: unknown) => Number(value)) : [];
1202
- const hoveredRow = typeof node.props.__hoveredRow === "number" ? Number(node.props.__hoveredRow) : -1;
1203
- const spans = frame.spans.slice();
1204
- for (let index = 0; index < frame.lines.length; index += 1) {
1205
- const row = index + 1;
1206
- const width = terminalCellWidth(frame.lines[index]) + 1;
1207
- if (highlightRows.includes(row)) {
1208
- spans.push({ kind: "highlight", x1: 1, x2: width, y: row });
1209
- }
1210
- if (hoveredRow === row) {
1211
- spans.push({ kind: "hover", x1: 1, x2: width, y: row });
1212
- }
1213
- }
1214
- frame = createFrame(frame.lines, frame.hitboxes, frame.cursor, spans);
1215
- }
1216
-
1217
- return frame;
1218
- }
1219
- case "terminal-split":
1220
- return renderSplitFrame(node, context);
1221
- case "terminal-fixed":
1222
- return renderStandaloneFixedFrame(node, context);
1223
- case "terminal-overlay":
1224
- return renderStandaloneOverlayFrame(node, context);
1225
- case "terminal-log-view":
1226
- return renderLogViewFrame(node, context);
1227
- case "terminal-list": {
1228
- const items = Array.isArray(node.props.items) ? node.props.items : [];
1229
- const activeIndex = clampListIndex(numeric(node.props.__activeIndex ?? node.props.__selectedIndex, 0), items.length);
1230
- const selectedIndex = typeof node.props.__selectedIndex === "number" ? clampListIndex(Number(node.props.__selectedIndex), items.length) : null;
1231
- const hoveredIndex = typeof node.props.__hoveredIndex === "number" ? Number(node.props.__hoveredIndex) : -1;
1232
- const range = listVirtualRange(node, items.length, context);
1233
- const layoutStyle = resolveLayoutStyle("list.base", node, context);
1234
- const padding = normalizeSpacing(layoutStyle.padding, "List padding");
1235
- const border = normalizeBorder(layoutStyle.border);
1236
- const horizontalDecoration = padding.left + padding.right + (border.left ? 1 : 0) + (border.right ? 1 : 0);
1237
- const wrapWidth = typeof context?.cols === "number" ? Math.max(1, context.cols - horizontalDecoration) : 1;
1238
- const visibleLines: string[] = [];
1239
- const itemIndexes: number[] = [];
1240
- const childHitboxes: TerminalHitbox[] = [];
1241
- for (let index = range.start; index < range.end; index += 1) {
1242
- const item = items[index];
1243
- const itemFrame = renderListItemFrame(node, item, index, index - range.visibleStart, activeIndex, selectedIndex, wrapWidth, context);
1244
- const rowOffset = visibleLines.length;
1245
- visibleLines.push(...itemFrame.lines);
1246
- for (let row = 0; row < itemFrame.lines.length; row += 1) {
1247
- itemIndexes.push(index);
1248
- }
1249
- childHitboxes.push(...shiftFrame(itemFrame, 0, rowOffset).hitboxes);
1250
- }
1251
- if (!visibleLines.length) {
1252
- visibleLines.push("");
1253
- itemIndexes.push(0);
1254
- }
1255
-
1256
- const frameHeight = node.props.virtualized && node.props.wrap === true
1257
- ? positiveDimension(node.props.height, "height") ?? (typeof context?.rows === "number" ? Math.max(1, context.rows) : undefined)
1258
- : undefined;
1259
- const contentHeight = typeof frameHeight === "number"
1260
- ? Math.max(1, frameHeight - padding.top - padding.bottom - (border.top ? 1 : 0) - (border.bottom ? 1 : 0))
1261
- : visibleLines.length;
1262
- let visibleLineStart = 0;
1263
- if (node.props.virtualized && node.props.wrap === true && visibleLines.length > contentHeight) {
1264
- const activeLineIndex = itemIndexes.findIndex((sourceIndex) => sourceIndex === activeIndex);
1265
- if (activeLineIndex >= contentHeight) {
1266
- visibleLineStart = activeLineIndex - contentHeight + 1;
1267
- }
1268
- }
1269
- const frameLines = visibleLines.slice(visibleLineStart, visibleLineStart + contentHeight);
1270
- const frameItemIndexes = itemIndexes.slice(visibleLineStart, visibleLineStart + contentHeight);
1271
- const frameChildHitboxes = childHitboxes
1272
- .filter((box) => box.y2 > visibleLineStart && box.y1 <= visibleLineStart + contentHeight)
1273
- .map((box) => ({
1274
- ...box,
1275
- y1: Math.max(1, box.y1 - visibleLineStart),
1276
- y2: Math.min(contentHeight, box.y2 - visibleLineStart),
1277
- contentY: typeof box.contentY === "number" ? Math.max(1, box.contentY - visibleLineStart) : undefined
1278
- }));
1279
-
1280
- const decorated = addBorder(padFrameSides(createFrame(frameLines, frameChildHitboxes), padding), border);
1281
- const width = Math.max(1, getFrameWidth(decorated));
1282
- const height = Math.max(1, getFrameHeight(decorated));
1283
- const itemY = 1 + (border.top ? 1 : 0) + padding.top;
1284
- const spans: TerminalStyleSpan[] = [];
1285
- for (let index = 0; index < frameLines.length; index += 1) {
1286
- const sourceIndex = frameItemIndexes[index];
1287
- const y = itemY + index;
1288
- spans.push(markFullRowSpan({ kind: "list.base", x1: 1, x2: width + 1, y }));
1289
- if (selectedIndex !== null && selectedIndex !== activeIndex && sourceIndex === selectedIndex) {
1290
- spans.push(markFullRowSpan({ kind: "list.selected", x1: 1, x2: width + 1, y }));
1291
- }
1292
- if (sourceIndex === activeIndex) {
1293
- spans.push(markFullRowSpan({ kind: "list.current", x1: 1, x2: width + 1, y }));
1294
- }
1295
- if (sourceIndex === hoveredIndex) {
1296
- spans.push(markFullRowSpan({ kind: "list.hover", x1: 1, x2: width + 1, y }));
1297
- }
1298
- }
1299
- const listHitboxes: TerminalHitbox[] = [];
1300
- const itemHitboxes: TerminalHitbox[] = [];
1301
- if (node.props.id) {
1302
- listHitboxes.push({
1303
- id: node.props.id,
1304
- tag: node.tag,
1305
- x1: 1,
1306
- x2: width,
1307
- y1: 1,
1308
- y2: Math.min(height, typeof frameHeight === "number" ? frameHeight : height),
1309
- itemOffset: range.start,
1310
- itemIndexes: frameItemIndexes,
1311
- contentY: itemY
1312
- });
1313
- let itemStart = 0;
1314
- while (itemStart < frameItemIndexes.length) {
1315
- const sourceIndex = frameItemIndexes[itemStart];
1316
- let itemEnd = itemStart;
1317
- while (itemEnd + 1 < frameItemIndexes.length && frameItemIndexes[itemEnd + 1] === sourceIndex) {
1318
- itemEnd += 1;
1319
- }
1320
- itemHitboxes.push({
1321
- id: node.props.id,
1322
- tag: node.tag,
1323
- x1: 1,
1324
- x2: width,
1325
- y1: itemY + itemStart,
1326
- y2: itemY + itemEnd,
1327
- itemOffset: range.start,
1328
- __listItemIndex: sourceIndex,
1329
- itemIndexes: new Array(itemEnd - itemStart + 1).fill(sourceIndex)
1330
- });
1331
- itemStart = itemEnd + 1;
1332
- }
1333
- }
1334
- const frame = createFrame(decorated.lines, [...listHitboxes, ...itemHitboxes, ...decorated.hitboxes], decorated.cursor, spans);
1335
- const styled = addFullFrameSpans(frame, resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1336
- return typeof frameHeight === "number" ? constrainFrame(styled, { height: frameHeight, expandFullFrameSpans: true }) : styled;
1337
- }
1338
- case "terminal-table":
1339
- return renderTableFrame(node, context);
1340
- case "terminal-row":
1341
- return renderSeparatedRowFrame(node.children.map((child) => renderTerminalFrame(child, context)), plainText(node.props.separator || " | "));
1342
- case "terminal-td": {
1343
- const frame = mergeVertical(node.children.map((child) => renderTerminalFrame(child, context)));
1344
- const resolved = resolveNodeStyle(node, context);
1345
- const layoutStyle = resolveNodeLayoutStyle(node, context);
1346
- const padding = normalizeSpacing(layoutStyle?.padding ?? node.props.padding, "Td padding");
1347
- const border = normalizeBorder(layoutStyle?.border);
1348
- return addFullFrameSpans(addBorder(padFrameSides(frame, padding), border), resolved.spanKinds);
1349
- }
1350
- case "terminal-text": {
1351
- const value = typeof node.props.value !== "undefined" ? plainText(node.props.value) : plainText(node.children.map(textContent).join(""));
1352
- const frame = createFrame(value.split("\n"));
1353
- return addFullFrameSpans(frame, resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1354
- }
1355
- case "terminal-input": {
1356
- const value = typeof node.props.value !== "undefined" ? node.props.value : node.props.placeholder || "";
1357
- const stringValue = plainText(value);
1358
- const displayValue = !node.props.__focused && stringValue.length === 0 && typeof node.props.placeholder !== "undefined"
1359
- ? plainText(node.props.placeholder)
1360
- : stringValue;
1361
- const layoutStyle = resolveLayoutStyle("input.base", node, context);
1362
- const inputPadding = normalizeSpacing(layoutStyle.padding, "Input padding");
1363
- const inputBorder = normalizeBorder(layoutStyle.border);
1364
- const textStartX = (inputBorder.left ? 1 : 0) + inputPadding.left + 1;
1365
- if (!node.props.__focused) {
1366
- const line = `${" ".repeat(inputPadding.left)}${displayValue}${" ".repeat(inputPadding.right)}`;
1367
- const decorated = addBorder(createFrame([line]), inputBorder);
1368
- const width = Math.max(1, getFrameWidth(decorated));
1369
- const height = Math.max(1, getFrameHeight(decorated));
1370
- const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, ...interactiveTextMetadata(stringValue) }] : [];
1371
- const spans = fullFrameSpans(["input.base"], width, height);
1372
- return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1373
- }
1374
-
1375
- const rendered = renderInputLine(stringValue, node.props.__inputState || { cursor: stringValue.length, anchor: stringValue.length }, inputPadding);
1376
- const decorated = addBorder(createFrame([rendered.line], [], rendered.cursor, rendered.spans), inputBorder);
1377
- const width = Math.max(1, getFrameWidth(decorated));
1378
- const height = Math.max(1, getFrameHeight(decorated));
1379
- const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, ...interactiveTextMetadata(stringValue) }] : [];
1380
- const spans = [...fullFrameSpans(["input.base", "input.focus"], width, height), ...decorated.spans];
1381
- return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1382
- }
1383
- case "terminal-editor":
1384
- return addFullFrameSpans(renderEditorFrame(node, context), resolveNodeStyle(node, context).spanKinds);
1385
- case "terminal-button": {
1386
- const label = typeof node.props.label !== "undefined" ? plainText(node.props.label) : plainText(node.children.map(textContent).join(""));
1387
- const layoutStyle = resolveLayoutStyle("button.base", node, context);
1388
- const decorated = decoratedControlFrame([String(label)], layoutStyle);
1389
- const width = Math.max(1, getFrameWidth(decorated));
1390
- const height = Math.max(1, getFrameHeight(decorated));
1391
- const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, __pressHandler: typeof node.props.onpress === "function" ? node.props.onpress : undefined }] : [];
1392
- const kinds = ["button.base", ...nodeStates(node).map((state) => `button.${state}`)];
1393
- const spans = fullFrameSpans(kinds, width, height);
1394
- return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1395
- }
1396
- default:
1397
- return mergeVertical(node.children.map((child) => renderTerminalFrame(child, context)));
1398
- }
1399
- }
1400
-
1401
54
  export function renderTerminalFrame(node: TerminalNode, context?: TerminalRenderContext): TerminalFrame {
1402
- if (node.type === "text") {
1403
- return createFrame([plainText(node.value)]);
1404
- }
1405
- return renderElementFrame(node, context);
55
+ return publicTerminalFrame(renderTerminalFrameInternal(node, context));
1406
56
  }
1407
57
 
1408
58
  export function renderTerminalNode(node: TerminalNode, context?: TerminalRenderContext): string {