@cel-tui/core 0.1.0 → 0.2.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.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Core framework engine for cel-tui — primitives, layout, rendering, input",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "src/**/*.ts",
17
- "!src/**/*.test.ts"
17
+ "!src/**/*.test.ts",
18
+ "!src/test-helpers.ts"
18
19
  ],
19
20
  "license": "MIT",
20
21
  "repository": {
package/src/cel.ts CHANGED
@@ -1,11 +1,11 @@
1
- import type { Node } from "@cel-tui/types";
1
+ import type { Node, Theme } from "@cel-tui/types";
2
2
  import { CellBuffer } from "./cell-buffer.js";
3
- import { emitBuffer, emitDiff } from "./emitter.js";
3
+ import { emitBuffer, emitDiff, defaultTheme } from "./emitter.js";
4
4
  import {
5
5
  hitTest,
6
6
  findClickHandler,
7
7
  findScrollTarget,
8
- findKeyPressHandler,
8
+ collectKeyPressHandlers,
9
9
  collectFocusable,
10
10
  } from "./hit-test.js";
11
11
  import { parseKey, isEditingKey, normalizeKey } from "./keys.js";
@@ -16,8 +16,7 @@ import {
16
16
  setTextInputCursor,
17
17
  getTextInputScroll,
18
18
  setTextInputScroll,
19
- getContainerScroll,
20
- setContainerScroll,
19
+ getTextInputCursorScreenPos,
21
20
  } from "./paint.js";
22
21
  import {
23
22
  insertChar,
@@ -32,6 +31,7 @@ import { visibleWidth } from "./width.js";
32
31
  type RenderFn = () => Node | Node[];
33
32
 
34
33
  let terminal: Terminal | null = null;
34
+ let activeTheme: Theme = defaultTheme;
35
35
  let renderFn: RenderFn | null = null;
36
36
  let renderScheduled = false;
37
37
  let prevBuffer: CellBuffer | null = null;
@@ -49,6 +49,17 @@ let frameworkFocusIndex = -1;
49
49
  /** The node whose props were stamped with `focused: true` during the last paint. */
50
50
  let stampedNode: LayoutNode | null = null;
51
51
 
52
+ /**
53
+ * Uncontrolled scroll offsets, keyed by structural tree path.
54
+ * The path is a string like "0/2/1" representing the DFS child indices
55
+ * from the layer root to the scrollable container. This survives re-renders
56
+ * because the structural position is the same even with new props objects.
57
+ */
58
+ const uncontrolledScrollOffsets = new Map<string, number>();
59
+
60
+ /** Nodes whose props were stamped with scrollOffset during the last paint. */
61
+ let stampedScrollNodes: { node: Node; key: string }[] = [];
62
+
52
63
  function doRender(): void {
53
64
  renderScheduled = false;
54
65
  if (!renderFn || !terminal) return;
@@ -81,8 +92,9 @@ function doRender(): void {
81
92
  currentLayouts.push(layoutTree);
82
93
  }
83
94
 
84
- // Stamp uncontrolled focus before painting so focusStyle and cursor work
95
+ // Stamp uncontrolled focus and scroll before painting
85
96
  stampUncontrolledFocus();
97
+ stampUncontrolledScroll();
86
98
 
87
99
  // Paint each layer into the buffer
88
100
  for (const layoutTree of currentLayouts) {
@@ -91,15 +103,79 @@ function doRender(): void {
91
103
 
92
104
  // Unstamp after paint so input handlers see clean props
93
105
  unstampUncontrolledFocus();
106
+ unstampUncontrolledScroll();
94
107
 
95
108
  // Emit to terminal — differential when possible
96
109
  if (prevBuffer) {
97
- const output = emitDiff(prevBuffer, currentBuffer);
110
+ const output = emitDiff(prevBuffer, currentBuffer, activeTheme);
98
111
  if (output.length > 0) terminal.write(output);
99
112
  } else {
100
- const output = emitBuffer(currentBuffer);
113
+ const output = emitBuffer(currentBuffer, activeTheme);
101
114
  terminal.write(output);
102
115
  }
116
+
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();
120
+ }
121
+
122
+ /**
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.
126
+ */
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)
132
+ 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();
145
+ }
146
+
147
+ /**
148
+ * Find the focused TextInput in the current layout tree.
149
+ * Checks controlled focus (props.focused) and uncontrolled
150
+ * (framework-tracked index). Returns the LayoutNode or null.
151
+ */
152
+ function findFocusedTextInputLayout(): LayoutNode | null {
153
+ // Check controlled focus: scan all layers for TextInput with focused: true
154
+ for (let i = currentLayouts.length - 1; i >= 0; i--) {
155
+ const found = findFocusedTIInTree(currentLayouts[i]!);
156
+ if (found) return found;
157
+ }
158
+ // Check uncontrolled focus
159
+ if (frameworkFocusIndex >= 0) {
160
+ const topLayer = currentLayouts[currentLayouts.length - 1];
161
+ if (topLayer) {
162
+ const focusables = collectFocusable(topLayer);
163
+ if (frameworkFocusIndex < focusables.length) {
164
+ const target = focusables[frameworkFocusIndex]!;
165
+ if (target.node.type === "textinput") return target;
166
+ }
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+
172
+ function findFocusedTIInTree(ln: LayoutNode): LayoutNode | null {
173
+ if (ln.node.type === "textinput" && ln.node.props.focused) return ln;
174
+ for (const child of ln.children) {
175
+ const found = findFocusedTIInTree(child);
176
+ if (found) return found;
177
+ }
178
+ return null;
103
179
  }
104
180
 
105
181
  // --- Input handling ---
@@ -252,6 +328,75 @@ function unstampUncontrolledFocus(): void {
252
328
  stampedNode = null;
253
329
  }
254
330
 
331
+ /**
332
+ * Compute the structural tree path from a layer root to a target LayoutNode.
333
+ * Returns a string like "0/2/1" or null if target is not in the tree.
334
+ */
335
+ function computeTreePath(root: LayoutNode, target: LayoutNode): string | null {
336
+ if (root === target) return "";
337
+ for (let i = 0; i < root.children.length; i++) {
338
+ const sub = computeTreePath(root.children[i]!, target);
339
+ if (sub !== null) return sub === "" ? String(i) : `${i}/${sub}`;
340
+ }
341
+ return null;
342
+ }
343
+
344
+ /**
345
+ * Get the full path key for a scrollable node, prefixed by layer index.
346
+ */
347
+ function getScrollPathKey(target: LayoutNode): string | null {
348
+ for (let i = 0; i < currentLayouts.length; i++) {
349
+ const path = computeTreePath(currentLayouts[i]!, target);
350
+ if (path !== null) return `L${i}:${path}`;
351
+ }
352
+ return null;
353
+ }
354
+
355
+ /**
356
+ * Stamp `scrollOffset` on uncontrolled scrollable containers' props
357
+ * before painting, so paint reads the correct offset.
358
+ */
359
+ function stampUncontrolledScroll(): void {
360
+ stampedScrollNodes = [];
361
+ if (uncontrolledScrollOffsets.size === 0) return;
362
+ for (let i = 0; i < currentLayouts.length; i++) {
363
+ walkAndStampScroll(currentLayouts[i]!, `L${i}:`);
364
+ }
365
+ }
366
+
367
+ function walkAndStampScroll(ln: LayoutNode, pathKey: string): void {
368
+ const node = ln.node;
369
+ if (node.type === "vstack" || node.type === "hstack") {
370
+ if (
371
+ node.props.overflow === "scroll" &&
372
+ node.props.scrollOffset === undefined &&
373
+ !node.props.onScroll
374
+ ) {
375
+ const offset = uncontrolledScrollOffsets.get(pathKey);
376
+ if (offset !== undefined && offset !== 0) {
377
+ (node.props as any).scrollOffset = offset;
378
+ stampedScrollNodes.push({ node, key: pathKey });
379
+ }
380
+ }
381
+ }
382
+ for (let i = 0; i < ln.children.length; i++) {
383
+ // Keys match getScrollPathKey format: "L0:" for root, "L0:2" for child 2, "L0:2/1" for grandchild
384
+ const childKey = pathKey.endsWith(":")
385
+ ? `${pathKey}${i}`
386
+ : `${pathKey}/${i}`;
387
+ walkAndStampScroll(ln.children[i]!, childKey);
388
+ }
389
+ }
390
+
391
+ function unstampUncontrolledScroll(): void {
392
+ for (const { node } of stampedScrollNodes) {
393
+ if (node.type === "vstack" || node.type === "hstack") {
394
+ delete (node.props as any).scrollOffset;
395
+ }
396
+ }
397
+ stampedScrollNodes = [];
398
+ }
399
+
255
400
  /**
256
401
  * Blur the currently focused element and focus a new one.
257
402
  * Manages both controlled (via onFocus/onBlur callbacks) and
@@ -328,6 +473,9 @@ function getMaxScrollOffset(target: LayoutNode): number {
328
473
  }
329
474
 
330
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;
331
479
 
332
480
  if (isVertical) {
333
481
  let contentHeight = 0;
@@ -335,22 +483,42 @@ function getMaxScrollOffset(target: LayoutNode): number {
335
483
  const childBottom = child.rect.y + child.rect.height - rect.y;
336
484
  if (childBottom > contentHeight) contentHeight = childBottom;
337
485
  }
338
- return Math.max(0, contentHeight - rect.height);
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);
339
489
  } else {
340
490
  let contentWidth = 0;
341
491
  for (const child of children) {
342
492
  const childRight = child.rect.x + child.rect.width - rect.x;
343
493
  if (childRight > contentWidth) contentWidth = childRight;
344
494
  }
345
- return Math.max(0, contentWidth - rect.width);
495
+ return Math.max(0, contentWidth + padX - rect.width);
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Resolve the current scroll offset for a layout node.
501
+ * Checks controlled (props.scrollOffset), then uncontrolled (path-based map).
502
+ */
503
+ function resolveScrollOffset(ln: import("./layout.js").LayoutNode): number {
504
+ const node = ln.node;
505
+ if (node.type === "text") return 0;
506
+ const props = node.props;
507
+ if ((props as any).scrollOffset !== undefined)
508
+ return (props as any).scrollOffset;
509
+ // Check uncontrolled map
510
+ const pathKey = getScrollPathKey(ln);
511
+ if (pathKey !== null) {
512
+ return uncontrolledScrollOffsets.get(pathKey) ?? 0;
346
513
  }
514
+ return 0;
347
515
  }
348
516
 
349
517
  function handleMouseEvent(event: MouseEvent): void {
350
518
  // Hit test on topmost layer first
351
519
  for (let i = currentLayouts.length - 1; i >= 0; i--) {
352
520
  const layoutRoot = currentLayouts[i]!;
353
- const path = hitTest(layoutRoot, event.x, event.y);
521
+ const path = hitTest(layoutRoot, event.x, event.y, resolveScrollOffset);
354
522
  if (path.length === 0) continue;
355
523
 
356
524
  if (event.type === "click") {
@@ -383,7 +551,7 @@ function handleMouseEvent(event: MouseEvent): void {
383
551
  cel.render();
384
552
  } else {
385
553
  const props = target.node.type !== "text" ? target.node.props : null;
386
- if (props && "onScroll" in props) {
554
+ if (props && (props as any).overflow === "scroll") {
387
555
  if (props.onScroll) {
388
556
  // Controlled scroll: notify app.
389
557
  // Use batch accumulator if available (multiple events in one chunk),
@@ -397,12 +565,18 @@ function handleMouseEvent(event: MouseEvent): void {
397
565
  Math.min(maxOffset, baseOffset + delta),
398
566
  );
399
567
  batchScrollOffsets?.set(props, newOffset);
400
- props.onScroll(newOffset);
568
+ props.onScroll(newOffset, maxOffset);
401
569
  } else {
402
- // Uncontrolled scroll: framework manages state
403
- const current = getContainerScroll(props);
404
- const clamped = Math.max(0, Math.min(maxOffset, current + delta));
405
- setContainerScroll(props, clamped);
570
+ // Uncontrolled scroll: framework manages state via path key
571
+ const pathKey = getScrollPathKey(target);
572
+ if (pathKey !== null) {
573
+ const current = uncontrolledScrollOffsets.get(pathKey) ?? 0;
574
+ const clamped = Math.max(
575
+ 0,
576
+ Math.min(maxOffset, current + delta),
577
+ );
578
+ uncontrolledScrollOffsets.set(pathKey, clamped);
579
+ }
406
580
  }
407
581
  cel.render();
408
582
  }
@@ -420,12 +594,11 @@ function findClickFocusTarget(path: LayoutNode[]): LayoutNode | null {
420
594
  for (let i = path.length - 1; i >= 0; i--) {
421
595
  const node = path[i]!.node;
422
596
  if (node.type === "textinput") return path[i]!;
423
- if (
424
- (node.type === "vstack" || node.type === "hstack") &&
425
- node.props.onClick &&
426
- node.props.focusable !== false
427
- ) {
428
- return path[i]!;
597
+ if (node.type === "vstack" || node.type === "hstack") {
598
+ const isFocusable =
599
+ node.props.focusable === true ||
600
+ (node.props.onClick != null && node.props.focusable !== false);
601
+ if (isFocusable) return path[i]!;
429
602
  }
430
603
  }
431
604
  return null;
@@ -539,11 +712,20 @@ function handleKeyEvent(key: string, rawData?: string): void {
539
712
  case "down":
540
713
  case "home":
541
714
  case "end":
542
- newState = moveCursor(
543
- editState,
544
- key as "left" | "right" | "up" | "down" | "home" | "end",
545
- focusedInput.rect.width,
546
- );
715
+ {
716
+ const tiPadX =
717
+ (focusedInput.node as import("@cel-tui/types").TextInputNode)
718
+ .props.padding?.x ?? 0;
719
+ const contentWidth = Math.max(
720
+ 0,
721
+ focusedInput.rect.width - tiPadX * 2,
722
+ );
723
+ newState = moveCursor(
724
+ editState,
725
+ key as "left" | "right" | "up" | "down" | "home" | "end",
726
+ contentWidth,
727
+ );
728
+ }
547
729
  break;
548
730
  case "enter":
549
731
  newState = insertChar(editState, "\n");
@@ -551,6 +733,12 @@ function handleKeyEvent(key: string, rawData?: string): void {
551
733
  case "tab":
552
734
  newState = insertChar(editState, "\t");
553
735
  break;
736
+ case "space":
737
+ newState = insertChar(editState, " ");
738
+ break;
739
+ case "plus":
740
+ newState = insertChar(editState, "+");
741
+ break;
554
742
  default:
555
743
  // Single printable character — use raw data to preserve case
556
744
  if (key.length === 1 && rawData && rawData.length === 1) {
@@ -578,10 +766,21 @@ function handleKeyEvent(key: string, rawData?: string): void {
578
766
  for (let i = currentLayouts.length - 1; i >= 0; i--) {
579
767
  const path = findPathTo(currentLayouts[i]!, focused);
580
768
  if (path) {
581
- const handler = findKeyPressHandler(path);
582
- if (handler) {
583
- handler.handler(key);
584
- cel.render();
769
+ const handlers = collectKeyPressHandlers(path);
770
+ if (handlers.length > 0) {
771
+ let consumed = false;
772
+ for (const h of handlers) {
773
+ const result = h.handler(key);
774
+ if (result !== false) {
775
+ consumed = true;
776
+ break;
777
+ }
778
+ // result === false → key not consumed, keep bubbling
779
+ }
780
+ // Always return — the key was offered to every handler in the
781
+ // focused element's path (including root). Even if all returned
782
+ // false, we don't retry via the unfocused fallback path.
783
+ if (consumed) cel.render();
585
784
  return;
586
785
  }
587
786
  }
@@ -592,9 +791,12 @@ function handleKeyEvent(key: string, rawData?: string): void {
592
791
  for (let i = currentLayouts.length - 1; i >= 0; i--) {
593
792
  const layoutRoot = currentLayouts[i]!;
594
793
  const path = [layoutRoot];
595
- const handler = findKeyPressHandler(path);
596
- if (handler) {
597
- handler.handler(key);
794
+ const handlers = collectKeyPressHandlers(path);
795
+ if (handlers.length > 0) {
796
+ for (const h of handlers) {
797
+ const result = h.handler(key);
798
+ if (result !== false) break;
799
+ }
598
800
  cel.render();
599
801
  return;
600
802
  }
@@ -663,10 +865,16 @@ export const cel = {
663
865
  * Initialize the framework with a terminal implementation.
664
866
  * Must be called before {@link cel.viewport}.
665
867
  *
868
+ * Enables the Kitty keyboard protocol (level 1) via the terminal,
869
+ * enters raw mode, and starts mouse tracking.
870
+ *
666
871
  * @param term - Terminal to render to (ProcessTerminal or MockTerminal).
872
+ * @param options - Optional configuration.
873
+ * @param options.theme - Color theme mapping. Defaults to the ANSI 16 theme.
667
874
  */
668
- init(term: Terminal): void {
875
+ init(term: Terminal, options?: { theme?: Theme }): void {
669
876
  terminal = term;
877
+ activeTheme = options?.theme ?? defaultTheme;
670
878
  terminal.start(handleInput, () => cel.render());
671
879
  },
672
880
 
@@ -695,6 +903,9 @@ export const cel = {
695
903
 
696
904
  /**
697
905
  * Stop the framework and restore terminal state.
906
+ *
907
+ * Pops the Kitty keyboard protocol mode, disables mouse tracking,
908
+ * and restores the terminal to its previous state.
698
909
  */
699
910
  stop(): void {
700
911
  terminal?.stop();
@@ -707,6 +918,9 @@ export const cel = {
707
918
  lastFocusedIndex = -1;
708
919
  frameworkFocusIndex = -1;
709
920
  stampedNode = null;
921
+ uncontrolledScrollOffsets.clear();
922
+ stampedScrollNodes = [];
923
+ activeTheme = defaultTheme;
710
924
  },
711
925
 
712
926
  /** @internal */
package/src/emitter.ts CHANGED
@@ -1,56 +1,76 @@
1
- import type { Color } from "@cel-tui/types";
1
+ import type { Color, Theme, ThemeValue } from "@cel-tui/types";
2
2
  import type { Cell } from "./cell-buffer.js";
3
3
  import { CellBuffer, EMPTY_CELL } from "./cell-buffer.js";
4
4
 
5
- // --- Color mapping ---
6
-
7
- const FG_CODES: Record<Color, number> = {
8
- black: 30,
9
- red: 31,
10
- green: 32,
11
- yellow: 33,
12
- blue: 34,
13
- magenta: 35,
14
- cyan: 36,
15
- white: 37,
16
- brightBlack: 90,
17
- brightRed: 91,
18
- brightGreen: 92,
19
- brightYellow: 93,
20
- brightBlue: 94,
21
- brightMagenta: 95,
22
- brightCyan: 96,
23
- brightWhite: 97,
24
- };
5
+ // --- Default ANSI 16 theme ---
25
6
 
26
- const BG_CODES: Record<Color, number> = {
27
- black: 40,
28
- red: 41,
29
- green: 42,
30
- yellow: 43,
31
- blue: 44,
32
- magenta: 45,
33
- cyan: 46,
34
- white: 47,
35
- brightBlack: 100,
36
- brightRed: 101,
37
- brightGreen: 102,
38
- brightYellow: 103,
39
- brightBlue: 104,
40
- brightMagenta: 105,
41
- brightCyan: 106,
42
- brightWhite: 107,
7
+ /**
8
+ * The default theme — maps each color slot to its matching ANSI palette
9
+ * index. With this theme, colors inherit the terminal's configured
10
+ * color scheme automatically.
11
+ */
12
+ export const defaultTheme: Theme = {
13
+ color00: 0,
14
+ color01: 1,
15
+ color02: 2,
16
+ color03: 3,
17
+ color04: 4,
18
+ color05: 5,
19
+ color06: 6,
20
+ color07: 7,
21
+ color08: 8,
22
+ color09: 9,
23
+ color10: 10,
24
+ color11: 11,
25
+ color12: 12,
26
+ color13: 13,
27
+ color14: 14,
28
+ color15: 15,
43
29
  };
44
30
 
31
+ // --- Color resolution ---
32
+
33
+ /** Parse a hex color string "#rrggbb" into [r, g, b]. */
34
+ function parseHex(hex: string): [number, number, number] {
35
+ const r = parseInt(hex.slice(1, 3), 16);
36
+ const g = parseInt(hex.slice(3, 5), 16);
37
+ const b = parseInt(hex.slice(5, 7), 16);
38
+ return [r, g, b];
39
+ }
40
+
41
+ /** Resolve a color slot to an SGR code fragment for foreground. */
42
+ function fgSgr(color: Color, theme: Theme): string {
43
+ const val: ThemeValue = theme[color];
44
+ if (typeof val === "number") {
45
+ // ANSI palette index 0-15
46
+ return String(val < 8 ? 30 + val : 82 + val);
47
+ }
48
+ // Hex true color
49
+ const [r, g, b] = parseHex(val);
50
+ return `38;2;${r};${g};${b}`;
51
+ }
52
+
53
+ /** Resolve a color slot to an SGR code fragment for background. */
54
+ function bgSgr(color: Color, theme: Theme): string {
55
+ const val: ThemeValue = theme[color];
56
+ if (typeof val === "number") {
57
+ // ANSI palette index 0-15
58
+ return String(val < 8 ? 40 + val : 92 + val);
59
+ }
60
+ // Hex true color
61
+ const [r, g, b] = parseHex(val);
62
+ return `48;2;${r};${g};${b}`;
63
+ }
64
+
45
65
  // --- SGR generation ---
46
66
 
47
- function sgrForCell(cell: Cell): string {
48
- const codes: number[] = [];
49
- if (cell.bold) codes.push(1);
50
- if (cell.italic) codes.push(3);
51
- if (cell.underline) codes.push(4);
52
- if (cell.fgColor) codes.push(FG_CODES[cell.fgColor]);
53
- if (cell.bgColor) codes.push(BG_CODES[cell.bgColor]);
67
+ function sgrForCell(cell: Cell, theme: Theme): string {
68
+ const codes: string[] = [];
69
+ if (cell.bold) codes.push("1");
70
+ if (cell.italic) codes.push("3");
71
+ if (cell.underline) codes.push("4");
72
+ if (cell.fgColor) codes.push(fgSgr(cell.fgColor, theme));
73
+ if (cell.bgColor) codes.push(bgSgr(cell.bgColor, theme));
54
74
  if (codes.length === 0) return "";
55
75
  return `\x1b[${codes.join(";")}m`;
56
76
  }
@@ -93,9 +113,13 @@ const RESET = "\x1b[0m";
93
113
  * only emitting SGR codes when the style changes.
94
114
  *
95
115
  * @param buf - The cell buffer to render.
116
+ * @param theme - Color theme mapping. Defaults to the ANSI 16 theme.
96
117
  * @returns A complete ANSI string ready to write to the terminal.
97
118
  */
98
- export function emitBuffer(buf: CellBuffer): string {
119
+ export function emitBuffer(
120
+ buf: CellBuffer,
121
+ theme: Theme = defaultTheme,
122
+ ): string {
99
123
  let out = SYNC_START + CURSOR_HOME;
100
124
 
101
125
  let lastStyle: Cell | null = null;
@@ -106,13 +130,16 @@ export function emitBuffer(buf: CellBuffer): string {
106
130
  for (let x = 0; x < buf.width; x++) {
107
131
  const cell = buf.get(x, y);
108
132
 
133
+ // Skip continuation cells (trailing half of wide characters)
134
+ if (cell.char === "") continue;
135
+
109
136
  // Emit style change if needed
110
137
  if (lastStyle === null || !styleEquals(cell, lastStyle)) {
111
138
  // Reset before applying new style
112
139
  if (lastStyle !== null && hasStyle(lastStyle)) {
113
140
  out += RESET;
114
141
  }
115
- const sgr = sgrForCell(cell);
142
+ const sgr = sgrForCell(cell, theme);
116
143
  if (sgr) out += sgr;
117
144
  lastStyle = cell;
118
145
  }
@@ -139,9 +166,14 @@ export function emitBuffer(buf: CellBuffer): string {
139
166
  *
140
167
  * @param prev - The previous buffer.
141
168
  * @param next - The new buffer.
169
+ * @param theme - Color theme mapping. Defaults to the ANSI 16 theme.
142
170
  * @returns An ANSI string with only the changed cells.
143
171
  */
144
- export function emitDiff(prev: CellBuffer, next: CellBuffer): string {
172
+ export function emitDiff(
173
+ prev: CellBuffer,
174
+ next: CellBuffer,
175
+ theme: Theme = defaultTheme,
176
+ ): string {
145
177
  let out = SYNC_START;
146
178
 
147
179
  const changes = prev.diff(next);
@@ -156,6 +188,9 @@ export function emitDiff(prev: CellBuffer, next: CellBuffer): string {
156
188
  for (const { x, y } of changes) {
157
189
  const cell = next.get(x, y);
158
190
 
191
+ // Skip continuation cells (trailing half of wide characters)
192
+ if (cell.char === "") continue;
193
+
159
194
  // Position cursor if not consecutive
160
195
  if (y !== lastY || x !== lastX + 1) {
161
196
  // Reset style before repositioning
@@ -172,7 +207,7 @@ export function emitDiff(prev: CellBuffer, next: CellBuffer): string {
172
207
  if (lastStyle !== null && hasStyle(lastStyle)) {
173
208
  out += RESET;
174
209
  }
175
- const sgr = sgrForCell(cell);
210
+ const sgr = sgrForCell(cell, theme);
176
211
  if (sgr) out += sgr;
177
212
  lastStyle = cell;
178
213
  }