@cel-tui/core 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cel-tui/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Core framework engine for cel-tui — primitives, layout, rendering, input",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",
@@ -34,7 +34,7 @@
34
34
  "layout"
35
35
  ],
36
36
  "dependencies": {
37
- "@cel-tui/types": "0.3.0",
37
+ "@cel-tui/types": "0.4.0",
38
38
  "get-east-asian-width": "^1.5.0"
39
39
  },
40
40
  "peerDependencies": {
package/src/cel.ts CHANGED
@@ -26,10 +26,18 @@ import {
26
26
  type EditState,
27
27
  } from "./text-edit.js";
28
28
  import type { Terminal } from "./terminal.js";
29
- import { visibleWidth } from "./width.js";
29
+ import { getMaxScrollOffset, getScrollStep } from "./scroll.js";
30
30
 
31
31
  type RenderFn = () => Node | Node[];
32
32
 
33
+ type TerminalCursorState =
34
+ | { visible: false }
35
+ | {
36
+ visible: true;
37
+ x: number;
38
+ y: number;
39
+ };
40
+
33
41
  let terminal: Terminal | null = null;
34
42
  let activeTheme: Theme = defaultTheme;
35
43
  let renderFn: RenderFn | null = null;
@@ -60,6 +68,9 @@ const uncontrolledScrollOffsets = new Map<string, number>();
60
68
  /** Nodes whose props were stamped with scrollOffset during the last paint. */
61
69
  let stampedScrollNodes: { node: Node; key: string }[] = [];
62
70
 
71
+ /** The cursor state currently expected on the terminal. */
72
+ let lastTerminalCursor: TerminalCursorState | null = { visible: false };
73
+
63
74
  function doRender(): void {
64
75
  renderScheduled = false;
65
76
  if (!renderFn || !terminal) return;
@@ -105,43 +116,38 @@ function doRender(): void {
105
116
  unstampUncontrolledFocus();
106
117
  unstampUncontrolledScroll();
107
118
 
119
+ const nextCursor = getDesiredTerminalCursor();
120
+
108
121
  // Emit to terminal — differential when possible
109
122
  if (prevBuffer) {
110
- const output = emitDiff(prevBuffer, currentBuffer, activeTheme);
123
+ const output = emitDiff(prevBuffer, currentBuffer, activeTheme, {
124
+ cursor: nextCursor,
125
+ previousCursor: lastTerminalCursor,
126
+ });
111
127
  if (output.length > 0) terminal.write(output);
112
128
  } else {
113
- const output = emitBuffer(currentBuffer, activeTheme);
129
+ const output = emitBuffer(currentBuffer, activeTheme, {
130
+ cursor: nextCursor,
131
+ previousCursor: lastTerminalCursor,
132
+ });
114
133
  terminal.write(output);
115
134
  }
116
135
 
117
- // Position the native terminal cursor at the focused TextInput's cursor.
118
- // This gives us a blinking cursor for free (terminal-managed blink).
119
- positionTerminalCursor();
136
+ lastTerminalCursor = nextCursor;
120
137
  }
121
138
 
122
139
  /**
123
- * After each render, show the native terminal cursor at the focused
124
- * TextInput's cursor position (gives blinking for free). When no
125
- * TextInput is focused, hide the cursor.
140
+ * Resolve the final terminal cursor state for the current frame.
141
+ * The cursor is shown only for a focused TextInput whose cursor is visible
142
+ * within the clipped viewport.
126
143
  */
127
- function positionTerminalCursor(): void {
128
- if (!terminal) return;
129
-
130
- // Find focused TextInput in the layout tree (check stamped state during
131
- // render — we need to check controlled focus in the current layouts)
144
+ function getDesiredTerminalCursor(): TerminalCursorState {
132
145
  const focusedTI = findFocusedTextInputLayout();
133
- if (focusedTI) {
134
- const props = focusedTI.node
135
- .props as import("@cel-tui/types").TextInputProps;
136
- const pos = getTextInputCursorScreenPos(props, focusedTI.rect);
137
- if (pos) {
138
- // CUP: move cursor to (row, col) — 1-indexed
139
- terminal.write(`\x1b[${pos.y + 1};${pos.x + 1}H`);
140
- terminal.showCursor();
141
- return;
142
- }
143
- }
144
- terminal.hideCursor();
146
+ if (!focusedTI) return { visible: false };
147
+
148
+ const props = focusedTI.node.props as import("@cel-tui/types").TextInputProps;
149
+ const pos = getTextInputCursorScreenPos(props, focusedTI.rect);
150
+ return pos ? { visible: true, x: pos.x, y: pos.y } : { visible: false };
145
151
  }
146
152
 
147
153
  /**
@@ -453,49 +459,6 @@ function changeFocus(target: LayoutNode | null): void {
453
459
  }
454
460
  }
455
461
 
456
- /**
457
- * Compute the maximum scroll offset for a scrollable container or TextInput.
458
- * Returns 0 if content fits within the viewport.
459
- */
460
- function getMaxScrollOffset(target: LayoutNode): number {
461
- const { rect, children } = target;
462
-
463
- // TextInput: compute content height from wrapped text value
464
- if (target.node.type === "textinput") {
465
- const w = rect.width;
466
- const value = target.node.props.value;
467
- let lineCount = 0;
468
- for (const rawLine of value.split("\n")) {
469
- const lw = visibleWidth(rawLine);
470
- lineCount += w > 0 && lw > w ? Math.ceil(lw / w) : 1;
471
- }
472
- return Math.max(0, lineCount - rect.height);
473
- }
474
-
475
- const isVertical = target.node.type === "vstack";
476
- const props = target.node.type !== "text" ? target.node.props : null;
477
- const padX = (props as any)?.padding?.x ?? 0;
478
- const padY = (props as any)?.padding?.y ?? 0;
479
-
480
- if (isVertical) {
481
- let contentHeight = 0;
482
- for (const child of children) {
483
- const childBottom = child.rect.y + child.rect.height - rect.y;
484
- if (childBottom > contentHeight) contentHeight = childBottom;
485
- }
486
- // Viewport is the inner height (minus bottom padding)
487
- // Content starts at padY, so contentHeight includes top padding offset
488
- return Math.max(0, contentHeight + padY - rect.height);
489
- } else {
490
- let contentWidth = 0;
491
- for (const child of children) {
492
- const childRight = child.rect.x + child.rect.width - rect.x;
493
- if (childRight > contentWidth) contentWidth = childRight;
494
- }
495
- return Math.max(0, contentWidth + padX - rect.width);
496
- }
497
- }
498
-
499
462
  /**
500
463
  * Resolve the current scroll offset for a layout node.
501
464
  * Checks controlled (props.scrollOffset), then uncontrolled (path-based map).
@@ -539,7 +502,8 @@ function handleMouseEvent(event: MouseEvent): void {
539
502
  if (event.type === "scroll-up" || event.type === "scroll-down") {
540
503
  const target = findScrollTarget(path);
541
504
  if (target) {
542
- const delta = event.type === "scroll-up" ? -1 : 1;
505
+ const step = getScrollStep(target);
506
+ const delta = event.type === "scroll-up" ? -step : step;
543
507
  const maxOffset = getMaxScrollOffset(target);
544
508
 
545
509
  if (target.node.type === "textinput") {
@@ -890,6 +854,7 @@ export const cel = {
890
854
  init(term: Terminal, options?: { theme?: Theme }): void {
891
855
  terminal = term;
892
856
  activeTheme = options?.theme ?? defaultTheme;
857
+ lastTerminalCursor = { visible: false };
893
858
  terminal.start(handleInput, () => cel.render());
894
859
  },
895
860
 
@@ -935,6 +900,7 @@ export const cel = {
935
900
  stampedNode = null;
936
901
  uncontrolledScrollOffsets.clear();
937
902
  stampedScrollNodes = [];
903
+ lastTerminalCursor = { visible: false };
938
904
  activeTheme = defaultTheme;
939
905
  },
940
906
 
package/src/emitter.ts CHANGED
@@ -100,8 +100,61 @@ function hasStyle(cell: Cell): boolean {
100
100
  const SYNC_START = "\x1b[?2026h";
101
101
  const SYNC_END = "\x1b[?2026l";
102
102
  const CURSOR_HOME = "\x1b[H";
103
+ const HIDE_CURSOR = "\x1b[?25l";
104
+ const SHOW_CURSOR = "\x1b[?25h";
105
+ const SAVE_CURSOR = "\x1b7";
106
+ const RESTORE_CURSOR = "\x1b8";
103
107
  const RESET = "\x1b[0m";
104
108
 
109
+ type CursorState =
110
+ | { visible: false }
111
+ | {
112
+ visible: true;
113
+ x: number;
114
+ y: number;
115
+ };
116
+
117
+ interface EmitOptions {
118
+ cursor?: CursorState;
119
+ previousCursor?: CursorState | null;
120
+ }
121
+
122
+ function sameVisibleCursor(
123
+ a: CursorState | null | undefined,
124
+ b: CursorState | null | undefined,
125
+ ): boolean {
126
+ return (
127
+ a?.visible === true && b?.visible === true && a.x === b.x && a.y === b.y
128
+ );
129
+ }
130
+
131
+ function getCursorControls(
132
+ options: EmitOptions | undefined,
133
+ hasPaintOutput: boolean,
134
+ ): { prefix: string; suffix: string } {
135
+ const cursor = options?.cursor ?? { visible: false };
136
+ const previous = options?.previousCursor ?? null;
137
+
138
+ if (sameVisibleCursor(cursor, previous)) {
139
+ return hasPaintOutput
140
+ ? { prefix: SAVE_CURSOR, suffix: RESTORE_CURSOR }
141
+ : { prefix: "", suffix: "" };
142
+ }
143
+
144
+ if (cursor.visible) {
145
+ return {
146
+ prefix: "",
147
+ suffix:
148
+ `\x1b[${cursor.y + 1};${cursor.x + 1}H` +
149
+ (previous?.visible === true ? "" : SHOW_CURSOR),
150
+ };
151
+ }
152
+
153
+ return previous?.visible === true
154
+ ? { prefix: "", suffix: HIDE_CURSOR }
155
+ : { prefix: "", suffix: "" };
156
+ }
157
+
105
158
  /**
106
159
  * Emit a full cell buffer as an ANSI string for terminal output.
107
160
  *
@@ -114,13 +167,16 @@ const RESET = "\x1b[0m";
114
167
  *
115
168
  * @param buf - The cell buffer to render.
116
169
  * @param theme - Color theme mapping. Defaults to the ANSI 16 theme.
170
+ * @param options - Optional terminal cursor state to apply before the synchronized output ends.
117
171
  * @returns A complete ANSI string ready to write to the terminal.
118
172
  */
119
173
  export function emitBuffer(
120
174
  buf: CellBuffer,
121
175
  theme: Theme = defaultTheme,
176
+ options?: EmitOptions,
122
177
  ): string {
123
- let out = SYNC_START + CURSOR_HOME;
178
+ const cursorControls = getCursorControls(options, true);
179
+ let out = SYNC_START + cursorControls.prefix + CURSOR_HOME;
124
180
 
125
181
  let lastStyle: Cell | null = null;
126
182
 
@@ -153,7 +209,7 @@ export function emitBuffer(
153
209
  out += RESET;
154
210
  }
155
211
 
156
- out += SYNC_END;
212
+ out += cursorControls.suffix + SYNC_END;
157
213
  return out;
158
214
  }
159
215
 
@@ -167,18 +223,21 @@ export function emitBuffer(
167
223
  * @param prev - The previous buffer.
168
224
  * @param next - The new buffer.
169
225
  * @param theme - Color theme mapping. Defaults to the ANSI 16 theme.
226
+ * @param options - Optional terminal cursor state to apply before the synchronized output ends.
170
227
  * @returns An ANSI string with only the changed cells.
171
228
  */
172
229
  export function emitDiff(
173
230
  prev: CellBuffer,
174
231
  next: CellBuffer,
175
232
  theme: Theme = defaultTheme,
233
+ options?: EmitOptions,
176
234
  ): string {
177
- let out = SYNC_START;
178
-
179
235
  const changes = prev.diff(next);
236
+ const cursorControls = getCursorControls(options, changes.length > 0);
237
+ let out = SYNC_START + cursorControls.prefix;
238
+
180
239
  if (changes.length === 0) {
181
- return out + SYNC_END;
240
+ return out + cursorControls.suffix + SYNC_END;
182
241
  }
183
242
 
184
243
  let lastStyle: Cell | null = null;
@@ -222,6 +281,6 @@ export function emitDiff(
222
281
  out += RESET;
223
282
  }
224
283
 
225
- out += SYNC_END;
284
+ out += cursorControls.suffix + SYNC_END;
226
285
  return out;
227
286
  }
package/src/layout.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Node, ContainerProps, SizeValue } from "@cel-tui/types";
2
+ import { layoutText } from "./text-layout.js";
2
3
  import { visibleWidth } from "./width.js";
3
4
 
4
5
  /**
@@ -58,20 +59,11 @@ function intrinsicMainSize(
58
59
  ): number {
59
60
  if (node.type === "text") {
60
61
  if (isVertical) {
61
- // Height = number of lines
62
- if (node.content.length === 0) return 1;
63
- const lines = node.content.split("\n");
64
- if (node.props.wrap === "word") {
65
- let total = 0;
66
- for (const line of lines) {
67
- total += Math.max(
68
- 1,
69
- Math.ceil(visibleWidth(line) / Math.max(1, crossSize)),
70
- );
71
- }
72
- return total;
73
- }
74
- return lines.length;
62
+ return layoutText(
63
+ node.content,
64
+ Math.max(1, crossSize),
65
+ node.props.wrap ?? "none",
66
+ ).lineCount;
75
67
  }
76
68
  // Width (intrinsic)
77
69
  if (node.props.repeat === "fill") return 0;
@@ -91,16 +83,7 @@ function intrinsicMainSize(
91
83
  if (isVertical) {
92
84
  const val = node.props.value || "";
93
85
  const innerCrossForTI = Math.max(1, crossSize - tiPadX);
94
- if (val.length === 0) return 1 + tiPadY;
95
- const lines = val.split("\n");
96
- let total = 0;
97
- for (const line of lines) {
98
- total += Math.max(
99
- 1,
100
- Math.ceil(visibleWidth(line) / Math.max(1, innerCrossForTI)),
101
- );
102
- }
103
- return total + tiPadY;
86
+ return layoutText(val, innerCrossForTI, "word").lineCount + tiPadY;
104
87
  }
105
88
  return 0 + tiPadX;
106
89
  }
package/src/paint.ts CHANGED
@@ -2,6 +2,8 @@ import type { Color, StyleProps, TextInputProps } from "@cel-tui/types";
2
2
  import type { Cell } from "./cell-buffer.js";
3
3
  import { CellBuffer } from "./cell-buffer.js";
4
4
  import type { LayoutNode, Rect } from "./layout.js";
5
+ import { getMaxScrollOffset } from "./scroll.js";
6
+ import { layoutText } from "./text-layout.js";
5
7
  import { visibleWidth } from "./width.js";
6
8
 
7
9
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
@@ -104,32 +106,6 @@ function fillBackground(
104
106
  }
105
107
  }
106
108
 
107
- /**
108
- * Compute the maximum scroll offset for a scrollable container.
109
- * This is the content size minus the viewport size along the main axis.
110
- */
111
- function computeMaxScrollOffset(ln: LayoutNode, isVertical: boolean): number {
112
- const { rect, children } = ln;
113
- const props = ln.node.type !== "text" ? ln.node.props : null;
114
- const padX = (props as any)?.padding?.x ?? 0;
115
- const padY = (props as any)?.padding?.y ?? 0;
116
-
117
- if (isVertical) {
118
- let contentHeight = 0;
119
- for (const child of children) {
120
- const childBottom = child.rect.y + child.rect.height - rect.y;
121
- if (childBottom > contentHeight) contentHeight = childBottom;
122
- }
123
- return Math.max(0, contentHeight + padY - rect.height);
124
- }
125
- let contentWidth = 0;
126
- for (const child of children) {
127
- const childRight = child.rect.x + child.rect.width - rect.x;
128
- if (childRight > contentWidth) contentWidth = childRight;
129
- }
130
- return Math.max(0, contentWidth + padX - rect.width);
131
- }
132
-
133
109
  function paintLayoutNode(
134
110
  ln: LayoutNode,
135
111
  buf: CellBuffer,
@@ -203,7 +179,7 @@ function paintLayoutNode(
203
179
  if (isScrollable) {
204
180
  const raw = containerProps.scrollOffset ?? 0;
205
181
  // Clamp to valid range so apps can pass large values to mean "scroll to end"
206
- const maxOffset = computeMaxScrollOffset(ln, isVertical);
182
+ const maxOffset = getMaxScrollOffset(ln);
207
183
  scrollOffset = Math.max(0, Math.min(raw, maxOffset));
208
184
  }
209
185
 
@@ -434,26 +410,11 @@ function paintText(
434
410
  text = content.repeat(props.repeat);
435
411
  }
436
412
 
437
- // Split into lines
438
- const rawLines = text.split("\n");
439
-
440
- // Word-wrap if enabled
441
- const lines: string[] = [];
442
- if (props.wrap === "word") {
443
- for (const rawLine of rawLines) {
444
- if (visibleWidth(rawLine) <= w) {
445
- lines.push(rawLine);
446
- } else {
447
- wrapLine(rawLine, w, lines);
448
- }
449
- }
450
- } else {
451
- lines.push(...rawLines);
452
- }
413
+ const textLayout = layoutText(text, w, props.wrap ?? "none");
453
414
 
454
415
  // Paint lines, clipped to rect (grapheme-aware)
455
- for (let row = 0; row < lines.length && row < h; row++) {
456
- const line = lines[row]!;
416
+ for (let row = 0; row < textLayout.lineCount && row < h; row++) {
417
+ const line = textLayout.lines[row]!.text;
457
418
  paintLineGraphemes(line, x, y + row, w, clipRect, props, buf);
458
419
  }
459
420
  }
@@ -510,22 +471,14 @@ function paintTextInput(
510
471
  return;
511
472
  }
512
473
 
513
- // Word-wrap value (always on for TextInput) using content width
514
- const lines: string[] = [];
515
- for (const rawLine of value.split("\n")) {
516
- if (visibleWidth(rawLine) <= cw) {
517
- lines.push(rawLine);
518
- } else {
519
- wrapLine(rawLine, cw, lines);
520
- }
521
- }
474
+ const textLayout = layoutText(value, cw, "word");
522
475
 
523
476
  // Framework-managed scroll: auto-scroll to keep cursor visible
524
477
  let scrollOffset = getTextInputScroll(props);
525
478
 
526
479
  if (props.focused) {
527
480
  const cursorOffset = getTextInputCursor(props);
528
- const cursorPos = offsetToWrappedPos(value, cursorOffset, cw);
481
+ const cursorPos = textLayout.offsetToPosition(cursorOffset);
529
482
  // Scroll down if cursor is below viewport
530
483
  if (cursorPos.line >= scrollOffset + ch) {
531
484
  scrollOffset = cursorPos.line - ch + 1;
@@ -540,8 +493,8 @@ function paintTextInput(
540
493
  // Paint visible lines (grapheme-aware) in content area
541
494
  for (let row = 0; row < ch; row++) {
542
495
  const lineIdx = scrollOffset + row;
543
- if (lineIdx >= lines.length) break;
544
- const line = lines[lineIdx]!;
496
+ if (lineIdx >= textLayout.lineCount) break;
497
+ const line = textLayout.lines[lineIdx]!.text;
545
498
  paintLineGraphemes(line, cx, cy + row, cw, clipRect, props, buf);
546
499
  }
547
500
 
@@ -551,7 +504,7 @@ function paintTextInput(
551
504
  // for blinking.
552
505
  if (props.focused) {
553
506
  const cursorOffset = getTextInputCursor(props);
554
- const pos = offsetToWrappedPos(value, cursorOffset, cw);
507
+ const pos = textLayout.offsetToPosition(cursorOffset);
555
508
  const screenRow = pos.line - scrollOffset;
556
509
  if (screenRow >= 0 && screenRow < ch && pos.col < cw) {
557
510
  const absX = cx + pos.col;
@@ -581,43 +534,6 @@ function paintTextInput(
581
534
  }
582
535
  }
583
536
 
584
- /**
585
- * Map a cursor offset in the raw value to a (line, col) position
586
- * in the word-wrapped output.
587
- */
588
- function offsetToWrappedPos(
589
- value: string,
590
- cursor: number,
591
- width: number,
592
- ): { line: number; col: number } {
593
- const rawLines = value.split("\n");
594
- let offset = 0;
595
- let wrappedLine = 0;
596
-
597
- for (const rawLine of rawLines) {
598
- if (cursor <= offset + rawLine.length) {
599
- // Cursor is in this raw line
600
- const colInRaw = cursor - offset;
601
- if (width <= 0) return { line: wrappedLine, col: colInRaw };
602
- // Compute visible width of text before cursor
603
- const textBeforeCursor = rawLine.slice(0, colInRaw);
604
- const vw = visibleWidth(textBeforeCursor);
605
- const extraLines = Math.floor(vw / width);
606
- return { line: wrappedLine + extraLines, col: vw % width };
607
- }
608
- // Count wrapped lines for this raw line
609
- const lineVW = visibleWidth(rawLine);
610
- if (lineVW <= width || width <= 0) {
611
- wrappedLine += 1;
612
- } else {
613
- wrappedLine += Math.ceil(lineVW / width);
614
- }
615
- offset += rawLine.length + 1; // +1 for \n
616
- }
617
-
618
- return { line: wrappedLine, col: 0 };
619
- }
620
-
621
537
  // --- Framework-managed state ---
622
538
 
623
539
  import type { ContainerProps } from "@cel-tui/types";
@@ -653,7 +569,8 @@ export function getTextInputCursorScreenPos(
653
569
  if (cw <= 0 || ch <= 0) return null;
654
570
 
655
571
  const cursorOffset = getTextInputCursor(props);
656
- const pos = offsetToWrappedPos(props.value, cursorOffset, cw);
572
+ const textLayout = layoutText(props.value, cw, "word");
573
+ const pos = textLayout.offsetToPosition(cursorOffset);
657
574
  const scrollOffset = getTextInputScroll(props);
658
575
  const screenRow = pos.line - scrollOffset;
659
576
 
@@ -692,58 +609,3 @@ export function setTextInputScroll(
692
609
  ): void {
693
610
  textInputScrolls.set(props.onChange, scroll);
694
611
  }
695
-
696
- /**
697
- * Simple word-wrap: break a line into multiple lines at word boundaries.
698
- */
699
- function wrapLine(line: string, width: number, out: string[]): void {
700
- if (width <= 0) return;
701
-
702
- let current = "";
703
- let currentW = 0;
704
- const words = line.split(" ");
705
-
706
- for (const word of words) {
707
- const wordW = visibleWidth(word);
708
- if (currentW === 0) {
709
- current = word;
710
- currentW = wordW;
711
- } else if (currentW + 1 + wordW <= width) {
712
- current += " " + word;
713
- currentW += 1 + wordW;
714
- } else {
715
- out.push(current);
716
- current = word;
717
- currentW = wordW;
718
- }
719
-
720
- // Handle words longer than width (break by grapheme)
721
- while (currentW > width) {
722
- let taken = "";
723
- let takenW = 0;
724
- let rest = "";
725
- let inRest = false;
726
- for (const { segment } of segmenter.segment(current)) {
727
- if (inRest) {
728
- rest += segment;
729
- continue;
730
- }
731
- const gw = visibleWidth(segment);
732
- if (takenW + gw > width) {
733
- rest += segment;
734
- inRest = true;
735
- } else {
736
- taken += segment;
737
- takenW += gw;
738
- }
739
- }
740
- out.push(taken);
741
- current = rest;
742
- currentW = visibleWidth(rest);
743
- }
744
- }
745
-
746
- if (current.length > 0) {
747
- out.push(current);
748
- }
749
- }
package/src/scroll.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { LayoutNode } from "./layout.js";
2
+ import { layoutText } from "./text-layout.js";
3
+
4
+ function isVerticalScrollTarget(target: LayoutNode): boolean {
5
+ return target.node.type === "vstack" || target.node.type === "textinput";
6
+ }
7
+
8
+ function getScrollTargetProps(target: LayoutNode): Record<string, any> {
9
+ return target.node.props as Record<string, any>;
10
+ }
11
+
12
+ function getScrollViewportMainAxisSize(target: LayoutNode): number {
13
+ const props = getScrollTargetProps(target);
14
+ const isVertical = isVerticalScrollTarget(target);
15
+ const mainAxisSize = isVertical ? target.rect.height : target.rect.width;
16
+ const mainAxisPadding = isVertical
17
+ ? (props.padding?.y ?? 0) * 2
18
+ : (props.padding?.x ?? 0) * 2;
19
+ return Math.max(1, mainAxisSize - mainAxisPadding);
20
+ }
21
+
22
+ /**
23
+ * Resolve the mouse wheel scroll step for a scrollable layout node.
24
+ * Uses the explicit `scrollStep` prop when provided, otherwise an
25
+ * adaptive default based on the visible main-axis viewport size.
26
+ */
27
+ export function getScrollStep(target: LayoutNode): number {
28
+ const rawStep = getScrollTargetProps(target).scrollStep;
29
+ if (Number.isFinite(rawStep) && rawStep > 0) {
30
+ return Math.max(1, Math.floor(rawStep));
31
+ }
32
+
33
+ const viewport = getScrollViewportMainAxisSize(target);
34
+ return Math.max(3, Math.min(8, Math.floor(viewport / 3)));
35
+ }
36
+
37
+ /**
38
+ * Compute the maximum scroll offset for a scrollable layout node.
39
+ * Returns 0 if content fits within the viewport.
40
+ */
41
+ export function getMaxScrollOffset(target: LayoutNode): number {
42
+ const { rect, children } = target;
43
+
44
+ if (target.node.type === "textinput") {
45
+ const padX = (target.node.props.padding?.x ?? 0) * 2;
46
+ const padY = (target.node.props.padding?.y ?? 0) * 2;
47
+ const contentWidth = Math.max(1, rect.width - padX);
48
+ const contentHeight = Math.max(0, rect.height - padY);
49
+ const lineCount = layoutText(
50
+ target.node.props.value,
51
+ contentWidth,
52
+ "word",
53
+ ).lineCount;
54
+ return Math.max(0, lineCount - contentHeight);
55
+ }
56
+
57
+ const isVertical = target.node.type === "vstack";
58
+ const props = target.node.type !== "text" ? target.node.props : null;
59
+ const padX = (props as any)?.padding?.x ?? 0;
60
+ const padY = (props as any)?.padding?.y ?? 0;
61
+
62
+ if (isVertical) {
63
+ let contentHeight = 0;
64
+ for (const child of children) {
65
+ const childBottom = child.rect.y + child.rect.height - rect.y;
66
+ if (childBottom > contentHeight) contentHeight = childBottom;
67
+ }
68
+ return Math.max(0, contentHeight + padY - rect.height);
69
+ }
70
+
71
+ let contentWidth = 0;
72
+ for (const child of children) {
73
+ const childRight = child.rect.x + child.rect.width - rect.x;
74
+ if (childRight > contentWidth) contentWidth = childRight;
75
+ }
76
+ return Math.max(0, contentWidth + padX - rect.width);
77
+ }
@@ -0,0 +1,250 @@
1
+ import { visibleWidth } from "./width.js";
2
+
3
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
4
+
5
+ export interface VisualLine {
6
+ text: string;
7
+ startOffset: number;
8
+ endOffset: number;
9
+ width: number;
10
+ }
11
+
12
+ export interface VisualPosition {
13
+ line: number;
14
+ col: number;
15
+ }
16
+
17
+ export interface TextLayoutResult {
18
+ lines: VisualLine[];
19
+ lineCount: number;
20
+ offsetToPosition(offset: number): VisualPosition;
21
+ }
22
+
23
+ interface GraphemeInfo {
24
+ text: string;
25
+ startOffset: number;
26
+ endOffset: number;
27
+ width: number;
28
+ isWhitespace: boolean;
29
+ }
30
+
31
+ interface VisualLineData {
32
+ line: VisualLine;
33
+ graphemes: GraphemeInfo[];
34
+ }
35
+
36
+ interface Range {
37
+ start: number;
38
+ end: number;
39
+ }
40
+
41
+ export function layoutText(
42
+ value: string,
43
+ width: number,
44
+ wrap: "none" | "word",
45
+ ): TextLayoutResult {
46
+ const hardLines = splitHardLines(value);
47
+ const lines: VisualLineData[] = [];
48
+
49
+ for (const hardLine of hardLines) {
50
+ const graphemes = segmentLine(value, hardLine.start, hardLine.end);
51
+ if (wrap === "word") {
52
+ lines.push(
53
+ ...wrapGraphemes(value, graphemes, Math.max(1, width), hardLine),
54
+ );
55
+ } else {
56
+ lines.push(
57
+ ...buildVisualLines(
58
+ value,
59
+ graphemes,
60
+ [[0, graphemes.length]],
61
+ hardLine,
62
+ ),
63
+ );
64
+ }
65
+ }
66
+
67
+ if (lines.length === 0) {
68
+ const empty: VisualLineData = {
69
+ line: { text: "", startOffset: 0, endOffset: 0, width: 0 },
70
+ graphemes: [],
71
+ };
72
+ lines.push(empty);
73
+ }
74
+
75
+ return {
76
+ lines: lines.map((entry) => entry.line),
77
+ lineCount: lines.length,
78
+ offsetToPosition(offset: number): VisualPosition {
79
+ return offsetToPosition(lines, clamp(offset, 0, value.length));
80
+ },
81
+ };
82
+ }
83
+
84
+ function splitHardLines(value: string): Range[] {
85
+ const lines: Range[] = [];
86
+ let start = 0;
87
+ for (let i = 0; i < value.length; i++) {
88
+ if (value[i] === "\n") {
89
+ lines.push({ start, end: i });
90
+ start = i + 1;
91
+ }
92
+ }
93
+ lines.push({ start, end: value.length });
94
+ return lines;
95
+ }
96
+
97
+ function segmentLine(
98
+ value: string,
99
+ start: number,
100
+ end: number,
101
+ ): GraphemeInfo[] {
102
+ const graphemes: GraphemeInfo[] = [];
103
+ const text = value.slice(start, end);
104
+ let offset = start;
105
+
106
+ for (const { segment } of segmenter.segment(text)) {
107
+ graphemes.push({
108
+ text: segment,
109
+ startOffset: offset,
110
+ endOffset: offset + segment.length,
111
+ width: visibleWidth(segment),
112
+ isWhitespace: /^\s+$/u.test(segment),
113
+ });
114
+ offset += segment.length;
115
+ }
116
+
117
+ return graphemes;
118
+ }
119
+
120
+ function wrapGraphemes(
121
+ value: string,
122
+ graphemes: GraphemeInfo[],
123
+ width: number,
124
+ hardLine: Range,
125
+ ): VisualLineData[] {
126
+ if (graphemes.length === 0) {
127
+ return [makeVisualLine(value, graphemes, 0, 0, hardLine.start)];
128
+ }
129
+
130
+ const prefixWidths = [0];
131
+ for (const grapheme of graphemes) {
132
+ prefixWidths.push(prefixWidths[prefixWidths.length - 1]! + grapheme.width);
133
+ }
134
+
135
+ const ranges: Array<[number, number]> = [];
136
+ let lineStart = 0;
137
+
138
+ while (lineStart < graphemes.length) {
139
+ let lineEnd = lineStart;
140
+ let lastBreak = -1;
141
+
142
+ while (lineEnd < graphemes.length) {
143
+ const nextWidth = prefixWidths[lineEnd + 1]! - prefixWidths[lineStart]!;
144
+ if (nextWidth > width) break;
145
+ lineEnd++;
146
+ if (graphemes[lineEnd - 1]!.isWhitespace) {
147
+ lastBreak = lineEnd;
148
+ }
149
+ }
150
+
151
+ if (lineEnd >= graphemes.length) {
152
+ ranges.push([lineStart, graphemes.length]);
153
+ break;
154
+ }
155
+
156
+ if (lineEnd === lineStart) {
157
+ ranges.push([lineStart, lineStart + 1]);
158
+ lineStart++;
159
+ continue;
160
+ }
161
+
162
+ if (lastBreak > lineStart) {
163
+ ranges.push([lineStart, lastBreak]);
164
+ lineStart = lastBreak;
165
+ continue;
166
+ }
167
+
168
+ ranges.push([lineStart, lineEnd]);
169
+ lineStart = lineEnd;
170
+ }
171
+
172
+ return buildVisualLines(value, graphemes, ranges, hardLine);
173
+ }
174
+
175
+ function buildVisualLines(
176
+ value: string,
177
+ graphemes: GraphemeInfo[],
178
+ ranges: Array<[number, number]>,
179
+ hardLine: Range,
180
+ ): VisualLineData[] {
181
+ return ranges.map(([startIndex, endIndex]) =>
182
+ makeVisualLine(value, graphemes, startIndex, endIndex, hardLine.start),
183
+ );
184
+ }
185
+
186
+ function makeVisualLine(
187
+ value: string,
188
+ graphemes: GraphemeInfo[],
189
+ startIndex: number,
190
+ endIndex: number,
191
+ hardLineStart: number,
192
+ ): VisualLineData {
193
+ if (startIndex === endIndex) {
194
+ return {
195
+ line: {
196
+ text: "",
197
+ startOffset: hardLineStart,
198
+ endOffset: hardLineStart,
199
+ width: 0,
200
+ },
201
+ graphemes: [],
202
+ };
203
+ }
204
+
205
+ const lineGraphemes = graphemes.slice(startIndex, endIndex);
206
+ const startOffset = lineGraphemes[0]!.startOffset;
207
+ const endOffset = lineGraphemes[lineGraphemes.length - 1]!.endOffset;
208
+
209
+ return {
210
+ line: {
211
+ text: value.slice(startOffset, endOffset),
212
+ startOffset,
213
+ endOffset,
214
+ width: lineGraphemes.reduce((sum, grapheme) => sum + grapheme.width, 0),
215
+ },
216
+ graphemes: lineGraphemes,
217
+ };
218
+ }
219
+
220
+ function offsetToPosition(
221
+ lines: VisualLineData[],
222
+ offset: number,
223
+ ): VisualPosition {
224
+ for (let i = 1; i < lines.length; i++) {
225
+ if (offset === lines[i]!.line.startOffset) {
226
+ return { line: i, col: 0 };
227
+ }
228
+ }
229
+
230
+ for (let i = 0; i < lines.length; i++) {
231
+ const entry = lines[i]!;
232
+ const { line, graphemes } = entry;
233
+ if (offset < line.startOffset || offset > line.endOffset) continue;
234
+
235
+ let col = 0;
236
+ for (const grapheme of graphemes) {
237
+ if (offset <= grapheme.startOffset) break;
238
+ if (offset < grapheme.endOffset) break;
239
+ col += grapheme.width;
240
+ }
241
+ return { line: i, col };
242
+ }
243
+
244
+ const last = lines[lines.length - 1]!;
245
+ return { line: lines.length - 1, col: last.line.width };
246
+ }
247
+
248
+ function clamp(value: number, min: number, max: number): number {
249
+ return Math.max(min, Math.min(max, value));
250
+ }