@termuijs/core 0.1.3 → 0.1.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @termuijs/core
2
2
 
3
- The rendering engine that sits at the bottom of the TermUI stack. Screen buffers, layout, input parsing, events, and styling. Everything else in the framework builds on this.
3
+ The rendering engine at the bottom of the TermUI stack. Screen buffers, flexbox layout, input parsing, events, styling, string utilities, and capability flags. Everything else in the framework builds on this.
4
4
 
5
5
  ## Install
6
6
 
@@ -10,41 +10,71 @@ npm install @termuijs/core
10
10
 
11
11
  ## What's in the box
12
12
 
13
- - **Screen** Double-buffered cell grid. Diffs the previous frame against the new one so only changed cells get written to stdout.
14
- - **LayoutEngine** Flexbox-inspired positioning: `flexDirection`, `flexGrow`, `flexShrink`, `alignItems`, `justifyContent`, percentage sizing. All calculated in character cells.
15
- - **InputParser** Converts raw stdin bytes into typed `KeyEvent` objects. Handles escape sequences, Ctrl combos, and multi-byte UTF-8.
16
- - **EventEmitter** Type-safe `on`, `off`, `once`, `emit`. Events bubble from focused widget up through parents.
17
- - **FocusManager** Tab cycling between widgets, focus traps for modals, focus groups for arrow key navigation within a panel.
18
- - **Style** Colors (RGB, hex, named), border styles (single, double, rounded, bold), padding, margin.
19
- - **LayerManager** Z-indexed overlays. Modals and dropdowns render above the base layer without z-fighting.
20
- - **App** The entry point. Mounts your widget tree, starts the render loop, and routes input to the focused widget.
13
+ - **Screen** - Double-buffered cell grid. Diffs the previous frame against the new one so only changed cells get written to stdout.
14
+ - **LayoutEngine** - Flexbox positioning: `flexDirection`, `flexGrow`, `flexShrink`, `alignItems`, `justifyContent`, percentage sizing. All calculated in character cells.
15
+ - **InputParser** - Converts raw stdin bytes into typed `KeyEvent` objects. Handles escape sequences, Ctrl combos, and multi-byte UTF-8.
16
+ - **EventEmitter** - Type-safe `on`, `off`, `once`, `emit`. Events bubble from the focused widget up through parents.
17
+ - **FocusManager** - Tab cycling between widgets, focus traps for modals, focus groups for arrow key navigation.
18
+ - **Style** - Colors (RGB, hex, named), border styles (single, double, rounded, bold), padding, margin.
19
+ - **LayerManager** - Z-indexed overlays. Modals and dropdowns render above the base layer without z-fighting.
20
+ - **App** - Mounts your widget tree, starts the render loop, and routes input to the focused widget.
21
+ - **Timer pool** - Shared tick pool for animations. All intervals share one `setInterval` at 16ms.
22
+ - **caps flags** - Runtime capability detection for unicode, motion, and color support.
23
+ - **String utilities** - `stringWidth`, `truncate`, `wordWrap`, `stripAnsi` for CJK-aware terminal text.
24
+ - **WCAG utilities** - `contrastRatio`, `meetsAA`, `meetsAAA` for accessible color combinations.
21
25
 
22
- ## Usage
26
+ ## Capability flags
27
+
28
+ The `caps` object reports what the current terminal environment supports:
23
29
 
24
30
  ```typescript
25
- import { App, Screen, Style } from '@termuijs/core'
31
+ import { caps } from '@termuijs/core'
32
+
33
+ caps.unicode // false when NO_UNICODE=1 — use ASCII fallbacks
34
+ caps.motion // false when NO_MOTION=1 — skip animations
35
+ caps.color // false when NO_COLOR=1 — skip ANSI color codes
36
+ ```
26
37
 
27
- const app = new App()
38
+ These are evaluated once at module load. All built-in widgets check them automatically. Use them in your own code to provide ASCII fallbacks:
28
39
 
29
- // Screen is the cell buffer
30
- const screen = app.screen
31
- screen.setCell(0, 0, { char: 'H', fg: 'red' })
40
+ ```typescript
41
+ import { caps } from '@termuijs/core'
32
42
 
33
- // Start the render loop
34
- app.start()
43
+ const bullet = caps.unicode ? '●' : '*'
44
+ const bar = caps.unicode ? '█' : '#'
35
45
  ```
36
46
 
37
- ## Event bubbling
47
+ Set `NO_UNICODE=1 NO_MOTION=1` in CI to test ASCII output without a real terminal.
38
48
 
39
- Key events start at the focused widget and bubble up through its parents. Stop propagation at any level.
49
+ ## String utilities
40
50
 
41
51
  ```typescript
42
- import { createKeyEvent } from '@termuijs/core'
52
+ import { stringWidth, truncate, wordWrap, stripAnsi } from '@termuijs/core'
53
+
54
+ stringWidth('你好') // 4 (each CJK char = 2 columns)
55
+ truncate('Hello World', 8) // 'Hello W…'
56
+ wordWrap('The quick brown fox', 10) // wraps at word boundaries
57
+ stripAnsi('\x1b[32mHello\x1b[0m') // 'Hello'
58
+ ```
59
+
60
+ ## WCAG color utilities
61
+
62
+ ```typescript
63
+ import { contrastRatio, meetsAA, meetsAAA } from '@termuijs/core'
64
+
65
+ contrastRatio('#ffffff', '#000000') // 21
66
+ meetsAA('#00ff88', '#0a0a0f') // true (>= 4.5:1)
67
+ meetsAAA('#ffffff', '#333333') // false (< 7:1)
68
+ ```
69
+
70
+ ## Event bubbling
43
71
 
72
+ Key events start at the focused widget and bubble up through its parents.
73
+
74
+ ```typescript
44
75
  widget.on('key', (event) => {
45
76
  if (event.key === 'enter') {
46
77
  event.stopPropagation()
47
- // handled here, parents won't see it
48
78
  }
49
79
  })
50
80
  ```
@@ -55,15 +85,25 @@ Widgets clip their children by default. Nothing renders outside a widget's bound
55
85
 
56
86
  ```typescript
57
87
  screen.pushClip({ x: 5, y: 5, width: 20, height: 10 })
58
- // setCell calls outside this rect are silently discarded
59
88
  screen.popClip()
60
89
  ```
61
90
 
62
- ## Batched rendering
91
+ ## Timer pool
92
+
93
+ Use `timerPoolSubscribe` instead of `setInterval` for animations. All subscribers share one underlying timer, reducing CPU overhead.
63
94
 
64
- State changes are batched via `queueMicrotask`. Multiple `setState` calls in the same tick produce a single render pass, not three.
95
+ ```typescript
96
+ import { timerPoolSubscribe } from '@termuijs/core'
97
+
98
+ const unsub = timerPoolSubscribe(16, () => {
99
+ // runs every ~16ms (60fps)
100
+ })
101
+
102
+ // Clean up
103
+ unsub()
104
+ ```
65
105
 
66
- ## API reference
106
+ ## Documentation
67
107
 
68
108
  Full docs at [www.termui.io/docs/core/overview](https://www.termui.io/docs/core/overview).
69
109
 
package/dist/index.cjs CHANGED
@@ -21,9 +21,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  App: () => App,
24
+ BLOCK: () => BLOCK,
24
25
  BORDER_CHARS: () => BORDER_CHARS,
26
+ BOX: () => BOX,
25
27
  BRAILLE_DOTS: () => BRAILLE_DOTS,
26
28
  BRAILLE_OFFSET: () => BRAILLE_OFFSET,
29
+ BRAILLE_SPIN: () => BRAILLE_SPIN,
27
30
  BarSets: () => BarSets,
28
31
  BorderSets: () => BorderSets,
29
32
  CTRL_KEYS: () => CTRL_KEYS,
@@ -44,12 +47,14 @@ __export(index_exports, {
44
47
  VERTICAL_BAR_SYMBOLS: () => VERTICAL_BAR_SYMBOLS,
45
48
  ansi: () => ansi_exports,
46
49
  borderSize: () => borderSize,
50
+ caps: () => caps,
47
51
  cellsEqual: () => cellsEqual,
48
52
  colorToAnsiBg: () => colorToAnsiBg,
49
53
  colorToAnsiFg: () => colorToAnsiFg,
50
54
  colorToRgb: () => colorToRgb,
51
55
  computeLayout: () => computeLayout,
52
56
  containsPoint: () => containsPoint,
57
+ contrastRatio: () => contrastRatio,
53
58
  createKeyEvent: () => createKeyEvent,
54
59
  createLayoutNode: () => createLayoutNode,
55
60
  createTestScreen: () => createTestScreen,
@@ -70,6 +75,7 @@ __export(index_exports, {
70
75
  parseMouseEvent: () => parseMouseEvent,
71
76
  percentage: () => percentage,
72
77
  ratio: () => ratio,
78
+ relativeLuminance: () => relativeLuminance,
73
79
  renderFallback: () => renderFallback,
74
80
  shouldUseFallback: () => shouldUseFallback,
75
81
  shrinkRect: () => shrinkRect,
@@ -84,7 +90,10 @@ __export(index_exports, {
84
90
  testScreenToString: () => testScreenToString,
85
91
  truncate: () => truncate,
86
92
  unionRect: () => unionRect,
87
- wordWrap: () => wordWrap
93
+ validateThemeContrast: () => validateThemeContrast,
94
+ wcagLevel: () => wcagLevel,
95
+ wordWrap: () => wordWrap,
96
+ writeClipboard: () => writeClipboard
88
97
  });
89
98
  module.exports = __toCommonJS(index_exports);
90
99
 
@@ -283,6 +292,56 @@ function colorToAnsiBg(color, depth) {
283
292
  return "";
284
293
  }
285
294
  }
295
+ function relativeLuminance(color) {
296
+ const [r, g, b] = colorToRgb(color);
297
+ const linearize = (c) => {
298
+ const sRGB = c / 255;
299
+ return sRGB <= 0.03928 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4);
300
+ };
301
+ return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
302
+ }
303
+ function contrastRatio(fg, bg) {
304
+ const l1 = relativeLuminance(fg);
305
+ const l2 = relativeLuminance(bg);
306
+ const lighter = Math.max(l1, l2);
307
+ const darker = Math.min(l1, l2);
308
+ return (lighter + 0.05) / (darker + 0.05);
309
+ }
310
+ function wcagLevel(ratio2, large = false) {
311
+ if (large) {
312
+ if (ratio2 >= 4.5) return "AAA";
313
+ if (ratio2 >= 3) return "AA";
314
+ return "fail";
315
+ }
316
+ if (ratio2 >= 7) return "AAA";
317
+ if (ratio2 >= 4.5) return "AA";
318
+ if (ratio2 >= 3) return "A";
319
+ return "fail";
320
+ }
321
+ function validateThemeContrast(theme) {
322
+ const failures = [];
323
+ const bg = theme["bg"];
324
+ if (!bg) return failures;
325
+ const bgColor = parseColor(bg);
326
+ const pairs = [
327
+ ["fg on bg", theme["fg"]],
328
+ ["primary on bg", theme["primary"]],
329
+ ["error on bg", theme["error"]],
330
+ ["success on bg", theme["success"]],
331
+ ["warning on bg", theme["warning"]],
332
+ ["muted on bg", theme["muted"]]
333
+ ];
334
+ for (const [label, hex] of pairs) {
335
+ if (!hex) continue;
336
+ const fgColor = parseColor(hex);
337
+ const ratio2 = contrastRatio(fgColor, bgColor);
338
+ const level = wcagLevel(ratio2);
339
+ if (level !== "AAA" && level !== "AA") {
340
+ failures.push({ pair: label, ratio: Math.round(ratio2 * 100) / 100, level, required: "AA" });
341
+ }
342
+ }
343
+ return failures;
344
+ }
286
345
 
287
346
  // src/utils/ansi.ts
288
347
  var ansi_exports = {};
@@ -330,7 +389,8 @@ __export(ansi_exports, {
330
389
  setTitle: () => setTitle,
331
390
  showCursor: () => showCursor,
332
391
  strikethrough: () => strikethrough,
333
- underline: () => underline
392
+ underline: () => underline,
393
+ writeClipboard: () => writeClipboard
334
394
  });
335
395
  var CSI = "\x1B[";
336
396
  var OSC = "\x1B]";
@@ -390,6 +450,10 @@ var resetScrollRegion = `${CSI}r`;
390
450
  function setTitle(title) {
391
451
  return `${OSC}0;${title}\x07`;
392
452
  }
453
+ function writeClipboard(text, stdout = process.stdout) {
454
+ const encoded = Buffer.from(text, "utf8").toString("base64");
455
+ stdout.write(`${OSC}52;c;${encoded}\x07`);
456
+ }
393
457
 
394
458
  // src/terminal/Terminal.ts
395
459
  var Terminal = class {
@@ -409,6 +473,8 @@ var Terminal = class {
409
473
  _exitHandler = null;
410
474
  _sigintHandler = null;
411
475
  _sigtermHandler = null;
476
+ _uncaughtExceptionHandler = null;
477
+ _unhandledRejectionHandler = null;
412
478
  _restored = false;
413
479
  constructor(options = {}) {
414
480
  this.stdout = options.stdout ?? process.stdout;
@@ -509,6 +575,14 @@ var Terminal = class {
509
575
  if (this._exitHandler) process.off("exit", this._exitHandler);
510
576
  if (this._sigintHandler) process.off("SIGINT", this._sigintHandler);
511
577
  if (this._sigtermHandler) process.off("SIGTERM", this._sigtermHandler);
578
+ if (this._uncaughtExceptionHandler) {
579
+ process.off("uncaughtException", this._uncaughtExceptionHandler);
580
+ this._uncaughtExceptionHandler = null;
581
+ }
582
+ if (this._unhandledRejectionHandler) {
583
+ process.off("unhandledRejection", this._unhandledRejectionHandler);
584
+ this._unhandledRejectionHandler = null;
585
+ }
512
586
  if (this._resizeHandler) {
513
587
  this.stdout.off("resize", this._resizeHandler);
514
588
  }
@@ -546,15 +620,26 @@ var Terminal = class {
546
620
  process.on("exit", this._exitHandler);
547
621
  process.on("SIGINT", this._sigintHandler);
548
622
  process.on("SIGTERM", this._sigtermHandler);
623
+ this._uncaughtExceptionHandler = (err) => {
624
+ this.restore();
625
+ process.exit(1);
626
+ };
627
+ this._unhandledRejectionHandler = () => {
628
+ this.restore();
629
+ process.exit(1);
630
+ };
631
+ process.on("uncaughtException", this._uncaughtExceptionHandler);
632
+ process.on("unhandledRejection", this._unhandledRejectionHandler);
549
633
  }
550
634
  };
551
635
 
552
636
  // src/terminal/Screen.ts
637
+ var EMPTY_COLOR = Object.freeze({ type: "none" });
553
638
  function emptyCell() {
554
639
  return {
555
640
  char: " ",
556
- fg: { type: "none" },
557
- bg: { type: "none" },
641
+ fg: EMPTY_COLOR,
642
+ bg: EMPTY_COLOR,
558
643
  bold: false,
559
644
  italic: false,
560
645
  underline: false,
@@ -566,8 +651,8 @@ function emptyCell() {
566
651
  }
567
652
  function resetCell(cell) {
568
653
  cell.char = " ";
569
- cell.fg = { type: "none" };
570
- cell.bg = { type: "none" };
654
+ cell.fg = EMPTY_COLOR;
655
+ cell.bg = EMPTY_COLOR;
571
656
  cell.bold = false;
572
657
  cell.italic = false;
573
658
  cell.underline = false;
@@ -705,6 +790,7 @@ var Screen = class {
705
790
  * Clear the back buffer to all empty cells.
706
791
  */
707
792
  clear() {
793
+ this._clipStack = [];
708
794
  for (let r = 0; r < this._rows; r++) {
709
795
  for (let c = 0; c < this._cols; c++) {
710
796
  resetCell(this.back[r][c]);
@@ -762,6 +848,7 @@ var Renderer = class {
762
848
  _frameTimer = null;
763
849
  _renderRequested = false;
764
850
  _colorDepth;
851
+ _onTick = null;
765
852
  constructor(terminal, screen, fps = 30) {
766
853
  this._terminal = terminal;
767
854
  this._screen = screen;
@@ -773,14 +860,16 @@ var Renderer = class {
773
860
  this._fps = fps;
774
861
  if (this._frameTimer) {
775
862
  this.stop();
776
- this.start();
863
+ this.start(this._onTick ?? void 0);
777
864
  }
778
865
  }
779
866
  /** Start the render loop */
780
- start() {
867
+ start(onTick) {
781
868
  if (this._frameTimer) return;
869
+ this._onTick = onTick ?? null;
782
870
  const interval = Math.floor(1e3 / this._fps);
783
871
  this._frameTimer = setInterval(() => {
872
+ this._onTick?.();
784
873
  if (this._renderRequested) {
785
874
  this._renderRequested = false;
786
875
  this._flush();
@@ -1046,6 +1135,42 @@ var LayerManager = class {
1046
1135
  }
1047
1136
  };
1048
1137
 
1138
+ // src/terminal/env-caps.ts
1139
+ var caps = {
1140
+ color: !process.env.NO_COLOR && process.env.TERM !== "dumb",
1141
+ unicode: !process.env.NO_UNICODE && process.env.TERM !== "dumb",
1142
+ motion: !process.env.NO_MOTION && !process.env.CI,
1143
+ ci: !!process.env.CI
1144
+ };
1145
+
1146
+ // src/terminal/ascii-map.ts
1147
+ var BOX = {
1148
+ "\u250C": "+",
1149
+ "\u2510": "+",
1150
+ "\u2514": "+",
1151
+ "\u2518": "+",
1152
+ "\u2500": "-",
1153
+ "\u2502": "|",
1154
+ "\u251C": "+",
1155
+ "\u2524": "+",
1156
+ "\u252C": "+",
1157
+ "\u2534": "+",
1158
+ "\u253C": "+",
1159
+ "\u2550": "=",
1160
+ "\u2551": "|",
1161
+ "\u2554": "+",
1162
+ "\u2557": "+",
1163
+ "\u255A": "+",
1164
+ "\u255D": "+",
1165
+ "\u2560": "+",
1166
+ "\u2563": "+",
1167
+ "\u2566": "+",
1168
+ "\u2569": "+",
1169
+ "\u256C": "+"
1170
+ };
1171
+ var BRAILLE_SPIN = ["|", "/", "-", "\\"];
1172
+ var BLOCK = { full: "#", empty: " ", partial: "-" };
1173
+
1049
1174
  // src/events/types.ts
1050
1175
  function createKeyEvent(base) {
1051
1176
  const event = {
@@ -1401,6 +1526,10 @@ var InputParser = class {
1401
1526
  return;
1402
1527
  }
1403
1528
  if (seq.length < 20) {
1529
+ if (this._escapeTimeout) {
1530
+ clearTimeout(this._escapeTimeout);
1531
+ this._escapeTimeout = null;
1532
+ }
1404
1533
  this._escapeTimeout = setTimeout(() => {
1405
1534
  this._escapeBuffer = "";
1406
1535
  this._escapeTimeout = null;
@@ -1440,6 +1569,10 @@ var InputParser = class {
1440
1569
  this._escapeBuffer = "";
1441
1570
  return;
1442
1571
  }
1572
+ if (this._escapeTimeout) {
1573
+ clearTimeout(this._escapeTimeout);
1574
+ this._escapeTimeout = null;
1575
+ }
1443
1576
  this._escapeTimeout = setTimeout(() => {
1444
1577
  this._escapeBuffer = "";
1445
1578
  this._escapeTimeout = null;
@@ -1568,7 +1701,8 @@ function createLayoutNode(id, style, children = []) {
1568
1701
  id,
1569
1702
  style,
1570
1703
  children,
1571
- computed: { x: 0, y: 0, width: 0, height: 0 }
1704
+ computed: { x: 0, y: 0, width: 0, height: 0 },
1705
+ _dirty: true
1572
1706
  };
1573
1707
  }
1574
1708
  function computeLayout(root, containerWidth, containerHeight) {
@@ -1590,7 +1724,10 @@ function layoutNode(node, availWidth, availHeight, precomputed = false) {
1590
1724
  node.computed.width = nodeWidth2;
1591
1725
  node.computed.height = nodeHeight2;
1592
1726
  }
1593
- if (node.children.length === 0) return;
1727
+ if (node.children.length === 0) {
1728
+ node._dirty = false;
1729
+ return;
1730
+ }
1594
1731
  const nodeWidth = node.computed.width;
1595
1732
  const nodeHeight = node.computed.height;
1596
1733
  const innerX = padding.left + border.horizontal / 2;
@@ -1711,6 +1848,7 @@ function layoutNode(node, availWidth, availHeight, precomputed = false) {
1711
1848
  mainOffset += info.mainSize + gap + spaceBetween;
1712
1849
  layoutNode(info.node, info.node.computed.width, info.node.computed.height, true);
1713
1850
  }
1851
+ node._dirty = false;
1714
1852
  }
1715
1853
  function resolveSize(value, available) {
1716
1854
  if (value === void 0) return void 0;
@@ -2321,6 +2459,9 @@ var App = class {
2321
2459
  _options;
2322
2460
  _mounted = false;
2323
2461
  _exitResolve = null;
2462
+ _unsubKey = null;
2463
+ _unsubMouse = null;
2464
+ _widgetById = /* @__PURE__ */ new Map();
2324
2465
  constructor(rootWidget, options = {}) {
2325
2466
  this._rootWidget = rootWidget;
2326
2467
  this._options = {
@@ -2365,10 +2506,11 @@ var App = class {
2365
2506
  this.screen.invalidate();
2366
2507
  this.layers.resize(cols, rows);
2367
2508
  this.events.emit("resize", { cols, rows });
2509
+ this._rootWidget.markDirty?.();
2368
2510
  this.requestRender();
2369
2511
  });
2370
2512
  this.input.start();
2371
- this.input.onKey((rawEvent) => {
2513
+ this._unsubKey = this.input.onKey((rawEvent) => {
2372
2514
  const event = createKeyEvent({
2373
2515
  ...rawEvent,
2374
2516
  targetId: this.focus.currentId ?? void 0
@@ -2394,10 +2536,10 @@ var App = class {
2394
2536
  this.events.emit("key", event);
2395
2537
  }
2396
2538
  });
2397
- this.input.onMouse((event) => {
2539
+ this._unsubMouse = this.input.onMouse((event) => {
2398
2540
  this.events.emit("mouse", event);
2399
2541
  });
2400
- this.renderer.start();
2542
+ this.renderer.start(() => this.requestRender());
2401
2543
  this._rootWidget.mount?.();
2402
2544
  this.events.emit("mount", void 0);
2403
2545
  this.screen.invalidate();
@@ -2414,6 +2556,10 @@ var App = class {
2414
2556
  this._mounted = false;
2415
2557
  this._rootWidget.unmount?.();
2416
2558
  this.events.emit("unmount", void 0);
2559
+ this._unsubKey?.();
2560
+ this._unsubKey = null;
2561
+ this._unsubMouse?.();
2562
+ this._unsubMouse = null;
2417
2563
  this.renderer.stop();
2418
2564
  this.input.stop();
2419
2565
  this.terminal.restore();
@@ -2435,14 +2581,20 @@ var App = class {
2435
2581
  }
2436
2582
  /**
2437
2583
  * Request a re-render on the next frame.
2584
+ * Skips layout + render pass when the root widget reports no dirty state.
2438
2585
  */
2439
2586
  requestRender() {
2440
2587
  if (!this._mounted) return;
2588
+ if (this._rootWidget.isDirty === false) {
2589
+ return;
2590
+ }
2441
2591
  const layoutRoot = this._rootWidget.getLayoutNode();
2442
2592
  computeLayout(layoutRoot, this.terminal.cols, this.terminal.rows);
2443
2593
  this._rootWidget.syncLayout?.();
2594
+ this._buildWidgetMap(this._rootWidget);
2444
2595
  this.screen.clear();
2445
2596
  this._rootWidget.render(this.screen);
2597
+ this._rootWidget.clearDirty?.();
2446
2598
  this.layers.composite(this.screen);
2447
2599
  this.renderer.requestFrame();
2448
2600
  }
@@ -2471,10 +2623,11 @@ var App = class {
2471
2623
  /**
2472
2624
  * Build the bubble chain for keyboard events.
2473
2625
  * Returns an array: [focused widget, parent, grandparent, ..., root]
2626
+ * Uses the cached _widgetById map for O(1) lookup instead of DFS.
2474
2627
  */
2475
2628
  _buildBubbleChain(widgetId) {
2476
2629
  const chain = [];
2477
- const widget = this._findWidgetById(this._rootWidget, widgetId);
2630
+ const widget = this._widgetById.get(widgetId);
2478
2631
  if (!widget) return chain;
2479
2632
  let current = widget;
2480
2633
  while (current) {
@@ -2486,19 +2639,22 @@ var App = class {
2486
2639
  return chain;
2487
2640
  }
2488
2641
  /**
2489
- * Find a widget by ID in the widget tree (DFS).
2490
- * Uses duck-typing to work with any object that has id/children.
2642
+ * Rebuild the widget ID cache by walking the entire widget tree.
2643
+ * Called after syncLayout() so the map stays current.
2491
2644
  */
2492
- _findWidgetById(root, id) {
2493
- if (root.id === id) return root;
2494
- const children = root._children ?? root.children ?? [];
2645
+ _buildWidgetMap(root) {
2646
+ this._widgetById.clear();
2647
+ this._walkWidget(root);
2648
+ }
2649
+ _walkWidget(widget) {
2650
+ if (!widget) return;
2651
+ if (widget.id) this._widgetById.set(widget.id, widget);
2652
+ const children = widget._children ?? widget.children ?? [];
2495
2653
  if (Array.isArray(children)) {
2496
2654
  for (const child of children) {
2497
- const found = this._findWidgetById(child, id);
2498
- if (found) return found;
2655
+ this._walkWidget(child);
2499
2656
  }
2500
2657
  }
2501
- return null;
2502
2658
  }
2503
2659
  };
2504
2660
 
@@ -2665,9 +2821,12 @@ function wordWrap(str, width) {
2665
2821
  // Annotate the CommonJS export names for ESM import in node:
2666
2822
  0 && (module.exports = {
2667
2823
  App,
2824
+ BLOCK,
2668
2825
  BORDER_CHARS,
2826
+ BOX,
2669
2827
  BRAILLE_DOTS,
2670
2828
  BRAILLE_OFFSET,
2829
+ BRAILLE_SPIN,
2671
2830
  BarSets,
2672
2831
  BorderSets,
2673
2832
  CTRL_KEYS,
@@ -2688,12 +2847,14 @@ function wordWrap(str, width) {
2688
2847
  VERTICAL_BAR_SYMBOLS,
2689
2848
  ansi,
2690
2849
  borderSize,
2850
+ caps,
2691
2851
  cellsEqual,
2692
2852
  colorToAnsiBg,
2693
2853
  colorToAnsiFg,
2694
2854
  colorToRgb,
2695
2855
  computeLayout,
2696
2856
  containsPoint,
2857
+ contrastRatio,
2697
2858
  createKeyEvent,
2698
2859
  createLayoutNode,
2699
2860
  createTestScreen,
@@ -2714,6 +2875,7 @@ function wordWrap(str, width) {
2714
2875
  parseMouseEvent,
2715
2876
  percentage,
2716
2877
  ratio,
2878
+ relativeLuminance,
2717
2879
  renderFallback,
2718
2880
  shouldUseFallback,
2719
2881
  shrinkRect,
@@ -2728,6 +2890,9 @@ function wordWrap(str, width) {
2728
2890
  testScreenToString,
2729
2891
  truncate,
2730
2892
  unionRect,
2731
- wordWrap
2893
+ validateThemeContrast,
2894
+ wcagLevel,
2895
+ wordWrap,
2896
+ writeClipboard
2732
2897
  });
2733
2898
  //# sourceMappingURL=index.cjs.map