@valyrianjs/terminal 0.2.3 → 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/ansi.ts CHANGED
@@ -29,12 +29,28 @@ function spanLayerPriority(span: TerminalStyleSpan) {
29
29
  return span.kind === "focus" ? 0 : 1;
30
30
  }
31
31
 
32
- function lineSpans(spans: TerminalStyleSpan[], y: number) {
32
+ type RowSpanEntry = {
33
+ span: TerminalStyleSpan;
34
+ index: number;
35
+ };
36
+
37
+ function compareSpanStart(a: RowSpanEntry, b: RowSpanEntry) {
38
+ return a.span.x1 - b.span.x1 || compareSpanPaintOrder(a, b) || b.span.x2 - a.span.x2;
39
+ }
40
+
41
+ function compareSpanPaintOrder(a: RowSpanEntry, b: RowSpanEntry) {
42
+ return spanLayerPriority(a.span) - spanLayerPriority(b.span) || a.index - b.index;
43
+ }
44
+
45
+ function lineSpanEntries(spans: TerminalStyleSpan[], y: number) {
33
46
  return spans
34
47
  .map((span, index) => ({ span, index }))
35
48
  .filter(({ span }) => span.y === y)
36
- .sort((a, b) => a.span.x1 - b.span.x1 || spanLayerPriority(a.span) - spanLayerPriority(b.span) || b.span.x2 - a.span.x2 || a.index - b.index)
37
- .map(({ span }) => span);
49
+ .sort(compareSpanStart);
50
+ }
51
+
52
+ function lineSpans(spans: TerminalStyleSpan[], y: number) {
53
+ return lineSpanEntries(spans, y).map(({ span }) => span);
38
54
  }
39
55
 
40
56
  function tokenStyle(token: ReturnType<typeof resolveTerminalStyleToken>) {
@@ -95,65 +111,110 @@ function colorAnsi(value: string | undefined, background: boolean) {
95
111
  return `\u001b[${background ? 48 : 38};2;${red};${green};${blue}m`;
96
112
  }
97
113
 
98
- function renderAnsiLine(line: string, spans: TerminalStyleSpan[], y: number, theme?: TerminalTheme) {
99
- let output = "";
100
- let visibleColumn = 1;
101
- const rowSpans = lineSpans(spans, y);
102
- let activeSpanIndex = 0;
114
+ function sameActiveAnsiStack(previous: RowSpanEntry[], next: RowSpanEntry[]) {
115
+ if (previous.length !== next.length) {
116
+ return false;
117
+ }
118
+ return previous.every((entry, index) => entry.index === next[index]?.index);
119
+ }
103
120
 
104
- for (const grapheme of terminalGraphemes(line)) {
105
- while (rowSpans[activeSpanIndex]?.x1 === visibleColumn) {
106
- output += spanAnsiOpen(rowSpans[activeSpanIndex], theme);
107
- activeSpanIndex += 1;
108
- }
121
+ function spanContains(container: TerminalStyleSpan, contained: TerminalStyleSpan) {
122
+ return container.y === contained.y && container.x1 <= contained.x1 && container.x2 >= contained.x2 && (container.x1 < contained.x1 || container.x2 > contained.x2);
123
+ }
109
124
 
110
- if (grapheme === "|") {
111
- continue;
112
- }
125
+ function hasLaterContainedSpan(entry: RowSpanEntry, rowEntries: RowSpanEntry[]) {
126
+ return rowEntries.some((candidate) => candidate.index > entry.index && spanContains(entry.span, candidate.span));
127
+ }
113
128
 
114
- output += grapheme;
115
- visibleColumn += terminalCellWidth(grapheme);
129
+ function compareActiveSpanPaintOrder(rowEntries: RowSpanEntry[], a: RowSpanEntry, b: RowSpanEntry) {
130
+ const priority = spanLayerPriority(a.span) - spanLayerPriority(b.span);
131
+ if (priority !== 0) {
132
+ return priority;
133
+ }
116
134
 
117
- let closedSpan = false;
118
- for (const span of rowSpans) {
119
- if (span.x2 === visibleColumn) {
120
- if (span.kind !== "focus") {
121
- output += spanAnsiClose(span, theme);
122
- closedSpan = true;
123
- }
124
- }
125
- }
126
- for (const span of rowSpans) {
127
- if (span.x2 === visibleColumn && span.kind === "focus") {
128
- output += spanAnsiClose(span, theme);
129
- closedSpan = true;
130
- }
135
+ const aContainsB = spanContains(a.span, b.span);
136
+ const bContainsA = spanContains(b.span, a.span);
137
+ if (aContainsB && !bContainsA) {
138
+ if (a.index < b.index) {
139
+ return -1;
131
140
  }
132
- if (closedSpan && visibleColumn <= terminalCellWidth(line)) {
133
- for (const span of rowSpans) {
134
- if (span.x1 < visibleColumn && span.x2 > visibleColumn) {
135
- output += spanAnsiOpen(span, theme);
136
- }
137
- }
141
+ return hasLaterContainedSpan(a, rowEntries) ? 1 : -1;
142
+ }
143
+ if (bContainsA && !aContainsB) {
144
+ if (b.index < a.index) {
145
+ return 1;
138
146
  }
147
+ return hasLaterContainedSpan(b, rowEntries) ? -1 : 1;
139
148
  }
140
149
 
141
- for (const span of rowSpans) {
142
- if (span.x1 < visibleColumn && span.x2 > visibleColumn) {
143
- if (span.kind !== "focus") {
144
- output += spanAnsiClose(span, theme);
145
- }
150
+ return a.index - b.index;
151
+ }
152
+
153
+ function activeAnsiStack(rowEntries: RowSpanEntry[], column: number) {
154
+ return rowEntries
155
+ .filter(({ span }) => span.x1 <= column && span.x2 > column)
156
+ .sort((a, b) => compareActiveSpanPaintOrder(rowEntries, a, b));
157
+ }
158
+
159
+ function commonAnsiStackPrefixLength(previous: RowSpanEntry[], next: RowSpanEntry[]) {
160
+ const length = Math.min(previous.length, next.length);
161
+ for (let index = 0; index < length; index += 1) {
162
+ if (previous[index]?.index !== next[index]?.index) {
163
+ return index;
146
164
  }
147
165
  }
148
- for (const span of rowSpans) {
149
- if (span.x1 < visibleColumn && span.x2 > visibleColumn && span.kind === "focus") {
150
- output += spanAnsiClose(span, theme);
166
+ return length;
167
+ }
168
+
169
+ function compareAnsiCloseOrder(a: RowSpanEntry, b: RowSpanEntry) {
170
+ const aFocus = a.span.kind === "focus" ? 1 : 0;
171
+ const bFocus = b.span.kind === "focus" ? 1 : 0;
172
+ return aFocus - bFocus || a.index - b.index;
173
+ }
174
+
175
+ function switchAnsiStack(previous: RowSpanEntry[], next: RowSpanEntry[], theme?: TerminalTheme) {
176
+ if (sameActiveAnsiStack(previous, next)) {
177
+ return "";
178
+ }
179
+
180
+ const commonPrefixLength = commonAnsiStackPrefixLength(previous, next);
181
+ const removed = previous.slice(commonPrefixLength).sort(compareAnsiCloseOrder);
182
+ const commonPrefix = previous.slice(0, commonPrefixLength);
183
+ return [
184
+ ...removed.map(({ span }) => spanAnsiClose(span, theme)),
185
+ ...(removed.length > 0 ? commonPrefix.map(({ span }) => spanAnsiOpen(span, theme)) : []),
186
+ ...next.slice(commonPrefixLength).map(({ span }) => spanAnsiOpen(span, theme))
187
+ ].join("");
188
+ }
189
+
190
+ function renderAnsiLineFromColumn(line: string, spans: TerminalStyleSpan[], y: number, startColumn: number, theme?: TerminalTheme) {
191
+ let output = "";
192
+ let visibleColumn = Math.max(1, startColumn);
193
+ const rowEntries = lineSpanEntries(spans, y);
194
+ let currentStack: RowSpanEntry[] = [];
195
+
196
+ for (const grapheme of terminalGraphemes(dropTerminalCells(line, visibleColumn - 1))) {
197
+ const nextStack = activeAnsiStack(rowEntries, visibleColumn);
198
+ output += switchAnsiStack(currentStack, nextStack, theme);
199
+ currentStack = nextStack;
200
+
201
+ if (grapheme === "|") {
202
+ continue;
151
203
  }
204
+
205
+ output += grapheme;
206
+ visibleColumn += terminalCellWidth(grapheme);
152
207
  }
153
208
 
209
+ output += switchAnsiStack(currentStack, [], theme);
210
+
154
211
  return output;
155
212
  }
156
213
 
214
+ function renderAnsiLine(line: string, spans: TerminalStyleSpan[], y: number, theme?: TerminalTheme) {
215
+ return renderAnsiLineFromColumn(line, spans, y, 1, theme);
216
+ }
217
+
157
218
  function hasPlainAffordance(token: ReturnType<typeof resolveTerminalStyleToken>) {
158
219
  return typeof token !== "undefined" && ("plainPrefix" in token || "plainSuffix" in token);
159
220
  }
@@ -253,71 +314,7 @@ function commonPrefixCellWidth(previousLine: string, nextLine: string) {
253
314
  }
254
315
 
255
316
  function renderAnsiLineSuffix(line: string, spans: TerminalStyleSpan[], y: number, startColumn: number, theme?: TerminalTheme) {
256
- let output = "";
257
- let visibleColumn = Math.max(1, startColumn);
258
- const rowSpans = lineSpans(spans, y);
259
- let activeSpanIndex = rowSpans.findIndex((span) => span.x1 >= visibleColumn);
260
- if (activeSpanIndex < 0) {
261
- activeSpanIndex = rowSpans.length;
262
- }
263
-
264
- for (const span of rowSpans) {
265
- if (span.x1 <= visibleColumn && span.x2 > visibleColumn) {
266
- output += spanAnsiOpen(span, theme);
267
- }
268
- }
269
-
270
- for (const grapheme of terminalGraphemes(dropTerminalCells(line, visibleColumn - 1))) {
271
- while (rowSpans[activeSpanIndex]?.x1 === visibleColumn) {
272
- output += spanAnsiOpen(rowSpans[activeSpanIndex], theme);
273
- activeSpanIndex += 1;
274
- }
275
-
276
- if (grapheme === "|") {
277
- continue;
278
- }
279
-
280
- output += grapheme;
281
- visibleColumn += terminalCellWidth(grapheme);
282
-
283
- let closedSpan = false;
284
- for (const span of rowSpans) {
285
- if (span.x2 === visibleColumn) {
286
- if (span.kind !== "focus") {
287
- output += spanAnsiClose(span, theme);
288
- closedSpan = true;
289
- }
290
- }
291
- }
292
- for (const span of rowSpans) {
293
- if (span.x2 === visibleColumn && span.kind === "focus") {
294
- output += spanAnsiClose(span, theme);
295
- closedSpan = true;
296
- }
297
- }
298
- if (closedSpan && visibleColumn <= terminalCellWidth(line)) {
299
- for (const span of rowSpans) {
300
- if (span.x1 < visibleColumn && span.x2 > visibleColumn) {
301
- output += spanAnsiOpen(span, theme);
302
- }
303
- }
304
- }
305
- }
306
-
307
- for (const span of rowSpans) {
308
- if (span.x1 < visibleColumn && span.x2 > visibleColumn) {
309
- if (span.kind !== "focus") {
310
- output += spanAnsiClose(span, theme);
311
- }
312
- }
313
- }
314
- for (const span of rowSpans) {
315
- if (span.x1 < visibleColumn && span.x2 > visibleColumn && span.kind === "focus") {
316
- output += spanAnsiClose(span, theme);
317
- }
318
- }
319
-
320
- return output;
317
+ return renderAnsiLineFromColumn(line, spans, y, startColumn, theme);
321
318
  }
322
319
 
323
320
 
package/src/layout.ts CHANGED
@@ -242,6 +242,8 @@ function overlayMetric(value: number, name: string) {
242
242
  return metric;
243
243
  }
244
244
 
245
+ const MODAL_OVERLAY_HITBOX_ID = "\u0000valyrian-overlay-modal-shield";
246
+
245
247
  function nextPointerLayer(hitboxes: TerminalHitbox[]) {
246
248
  let layer = 0;
247
249
  for (const box of hitboxes) {
@@ -250,7 +252,7 @@ function nextPointerLayer(hitboxes: TerminalHitbox[]) {
250
252
  return layer + 1;
251
253
  }
252
254
 
253
- export function overlayFrame(base: TerminalFrame, overlay: TerminalFrame, options: { x: number; y: number; width: number; height: number }): TerminalFrame {
255
+ export function overlayFrame(base: TerminalFrame, overlay: TerminalFrame, options: { x: number; y: number; width: number; height: number; backdropSpans?: TerminalStyleSpan[] }): TerminalFrame {
254
256
  const x = overlayMetric(options.x, "Overlay x");
255
257
  const y = overlayMetric(options.y, "Overlay y");
256
258
  const width = overlayMetric(options.width, "Overlay width");
@@ -289,9 +291,18 @@ export function overlayFrame(base: TerminalFrame, overlay: TerminalFrame, option
289
291
  ...box,
290
292
  pointerLayer: overlayLayer + (box.pointerLayer ?? 0)
291
293
  }));
294
+ const overlayShield = {
295
+ id: MODAL_OVERLAY_HITBOX_ID,
296
+ tag: "terminal-overlay" as const,
297
+ x1: 1,
298
+ x2: Math.max(1, getFrameWidth(base)),
299
+ y1: 1,
300
+ y2: Math.max(1, getFrameHeight(base)),
301
+ pointerLayer: overlayLayer - 0.5
302
+ };
292
303
  const cursor = visibleOverlay.cursor || base.cursor;
293
304
 
294
- return createFrame(lines, [...overlayHitboxes, ...base.hitboxes], cursor, [...base.spans, ...visibleOverlay.spans]);
305
+ return createFrame(lines, [...overlayHitboxes, overlayShield, ...base.hitboxes], cursor, [...base.spans, ...(options.backdropSpans ?? []), ...visibleOverlay.spans]);
295
306
  }
296
307
 
297
308
  export function cropFrame(frame: TerminalFrame, offset: number, height: number) {
@@ -7,7 +7,7 @@ import { renderValyrianTerminal } from "./runtime.js";
7
7
  import { cursorCellOffset, dropTerminalCells, padEndTerminalCells, plainText, sliceTerminalCells, terminalCellToStringIndex, terminalCellWidth, terminalGraphemes } from "./text.js";
8
8
  import { resolveTerminalStyle } from "./theme.js";
9
9
 
10
- import type { InputInteractionState, TerminalButtonPressEventPayload, TerminalElementNode, TerminalFocusNode, TerminalFrame, TerminalHitbox, TerminalNode, TerminalSpacing, TerminalSplitBreakpoint, TerminalSplitSize, TerminalStyleDefinition, TerminalStyleSpan, TerminalTheme, TerminalVisualState } from "./types.js";
10
+ import type { InputInteractionState, TerminalButtonPressEventPayload, TerminalElementNode, TerminalFocusNode, TerminalFrame, TerminalHitbox, TerminalNode, TerminalSpacing, TerminalSplitBreakpoint, TerminalSplitSize, TerminalStyleDefinition, TerminalStyleSpan, TerminalStyleValue, TerminalTheme, TerminalVisualState } from "./types.js";
11
11
 
12
12
  export interface TerminalRenderContext {
13
13
  cols: number;
@@ -579,6 +579,24 @@ function renderInputLine(value: string, inputState: InputInteractionState, paddi
579
579
  };
580
580
  }
581
581
 
582
+ function scrollFocusedInputLine(rendered: ReturnType<typeof renderInputLine>, visibleWidth: number) {
583
+ const width = Math.max(1, Math.trunc(visibleWidth));
584
+ const scrollOffset = Math.max(0, rendered.cursor.x - width);
585
+ if (scrollOffset === 0) {
586
+ return rendered;
587
+ }
588
+
589
+ return {
590
+ line: dropTerminalCells(rendered.line, scrollOffset),
591
+ cursor: { x: rendered.cursor.x - scrollOffset, y: rendered.cursor.y },
592
+ spans: rendered.spans.map((span) => ({
593
+ ...span,
594
+ x1: span.x1 - scrollOffset,
595
+ x2: span.x2 - scrollOffset
596
+ }))
597
+ };
598
+ }
599
+
582
600
  function resolveEditorDimensions(node: TerminalElementNode, context?: TerminalRenderContext) {
583
601
  const explicitWidth = positiveDimension(node.props.width, "width");
584
602
  const explicitHeight = positiveDimension(node.props.height, "height");
@@ -587,7 +605,7 @@ function resolveEditorDimensions(node: TerminalElementNode, context?: TerminalRe
587
605
  ? explicitWidth
588
606
  : node.props.fill === true
589
607
  ? fillContextDimension(context, "width", "Editor")
590
- : undefined,
608
+ : contextBackedDimension(context, "width", "Editor"),
591
609
  height: typeof explicitHeight !== "undefined"
592
610
  ? explicitHeight
593
611
  : node.props.fill === true
@@ -596,6 +614,93 @@ function resolveEditorDimensions(node: TerminalElementNode, context?: TerminalRe
596
614
  };
597
615
  }
598
616
 
617
+ type WrappedEditorSegment = { text: string; sourceLine: number; start: number; end: number };
618
+
619
+ function wrapEditorLineSegments(value: string, width: number, sourceLine: number): WrappedEditorSegment[] {
620
+ if (!Number.isFinite(width) || !Number.isInteger(width) || width <= 0) {
621
+ return [{ text: "", sourceLine, start: 0, end: 0 }];
622
+ }
623
+ if (value.length === 0) {
624
+ return [{ text: "", sourceLine, start: 0, end: 0 }];
625
+ }
626
+
627
+ const segments: WrappedEditorSegment[] = [];
628
+ let remaining = value;
629
+ let offset = 0;
630
+ while (terminalCellWidth(remaining) > width) {
631
+ const slice = sliceTerminalCells(remaining, width);
632
+ if (slice.length === 0) {
633
+ const [firstGrapheme = ""] = terminalGraphemes(remaining);
634
+ segments.push({ text: firstGrapheme, sourceLine, start: offset, end: offset + firstGrapheme.length });
635
+ remaining = remaining.slice(firstGrapheme.length);
636
+ offset += firstGrapheme.length;
637
+ continue;
638
+ }
639
+
640
+ const breakAt = slice.lastIndexOf(" ");
641
+ const useWordBreak = breakAt > 0 && breakAt >= Math.floor(width * 0.6);
642
+ const segmentText = useWordBreak ? remaining.slice(0, breakAt) : slice;
643
+ segments.push({ text: segmentText, sourceLine, start: offset, end: offset + segmentText.length });
644
+ const consumed = useWordBreak ? breakAt + 1 : slice.length;
645
+ remaining = remaining.slice(consumed);
646
+ offset += consumed;
647
+ }
648
+ segments.push({ text: remaining, sourceLine, start: offset, end: offset + remaining.length });
649
+ return segments;
650
+ }
651
+
652
+ function wrapEditorLines(lines: string[], width: number): WrappedEditorSegment[] {
653
+ return lines.flatMap((line, sourceLine) => wrapEditorLineSegments(line, width, sourceLine));
654
+ }
655
+
656
+ function insertTrailingEditorCursorSegment(segments: WrappedEditorSegment[], line: number, column: number, contentWidth?: number) {
657
+ if (typeof contentWidth !== "number") {
658
+ return segments;
659
+ }
660
+
661
+ for (let index = 0; index < segments.length; index += 1) {
662
+ const segment = segments[index];
663
+ if (segment.sourceLine !== line || column !== segment.end || segment.start === segment.end) {
664
+ continue;
665
+ }
666
+
667
+ const nextSegment = segments[index + 1];
668
+ if (nextSegment && nextSegment.sourceLine === line && nextSegment.start === column) {
669
+ return segments;
670
+ }
671
+
672
+ if (terminalCellWidth(segment.text) < contentWidth) {
673
+ return segments;
674
+ }
675
+
676
+ const cursorSegment = { text: "", sourceLine: line, start: column, end: column };
677
+ return [...segments.slice(0, index + 1), cursorSegment, ...segments.slice(index + 1)];
678
+ }
679
+
680
+ return segments;
681
+ }
682
+
683
+ function visualEditorCursor(segments: WrappedEditorSegment[], line: number, column: number) {
684
+ for (let index = 0; index < segments.length; index += 1) {
685
+ const segment = segments[index];
686
+ if (segment.sourceLine !== line) {
687
+ continue;
688
+ }
689
+
690
+ const nextSegment = segments[index + 1];
691
+ if (column === segment.end && nextSegment && nextSegment.sourceLine === line && nextSegment.start === column) {
692
+ return { index: index + 1, column: 0 };
693
+ }
694
+
695
+ const containsCursor = column >= segment.start && column <= segment.end;
696
+ const isTrailingEmpty = segment.start === segment.end && column === segment.start;
697
+ if (containsCursor || isTrailingEmpty) {
698
+ return { index, column: Math.max(0, Math.min(column - segment.start, segment.text.length)) };
699
+ }
700
+ }
701
+ return { index: Math.max(0, segments.length - 1), column: 0 };
702
+ }
703
+
599
704
  function renderEditorFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
600
705
  const value = typeof node.props.value !== "undefined" ? plainText(node.props.value) : "";
601
706
  const placeholder = typeof node.props.placeholder !== "undefined" ? plainText(node.props.placeholder) : "";
@@ -604,29 +709,24 @@ function renderEditorFrame(node: TerminalElementNode, context?: TerminalRenderCo
604
709
  const focusedState = createEditorState(value, node.props.__editorState?.cursor);
605
710
  const focusedLine = focusedState.cursor.line;
606
711
  const focusedColumn = focusedState.cursor.column;
607
- const lines = state.lines.map((line, index) => {
608
- if (!node.props.__focused || index !== focusedLine) {
609
- return ` ${line}`;
712
+ const dimensions = resolveEditorDimensions(node, context);
713
+ const contentWidth = typeof dimensions.width === "number" ? Math.max(1, dimensions.width - 2) : undefined;
714
+ const baseSegments = typeof contentWidth === "number" ? wrapEditorLines(state.lines, contentWidth) : state.lines.map((line, sourceLine) => ({ text: line, sourceLine, start: 0, end: line.length }));
715
+ const segments = node.props.__focused ? insertTrailingEditorCursorSegment(baseSegments, focusedLine, focusedColumn, contentWidth) : baseSegments;
716
+ const focusedVisualCursor = visualEditorCursor(segments, focusedLine, focusedColumn);
717
+ const lines = segments.map((segment, index) => {
718
+ if (!node.props.__focused || index !== focusedVisualCursor.index) {
719
+ return ` ${segment.text}`;
610
720
  }
611
721
 
612
- return ` ${line.slice(0, focusedColumn)}|${line.slice(focusedColumn)}`;
722
+ return ` ${segment.text.slice(0, focusedVisualCursor.column)}|${segment.text.slice(focusedVisualCursor.column)}`;
613
723
  });
614
- const dimensions = resolveEditorDimensions(node, context);
615
- let renderedLines = lines;
616
- let cursor = node.props.__focused ? { x: 3 + cursorCellOffset(focusedState.lines[focusedLine], focusedColumn), y: focusedLine + 1 } : null;
617
- let spans: TerminalStyleSpan[] = node.props.__focused ? [{ kind: "focus", x1: 1, x2: Math.max(2, terminalCellWidth(lines[focusedLine]) + 1), y: focusedLine + 1 }] : [];
618
-
619
- const availableWidth = dimensions.width ?? context?.cols;
620
- if (node.props.__focused && cursor && typeof availableWidth === "number" && cursor.x > availableWidth) {
621
- const horizontalOffset = cursor.x - availableWidth;
622
- renderedLines = lines.map((line) => dropTerminalCells(line, horizontalOffset));
623
- cursor = { x: availableWidth, y: cursor.y };
624
- spans = [{ kind: "focus", x1: 1, x2: Math.max(2, Math.min(availableWidth + 1, terminalCellWidth(renderedLines[focusedLine]) + 1)), y: focusedLine + 1 }];
625
- }
724
+ const cursor = node.props.__focused ? { x: 3 + cursorCellOffset(segments[focusedVisualCursor.index]?.text ?? "", focusedVisualCursor.column), y: focusedVisualCursor.index + 1 } : null;
725
+ const spans: TerminalStyleSpan[] = node.props.__focused ? [{ kind: "editor.focus", x1: 1, x2: Math.max(2, terminalCellWidth(lines[focusedVisualCursor.index] ?? "") + 1), y: focusedVisualCursor.index + 1 }] : [];
626
726
 
627
- const frame = createFrame(renderedLines, [], cursor, spans);
727
+ const frame = createFrame(lines, [], cursor, spans);
628
728
  const scrollOffset = node.props.__focused && typeof dimensions.height !== "undefined"
629
- ? Math.min(Math.max(0, focusedLine - dimensions.height + 1), Math.max(0, lines.length - dimensions.height))
729
+ ? Math.min(Math.max(0, focusedVisualCursor.index - dimensions.height + 1), Math.max(0, lines.length - dimensions.height))
630
730
  : 0;
631
731
  const croppedFrame = typeof dimensions.height === "undefined"
632
732
  ? frame
@@ -1004,6 +1104,18 @@ function overlayGeometry(node: TerminalElementNode, width: number, height: numbe
1004
1104
  };
1005
1105
  }
1006
1106
 
1107
+ function resolveOverlayBackdropSpans(node: TerminalElementNode, width: number, height: number, context?: TerminalRenderContext) {
1108
+ const backdrop = node.props.backdrop;
1109
+ if (backdrop === false || typeof backdrop === "undefined") {
1110
+ return [];
1111
+ }
1112
+
1113
+ const styleValue: TerminalStyleValue = backdrop === true ? "overlay.backdrop" : backdrop;
1114
+ const resolvedStyle = resolveTerminalStyle(styleValue, context?.theme);
1115
+ const span = typeof styleValue === "string" ? styleSpan(styleValue) : styleSpan("#style", resolvedStyle);
1116
+ return fullFrameSpans([span.kind], width, height).map((fullSpan) => (typeof span.style === "undefined" ? fullSpan : { ...fullSpan, style: span.style }));
1117
+ }
1118
+
1007
1119
  function renderOverlayChildFrame(node: TerminalElementNode, width: number, height: number, context?: TerminalRenderContext) {
1008
1120
  const geometry = overlayGeometry(node, width, height);
1009
1121
  let frame = constrainFrame(renderBodyFrame(node.children, {}, { cols: geometry.width, rows: geometry.height, theme: context?.theme }), { width: geometry.width, height: geometry.height, expandFullRowSpans: true });
@@ -1024,7 +1136,10 @@ function applyDirectOverlays(base: TerminalFrame, overlays: TerminalElementNode[
1024
1136
  const height = Math.max(1, getFrameHeight(base));
1025
1137
  for (const overlay of orderedDirectOverlays(overlays)) {
1026
1138
  const rendered = renderOverlayChildFrame(overlay, width, height, context);
1027
- frame = overlayFrame(frame, rendered.frame, rendered.geometry);
1139
+ frame = overlayFrame(frame, rendered.frame, {
1140
+ ...rendered.geometry,
1141
+ backdropSpans: resolveOverlayBackdropSpans(overlay, width, height, context)
1142
+ });
1028
1143
  }
1029
1144
  return frame;
1030
1145
  }
@@ -1383,24 +1498,33 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1383
1498
  const layoutStyle = resolveLayoutStyle("input.base", node, context);
1384
1499
  const inputPadding = normalizeSpacing(layoutStyle.padding, "Input padding");
1385
1500
  const inputBorder = normalizeBorder(layoutStyle.border);
1501
+ const dimensions = {
1502
+ width: positiveDimension(node.props.width, "width") ?? contextBackedDimension(context, "width", "Input")
1503
+ };
1386
1504
  const textStartX = (inputBorder.left ? 1 : 0) + inputPadding.left + 1;
1387
1505
  if (!node.props.__focused) {
1388
1506
  const line = `${" ".repeat(inputPadding.left)}${displayValue}${" ".repeat(inputPadding.right)}`;
1389
1507
  const decorated = addBorder(createFrame([line]), inputBorder);
1390
- const width = Math.max(1, getFrameWidth(decorated));
1391
- const height = Math.max(1, getFrameHeight(decorated));
1508
+ const constrained = typeof dimensions.width === "number" ? constrainFrame(decorated, { width: dimensions.width, expandFullFrameSpans: true }) : decorated;
1509
+ const width = Math.max(1, getFrameWidth(constrained));
1510
+ const height = Math.max(1, getFrameHeight(constrained));
1392
1511
  const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, ...interactiveTextMetadata(stringValue) }] : [];
1393
1512
  const spans = fullFrameSpans(["input.base"], width, height);
1394
- return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1513
+ return addFullFrameSpans(createFrame(constrained.lines, hitboxes, constrained.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1395
1514
  }
1396
1515
 
1397
1516
  const rendered = renderInputLine(stringValue, node.props.__inputState || { cursor: stringValue.length, anchor: stringValue.length }, inputPadding);
1398
- const decorated = addBorder(createFrame([rendered.line], [], rendered.cursor, rendered.spans), inputBorder);
1399
- const width = Math.max(1, getFrameWidth(decorated));
1400
- const height = Math.max(1, getFrameHeight(decorated));
1517
+ const visibleInputWidth = typeof dimensions.width === "number"
1518
+ ? Math.max(1, dimensions.width - (inputBorder.left ? 1 : 0) - (inputBorder.right ? 1 : 0))
1519
+ : getFrameWidth(createFrame([rendered.line]));
1520
+ const visibleRendered = scrollFocusedInputLine(rendered, visibleInputWidth);
1521
+ const decorated = addBorder(createFrame([visibleRendered.line], [], visibleRendered.cursor, visibleRendered.spans), inputBorder);
1522
+ const constrained = typeof dimensions.width === "number" ? constrainFrame(decorated, { width: dimensions.width, expandFullFrameSpans: true }) : decorated;
1523
+ const width = Math.max(1, getFrameWidth(constrained));
1524
+ const height = Math.max(1, getFrameHeight(constrained));
1401
1525
  const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, ...interactiveTextMetadata(stringValue) }] : [];
1402
- const spans = [...fullFrameSpans(["input.base", "input.focus"], width, height), ...decorated.spans];
1403
- return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1526
+ const spans = [...fullFrameSpans(["input.base", "input.focus"], width, height), ...constrained.spans];
1527
+ return addFullFrameSpans(createFrame(constrained.lines, hitboxes, constrained.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1404
1528
  }
1405
1529
  case "terminal-editor":
1406
1530
  return addFullFrameSpans(renderEditorFrame(node, context), resolveNodeStyle(node, context).spanKinds);
package/src/render.ts CHANGED
@@ -3,6 +3,8 @@ import { renderTerminalFrame as renderTerminalFrameInternal } from "./render-int
3
3
 
4
4
  import type { TerminalFrame, TerminalHitbox, TerminalNode, TerminalTheme } from "./types.js";
5
5
 
6
+ const MODAL_OVERLAY_HITBOX_ID = "\u0000valyrian-overlay-modal-shield";
7
+
6
8
  export interface TerminalRenderContext {
7
9
  cols: number;
8
10
  rows: number;
@@ -41,7 +43,9 @@ function stripDoubleUnderscoreFields<T extends Record<string, any>>(value: T): R
41
43
  function publicTerminalFrame(frame: TerminalFrame): TerminalFrame {
42
44
  return {
43
45
  lines: frame.lines,
44
- hitboxes: frame.hitboxes.map((hitbox) => stripDoubleUnderscoreFields(hitbox) as TerminalHitbox),
46
+ hitboxes: frame.hitboxes
47
+ .filter((hitbox) => hitbox.id !== MODAL_OVERLAY_HITBOX_ID)
48
+ .map((hitbox) => stripDoubleUnderscoreFields(hitbox) as TerminalHitbox),
45
49
  cursor: frame.cursor ? { ...frame.cursor } : null,
46
50
  spans: frame.spans
47
51
  };