@cel-tui/core 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cel-tui/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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.1",
38
38
  "get-east-asian-width": "^1.5.0"
39
39
  },
40
40
  "peerDependencies": {
package/src/cel.ts CHANGED
@@ -1,4 +1,9 @@
1
- import type { Node, Theme } from "@cel-tui/types";
1
+ import type {
2
+ Node,
3
+ TextInputNode,
4
+ TextInputProps,
5
+ Theme,
6
+ } from "@cel-tui/types";
2
7
  import { CellBuffer } from "./cell-buffer.js";
3
8
  import { emitBuffer, emitDiff, defaultTheme } from "./emitter.js";
4
9
  import {
@@ -8,7 +13,7 @@ import {
8
13
  collectKeyPressHandlers,
9
14
  collectFocusable,
10
15
  } from "./hit-test.js";
11
- import { parseKey, isEditingKey } from "./keys.js";
16
+ import { decodeKeyEvents, isEditingKey, type KeyInput } from "./keys.js";
12
17
  import { layout, type LayoutNode } from "./layout.js";
13
18
  import {
14
19
  paint,
@@ -26,10 +31,18 @@ import {
26
31
  type EditState,
27
32
  } from "./text-edit.js";
28
33
  import type { Terminal } from "./terminal.js";
29
- import { visibleWidth } from "./width.js";
34
+ import { getMaxScrollOffset, getScrollStep } from "./scroll.js";
30
35
 
31
36
  type RenderFn = () => Node | Node[];
32
37
 
38
+ type TerminalCursorState =
39
+ | { visible: false }
40
+ | {
41
+ visible: true;
42
+ x: number;
43
+ y: number;
44
+ };
45
+
33
46
  let terminal: Terminal | null = null;
34
47
  let activeTheme: Theme = defaultTheme;
35
48
  let renderFn: RenderFn | null = null;
@@ -60,6 +73,9 @@ const uncontrolledScrollOffsets = new Map<string, number>();
60
73
  /** Nodes whose props were stamped with scrollOffset during the last paint. */
61
74
  let stampedScrollNodes: { node: Node; key: string }[] = [];
62
75
 
76
+ /** The cursor state currently expected on the terminal. */
77
+ let lastTerminalCursor: TerminalCursorState | null = { visible: false };
78
+
63
79
  function doRender(): void {
64
80
  renderScheduled = false;
65
81
  if (!renderFn || !terminal) return;
@@ -105,43 +121,38 @@ function doRender(): void {
105
121
  unstampUncontrolledFocus();
106
122
  unstampUncontrolledScroll();
107
123
 
124
+ const nextCursor = getDesiredTerminalCursor();
125
+
108
126
  // Emit to terminal — differential when possible
109
127
  if (prevBuffer) {
110
- const output = emitDiff(prevBuffer, currentBuffer, activeTheme);
128
+ const output = emitDiff(prevBuffer, currentBuffer, activeTheme, {
129
+ cursor: nextCursor,
130
+ previousCursor: lastTerminalCursor,
131
+ });
111
132
  if (output.length > 0) terminal.write(output);
112
133
  } else {
113
- const output = emitBuffer(currentBuffer, activeTheme);
134
+ const output = emitBuffer(currentBuffer, activeTheme, {
135
+ cursor: nextCursor,
136
+ previousCursor: lastTerminalCursor,
137
+ });
114
138
  terminal.write(output);
115
139
  }
116
140
 
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();
141
+ lastTerminalCursor = nextCursor;
120
142
  }
121
143
 
122
144
  /**
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.
145
+ * Resolve the final terminal cursor state for the current frame.
146
+ * The cursor is shown only for a focused TextInput whose cursor is visible
147
+ * within the clipped viewport.
126
148
  */
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)
149
+ function getDesiredTerminalCursor(): TerminalCursorState {
132
150
  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();
151
+ if (!focusedTI) return { visible: false };
152
+
153
+ const props = focusedTI.node.props as import("@cel-tui/types").TextInputProps;
154
+ const pos = getTextInputCursorScreenPos(props, focusedTI.rect);
155
+ return pos ? { visible: true, x: pos.x, y: pos.y } : { visible: false };
145
156
  }
146
157
 
147
158
  /**
@@ -189,25 +200,49 @@ const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
189
200
  // need to remember the offset we already dispatched via onScroll.
190
201
  let batchScrollOffsets: Map<object, number> | null = null;
191
202
 
203
+ // Tracks the focused TextInput's latest edit state during a batched keyboard
204
+ // chunk. The layout tree does not re-render until the next tick, so subsequent
205
+ // keys in the same chunk must see the updated value/cursor immediately.
206
+ let batchTextInputEdits: Map<TextInputProps, EditState> | null = null;
207
+
192
208
  function handleInput(data: string): void {
193
- // Terminals may batch multiple mouse events into one data chunk.
194
- // Scan for all SGR mouse sequences and handle each one.
195
209
  SGR_MOUSE_RE.lastIndex = 0;
196
- let match = SGR_MOUSE_RE.exec(data);
197
- if (match) {
198
- batchScrollOffsets = new Map();
210
+ batchTextInputEdits = new Map();
211
+
212
+ let lastIndex = 0;
213
+
214
+ try {
215
+ let match = SGR_MOUSE_RE.exec(data);
199
216
  while (match) {
217
+ if (match.index > lastIndex) {
218
+ handleKeyChunk(data.slice(lastIndex, match.index));
219
+ }
220
+
221
+ if (batchScrollOffsets === null) {
222
+ batchScrollOffsets = new Map();
223
+ }
224
+
200
225
  const mouse = parseSgrMatch(match);
201
226
  if (mouse) handleMouseEvent(mouse);
227
+
228
+ lastIndex = match.index + match[0].length;
202
229
  match = SGR_MOUSE_RE.exec(data);
203
230
  }
231
+
232
+ if (lastIndex < data.length) {
233
+ handleKeyChunk(data.slice(lastIndex));
234
+ }
235
+ } finally {
204
236
  batchScrollOffsets = null;
205
- return;
237
+ batchTextInputEdits = null;
206
238
  }
239
+ }
207
240
 
208
- // Keyboard input
209
- const key = parseKey(data);
210
- handleKeyEvent(key, data);
241
+ function handleKeyChunk(data: string): void {
242
+ if (data.length === 0) return;
243
+ for (const key of decodeKeyEvents(data)) {
244
+ handleKeyEvent(key);
245
+ }
211
246
  }
212
247
 
213
248
  interface MouseEvent {
@@ -453,49 +488,6 @@ function changeFocus(target: LayoutNode | null): void {
453
488
  }
454
489
  }
455
490
 
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
491
  /**
500
492
  * Resolve the current scroll offset for a layout node.
501
493
  * Checks controlled (props.scrollOffset), then uncontrolled (path-based map).
@@ -539,7 +531,8 @@ function handleMouseEvent(event: MouseEvent): void {
539
531
  if (event.type === "scroll-up" || event.type === "scroll-down") {
540
532
  const target = findScrollTarget(path);
541
533
  if (target) {
542
- const delta = event.type === "scroll-up" ? -1 : 1;
534
+ const step = getScrollStep(target);
535
+ const delta = event.type === "scroll-up" ? -step : step;
543
536
  const maxOffset = getMaxScrollOffset(target);
544
537
 
545
538
  if (target.node.type === "textinput") {
@@ -608,7 +601,18 @@ function findClickFocusTarget(path: LayoutNode[]): LayoutNode | null {
608
601
  return null;
609
602
  }
610
603
 
611
- function handleKeyEvent(key: string, rawData?: string): void {
604
+ function getTextInputEditState(props: TextInputProps): EditState {
605
+ const batched = batchTextInputEdits?.get(props);
606
+ if (batched) return batched;
607
+ return {
608
+ value: props.value,
609
+ cursor: getTextInputCursor(props),
610
+ };
611
+ }
612
+
613
+ function handleKeyEvent(event: KeyInput): void {
614
+ const { key, text } = event;
615
+
612
616
  // --- Focus traversal keys ---
613
617
 
614
618
  // Tab / Shift+Tab: cycle through focusable elements
@@ -682,12 +686,10 @@ function handleKeyEvent(key: string, rawData?: string): void {
682
686
  }
683
687
 
684
688
  // --- TextInput key routing ---
685
- // Find the focused TextInput (if any) to route editing keys
686
689
  const focusedInput = findFocusedTextInput();
687
690
 
688
691
  if (focusedInput) {
689
- const props = focusedInput.node
690
- .props as import("@cel-tui/types").TextInputProps;
692
+ const props = focusedInput.node.props as TextInputProps;
691
693
 
692
694
  // onKeyPress fires before editing — return false prevents the default action
693
695
  if (props.onKeyPress) {
@@ -698,12 +700,12 @@ function handleKeyEvent(key: string, rawData?: string): void {
698
700
  }
699
701
  }
700
702
 
701
- // Editing keys are consumed by TextInput
702
- if (isEditingKey(key)) {
703
- const cursor = getTextInputCursor(props);
704
- const editState: EditState = { value: props.value, cursor };
705
- let newState: EditState | null = null;
703
+ let newState: EditState | null = null;
704
+ const editState = getTextInputEditState(props);
706
705
 
706
+ if (text !== undefined) {
707
+ newState = insertChar(editState, text);
708
+ } else if (isEditingKey(key)) {
707
709
  switch (key) {
708
710
  case "backspace":
709
711
  newState = deleteBackward(editState);
@@ -719,8 +721,7 @@ function handleKeyEvent(key: string, rawData?: string): void {
719
721
  case "end":
720
722
  {
721
723
  const tiPadX =
722
- (focusedInput.node as import("@cel-tui/types").TextInputNode)
723
- .props.padding?.x ?? 0;
724
+ (focusedInput.node as TextInputNode).props.padding?.x ?? 0;
724
725
  const contentWidth = Math.max(
725
726
  0,
726
727
  focusedInput.rect.width - tiPadX * 2,
@@ -745,24 +746,17 @@ function handleKeyEvent(key: string, rawData?: string): void {
745
746
  case "plus":
746
747
  newState = insertChar(editState, "+");
747
748
  break;
748
- default:
749
- // Single printable character — use raw data to preserve case
750
- if (key.length === 1 && rawData && rawData.length === 1) {
751
- newState = insertChar(editState, rawData);
752
- } else if (key.length === 1) {
753
- newState = insertChar(editState, key);
754
- }
755
- break;
756
749
  }
750
+ }
757
751
 
758
- if (newState && newState !== editState) {
759
- setTextInputCursor(props, newState.cursor);
760
- if (newState.value !== editState.value) {
761
- props.onChange(newState.value);
762
- }
763
- cel.render();
764
- return;
752
+ if (newState && newState !== editState) {
753
+ batchTextInputEdits?.set(props, newState);
754
+ setTextInputCursor(props, newState.cursor);
755
+ if (newState.value !== editState.value) {
756
+ props.onChange(newState.value);
765
757
  }
758
+ cel.render();
759
+ return;
766
760
  }
767
761
  }
768
762
 
@@ -890,6 +884,7 @@ export const cel = {
890
884
  init(term: Terminal, options?: { theme?: Theme }): void {
891
885
  terminal = term;
892
886
  activeTheme = options?.theme ?? defaultTheme;
887
+ lastTerminalCursor = { visible: false };
893
888
  terminal.start(handleInput, () => cel.render());
894
889
  },
895
890
 
@@ -935,6 +930,7 @@ export const cel = {
935
930
  stampedNode = null;
936
931
  uncontrolledScrollOffsets.clear();
937
932
  stampedScrollNodes = [];
933
+ lastTerminalCursor = { visible: false };
938
934
  activeTheme = defaultTheme;
939
935
  },
940
936
 
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
  }