pi-libtui 0.3.2

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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/package.json +55 -0
  4. package/src/activity-presentation.ts +75 -0
  5. package/src/appearance.ts +632 -0
  6. package/src/background-surface.ts +125 -0
  7. package/src/color/palette.ts +186 -0
  8. package/src/color/preview.ts +157 -0
  9. package/src/color/resolver.ts +75 -0
  10. package/src/color/theme.ts +531 -0
  11. package/src/component-stack.ts +247 -0
  12. package/src/content/markdown-code.ts +60 -0
  13. package/src/content/terminal-text.ts +135 -0
  14. package/src/content/text.ts +105 -0
  15. package/src/controls/action-panel.ts +420 -0
  16. package/src/controls/dialog-button-bar.ts +287 -0
  17. package/src/controls/multi-select.ts +467 -0
  18. package/src/controls/picker-panel.ts +447 -0
  19. package/src/controls/screen-icon-actions.ts +134 -0
  20. package/src/controls/searchable-select.ts +394 -0
  21. package/src/controls/select-box.ts +224 -0
  22. package/src/controls/selectable-list.ts +433 -0
  23. package/src/controls/selection-action-bar.ts +382 -0
  24. package/src/controls/semantic-input.ts +23 -0
  25. package/src/controls/tab.ts +189 -0
  26. package/src/cursor.ts +288 -0
  27. package/src/decoration/editor-pills.ts +156 -0
  28. package/src/decoration/glyphs.ts +326 -0
  29. package/src/decoration/pointer-interaction.ts +123 -0
  30. package/src/decoration/powerline-pill.ts +104 -0
  31. package/src/decoration/status.ts +185 -0
  32. package/src/decoration/transient-pill.ts +176 -0
  33. package/src/diff/index.ts +21 -0
  34. package/src/diff/model.ts +76 -0
  35. package/src/diff/parse.ts +310 -0
  36. package/src/diff/pierre-syntax.ts +86 -0
  37. package/src/diff/render.ts +504 -0
  38. package/src/diff/structured.ts +264 -0
  39. package/src/diff/view.ts +75 -0
  40. package/src/editor/chrome.ts +341 -0
  41. package/src/editor/composition.ts +53 -0
  42. package/src/editor/layout.ts +81 -0
  43. package/src/editor/presentation.ts +35 -0
  44. package/src/editor/protocol.ts +188 -0
  45. package/src/editor.ts +41 -0
  46. package/src/extension.ts +30 -0
  47. package/src/folding.ts +192 -0
  48. package/src/host/cursor-bridge.ts +210 -0
  49. package/src/host/editor-bridge.ts +148 -0
  50. package/src/host/extension-host.ts +231 -0
  51. package/src/host/mouse-bridge.ts +212 -0
  52. package/src/host/mouse-dispatch.ts +400 -0
  53. package/src/host/mouse-input.ts +85 -0
  54. package/src/host/mouse-targets.ts +238 -0
  55. package/src/host/pi-layout-adapter.ts +298 -0
  56. package/src/host/pi-native-selection.ts +227 -0
  57. package/src/host/split-pane-bridge.ts +720 -0
  58. package/src/host/transcript-bridge.ts +236 -0
  59. package/src/index.ts +334 -0
  60. package/src/line-layout.ts +6 -0
  61. package/src/motion.ts +1409 -0
  62. package/src/mouse/events.ts +55 -0
  63. package/src/mouse/fullscreen-layout.ts +260 -0
  64. package/src/mouse/registry.ts +348 -0
  65. package/src/mouse.ts +37 -0
  66. package/src/native-binary.ts +189 -0
  67. package/src/overlay/anchored.ts +91 -0
  68. package/src/overlay/detail-card.ts +158 -0
  69. package/src/overlay/dialog.ts +266 -0
  70. package/src/overlay/floating.ts +106 -0
  71. package/src/overlay/framed-editor.ts +236 -0
  72. package/src/overlay/fullscreen.ts +170 -0
  73. package/src/overlay/hover-tooltip.ts +109 -0
  74. package/src/overlay/modal-mount.ts +118 -0
  75. package/src/panels.ts +207 -0
  76. package/src/render-cache.ts +80 -0
  77. package/src/render-epoch.ts +48 -0
  78. package/src/request-animation.ts +160 -0
  79. package/src/scrollbar.ts +27 -0
  80. package/src/selection.ts +148 -0
  81. package/src/split-pane.ts +232 -0
  82. package/src/status-presentation.ts +411 -0
  83. package/src/stream.ts +543 -0
  84. package/src/syntax.ts +316 -0
  85. package/src/terminal/bridge-client.ts +243 -0
  86. package/src/terminal/embedding.ts +6 -0
  87. package/src/terminal/output.ts +151 -0
  88. package/src/terminal/projection.ts +362 -0
  89. package/src/terminal/pty-host.ts +263 -0
  90. package/src/terminal/pty-pane.ts +308 -0
  91. package/src/terminal-colors.ts +334 -0
  92. package/src/tool/action.ts +170 -0
  93. package/src/tool/activity.ts +500 -0
  94. package/src/tool/call-preview.ts +44 -0
  95. package/src/tool/disclosure-action.ts +89 -0
  96. package/src/tool/expanded-region-viewport.ts +103 -0
  97. package/src/tool/index.ts +26 -0
  98. package/src/tool/output.ts +271 -0
  99. package/src/tool/transcript.ts +150 -0
  100. package/src/tool/view-region.ts +581 -0
  101. package/test/appearance.test.ts +183 -0
  102. package/test/bounded-stream.test.ts +150 -0
  103. package/test/bridge-client.test.ts +42 -0
  104. package/test/color-preview.test.ts +23 -0
  105. package/test/component-stack.test.ts +206 -0
  106. package/test/components.test.ts +1165 -0
  107. package/test/content-text.test.ts +21 -0
  108. package/test/cursor-bridge.test.ts +151 -0
  109. package/test/cursor.test.ts +147 -0
  110. package/test/editor-chrome.test.ts +119 -0
  111. package/test/editor-layout.test.ts +30 -0
  112. package/test/editor-registry.test.ts +203 -0
  113. package/test/editor-theme.test.ts +33 -0
  114. package/test/editor-token-pill.test.ts +72 -0
  115. package/test/floating-overlay.test.ts +57 -0
  116. package/test/folding.test.ts +147 -0
  117. package/test/harmonious-theme.test.ts +23 -0
  118. package/test/interaction.test.ts +104 -0
  119. package/test/key-icons.test.ts +62 -0
  120. package/test/markdown-code.test.ts +24 -0
  121. package/test/modal-overlay-mount.test.ts +94 -0
  122. package/test/motion.test.ts +649 -0
  123. package/test/mouse/bridge.test.ts +1302 -0
  124. package/test/mouse/extension.test.ts +205 -0
  125. package/test/mouse/registry.test.ts +140 -0
  126. package/test/mouse/screen-decoration.test.ts +167 -0
  127. package/test/native-binary.test.ts +52 -0
  128. package/test/palette.test.ts +32 -0
  129. package/test/panels.test.ts +74 -0
  130. package/test/pointer-components.test.ts +151 -0
  131. package/test/progress.test.ts +54 -0
  132. package/test/pty-host.test.ts +126 -0
  133. package/test/pty-pane.test.ts +62 -0
  134. package/test/public-surface.test.ts +75 -0
  135. package/test/render-cache.test.ts +25 -0
  136. package/test/render-epoch.test.ts +119 -0
  137. package/test/request-animation.test.ts +193 -0
  138. package/test/screen-icon-actions.test.ts +69 -0
  139. package/test/select-box.test.ts +86 -0
  140. package/test/select-pointer.test.ts +369 -0
  141. package/test/selectable-list.test.ts +258 -0
  142. package/test/selection/registry.test.ts +44 -0
  143. package/test/selection-action-bar.test.ts +204 -0
  144. package/test/split-pane-bridge.test.ts +944 -0
  145. package/test/split-pane.test.ts +134 -0
  146. package/test/status-presentation.test.ts +39 -0
  147. package/test/syntax.test.ts +59 -0
  148. package/test/terminal-colors.test.ts +115 -0
  149. package/test/terminal-embedding.test.ts +12 -0
  150. package/test/terminal-projection.test.ts +312 -0
  151. package/test/theme-colors.test.ts +175 -0
  152. package/test/tool-output.test.ts +160 -0
  153. package/test/tool-transcript.test.ts +1190 -0
  154. package/test/unified-diff.test.ts +691 -0
  155. package/themes/harmonious.json +61 -0
  156. package/tsconfig.json +16 -0
@@ -0,0 +1,238 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { getMouseRegistryState } from "../mouse/registry.ts";
3
+ import type {
4
+ LayoutBox,
5
+ LayoutFrame,
6
+ MouseRect,
7
+ MouseRegistry,
8
+ TextInteractionTarget,
9
+ TuiMouseEvent,
10
+ } from "../mouse.ts";
11
+ import { TEXT_INTERACTION_TARGET } from "../mouse.ts";
12
+ import {
13
+ contains,
14
+ derivedChildren,
15
+ isComponent,
16
+ isRecord,
17
+ isRect,
18
+ rendererLayoutFrame,
19
+ terminalSize,
20
+ } from "./pi-layout-adapter.ts";
21
+
22
+ // type-boundary: Pi 0.84.2's private input and overlay members are narrowed by the validators below.
23
+ type PiPrivateValue = unknown;
24
+
25
+ type MouseHandler = (event: TuiMouseEvent) => boolean;
26
+
27
+ interface OverlayEntry {
28
+ component: Component;
29
+ options?: PiPrivateValue;
30
+ }
31
+
32
+ export interface MouseTarget {
33
+ key: object;
34
+ getRect(): MouseRect | undefined;
35
+ containsPoint?(x: number, y: number): boolean;
36
+ isCurrent?(): boolean;
37
+ dispatch(event: TuiMouseEvent): boolean;
38
+ frame?: LayoutFrame;
39
+ textInteraction?: Component & TextInteractionTarget;
40
+ overlayRegion?: boolean;
41
+ }
42
+
43
+ function parseOverlayEntry(value: PiPrivateValue): OverlayEntry | undefined {
44
+ if (!isRecord(value) || !isComponent(value.component)) return undefined;
45
+ return { component: value.component, options: value.options };
46
+ }
47
+
48
+ function safeCall(handler: MouseHandler, receiver: object, event: TuiMouseEvent): boolean {
49
+ try {
50
+ return Reflect.apply(handler, receiver, [event]) === true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function mouseHandler(component: Component): MouseHandler | undefined {
57
+ const candidate = component as Component & { onMouse?: PiPrivateValue };
58
+ return typeof candidate.onMouse === "function" ? (candidate.onMouse as MouseHandler) : undefined;
59
+ }
60
+
61
+ function textInteractionTarget(component: Component): (Component & TextInteractionTarget) | undefined {
62
+ const candidate = component as Component & Partial<TextInteractionTarget>;
63
+ return candidate[TEXT_INTERACTION_TARGET] === true &&
64
+ typeof candidate.handleViewportInput === "function" &&
65
+ typeof candidate.setViewportFocus === "function"
66
+ ? (candidate as Component & TextInteractionTarget)
67
+ : undefined;
68
+ }
69
+
70
+ const cachedComponentTargetBoxes = new WeakMap<LayoutFrame, Array<{ box: LayoutBox; depth: number }>>();
71
+ const cachedComponentTargets = new WeakMap<LayoutFrame, Map<string, MouseTarget[]>>();
72
+
73
+ function componentTargets(frame: LayoutFrame, x: number, y: number): MouseTarget[] {
74
+ let pointCache = cachedComponentTargets.get(frame);
75
+ if (!pointCache) {
76
+ pointCache = new Map();
77
+ cachedComponentTargets.set(frame, pointCache);
78
+ }
79
+ const point = `${x}:${y}`;
80
+ const cached = pointCache.get(point);
81
+ if (cached) return cached;
82
+ let candidates = cachedComponentTargetBoxes.get(frame);
83
+ if (!candidates) {
84
+ candidates = [];
85
+ const ancestors = new Set<object>();
86
+ const visit = (box: LayoutBox, depth: number): void => {
87
+ if (ancestors.has(box.component)) return;
88
+ const component = isComponent(box.component) ? box.component : undefined;
89
+ if (component && mouseHandler(component)) candidates?.push({ box, depth });
90
+ ancestors.add(box.component);
91
+ for (const child of derivedChildren(box)) visit(child, depth + 1);
92
+ ancestors.delete(box.component);
93
+ };
94
+ visit(frame.root, 0);
95
+ candidates.sort((left, right) => right.depth - left.depth);
96
+ cachedComponentTargetBoxes.set(frame, candidates);
97
+ }
98
+ const targets: MouseTarget[] = candidates
99
+ .filter(({ box }) => contains(box.clip, x, y) && contains(box.rect, x, y))
100
+ .map(({ box }) => ({
101
+ key: box.component,
102
+ frame,
103
+ // The layout walk already resolved this box for the current input frame.
104
+ // Rewalking here made dispatch quadratic and could rerender descendants.
105
+ getRect: () => box.rect,
106
+ containsPoint: (pointX, pointY) => contains(box.rect, pointX, pointY) && contains(box.clip, pointX, pointY),
107
+ textInteraction: isComponent(box.component) ? textInteractionTarget(box.component) : undefined,
108
+ dispatch: (event) => {
109
+ const component = isComponent(box.component) ? box.component : undefined;
110
+ const handler = component ? mouseHandler(component) : undefined;
111
+ return handler && component ? safeCall(handler, component, event) : false;
112
+ },
113
+ }));
114
+ if (pointCache.size >= 256) pointCache.clear();
115
+ pointCache.set(point, targets);
116
+ return targets;
117
+ }
118
+
119
+ function overlayTargets(registry: MouseRegistry, x: number, y: number): MouseTarget[] {
120
+ const matches: Array<{ target: MouseTarget; priority: number; order: number }> = [];
121
+ const regions = getMouseRegistryState(registry).regions;
122
+ for (let index = 0; index < regions.length; index += 1) {
123
+ const region = regions[index]!;
124
+ let rect: MouseRect | undefined;
125
+ try {
126
+ rect = region.getRect();
127
+ } catch {
128
+ continue;
129
+ }
130
+ if (!rect || !isRect(rect) || !contains(rect, x, y)) continue;
131
+ const target: MouseTarget = {
132
+ key: region,
133
+ overlayRegion: true,
134
+ isCurrent: () => getMouseRegistryState(registry).regions.includes(region),
135
+ getRect: () => {
136
+ try {
137
+ const current = region.getRect();
138
+ return current && isRect(current) ? current : undefined;
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ },
143
+ dispatch: (event) => safeCall(region.onMouse, region, event),
144
+ };
145
+ const priority = typeof region.priority === "number" && Number.isFinite(region.priority) ? region.priority : 0;
146
+ matches.push({ target, priority, order: index });
147
+ }
148
+ matches.sort((left, right) => right.priority - left.priority || right.order - left.order);
149
+ return matches.map(({ target }) => target);
150
+ }
151
+
152
+ export function focusedOverlayTarget(renderer: object, x: number, y: number): MouseTarget | undefined {
153
+ const topmost = Reflect.get(renderer, "getTopmostVisibleOverlay") as PiPrivateValue;
154
+ const resolveLayout = Reflect.get(renderer, "resolveOverlayLayout") as PiPrivateValue;
155
+ if (typeof topmost !== "function" || typeof resolveLayout !== "function") return undefined;
156
+ let entry: OverlayEntry | undefined;
157
+ try {
158
+ entry = parseOverlayEntry(Reflect.apply(topmost, renderer, []) as PiPrivateValue);
159
+ } catch {
160
+ return undefined;
161
+ }
162
+ if (!entry || !mouseHandler(entry.component)) return undefined;
163
+
164
+ const getRect = (): MouseRect | undefined => {
165
+ const size = terminalSize(renderer);
166
+ if (!size) return undefined;
167
+ try {
168
+ const initial = Reflect.apply(resolveLayout, renderer, [
169
+ entry.options,
170
+ 0,
171
+ size.columns,
172
+ size.rows,
173
+ ]) as PiPrivateValue;
174
+ if (!isRecord(initial) || typeof initial.width !== "number") return undefined;
175
+ const width = Math.max(1, Math.floor(initial.width));
176
+ const lines = entry.component.render(width);
177
+ if (!Array.isArray(lines) || !lines.every((line) => typeof line === "string")) return undefined;
178
+ const maxHeight = typeof initial.maxHeight === "number" ? Math.max(1, Math.floor(initial.maxHeight)) : undefined;
179
+ const height = maxHeight === undefined ? lines.length : Math.min(lines.length, maxHeight);
180
+ const final = Reflect.apply(resolveLayout, renderer, [
181
+ entry.options,
182
+ height,
183
+ size.columns,
184
+ size.rows,
185
+ ]) as PiPrivateValue;
186
+ if (
187
+ !isRecord(final) ||
188
+ typeof final.row !== "number" ||
189
+ typeof final.col !== "number" ||
190
+ typeof final.width !== "number"
191
+ ) {
192
+ return undefined;
193
+ }
194
+ return { x: final.col, y: final.row, width: final.width, height };
195
+ } catch {
196
+ return undefined;
197
+ }
198
+ };
199
+ const rect = getRect();
200
+ if (!rect || !contains(rect, x, y)) return undefined;
201
+ return {
202
+ key: entry.component,
203
+ frame: rendererLayoutFrame(renderer),
204
+ getRect: () => rect,
205
+ isCurrent: () => {
206
+ try {
207
+ return parseOverlayEntry(Reflect.apply(topmost, renderer, []) as PiPrivateValue)?.component === entry.component;
208
+ } catch {
209
+ return false;
210
+ }
211
+ },
212
+ textInteraction: textInteractionTarget(entry.component),
213
+ dispatch: (event) => {
214
+ const handler = mouseHandler(entry.component);
215
+ return handler ? safeCall(handler, entry.component, event) : false;
216
+ },
217
+ };
218
+ }
219
+
220
+ export function targetsAt(
221
+ registry: MouseRegistry,
222
+ renderer: object,
223
+ x: number,
224
+ y: number,
225
+ includeLayout: boolean,
226
+ defersInputToOverlay: (renderer: object) => boolean,
227
+ ): MouseTarget[] {
228
+ const targets = overlayTargets(registry, x, y);
229
+ if (!includeLayout) return targets;
230
+ if (defersInputToOverlay(renderer)) {
231
+ const focusedOverlay = focusedOverlayTarget(renderer, x, y);
232
+ if (focusedOverlay) targets.push(focusedOverlay);
233
+ return targets;
234
+ }
235
+ const frame = rendererLayoutFrame(renderer);
236
+ if (frame) targets.push(...componentTargets(frame, x, y));
237
+ return targets;
238
+ }
@@ -0,0 +1,298 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import type { LayoutBox, LayoutFrame, LayoutScrollView, LayoutScrollViewReference, MouseRect } from "../mouse.ts";
3
+
4
+ // type-boundary: Pi 0.84.2's private fullscreen fields are untyped; these validators narrow each reflected value.
5
+ type PiPrivateValue = unknown;
6
+
7
+ export function isRecord(value: PiPrivateValue): value is Record<PropertyKey, PiPrivateValue> {
8
+ return value !== null && typeof value === "object";
9
+ }
10
+
11
+ export function isRect(value: PiPrivateValue): value is MouseRect {
12
+ if (!isRecord(value)) return false;
13
+ return (
14
+ typeof value.x === "number" &&
15
+ typeof value.y === "number" &&
16
+ typeof value.width === "number" &&
17
+ typeof value.height === "number" &&
18
+ Number.isFinite(value.x) &&
19
+ Number.isFinite(value.y) &&
20
+ Number.isFinite(value.width) &&
21
+ Number.isFinite(value.height) &&
22
+ value.width >= 0 &&
23
+ value.height >= 0
24
+ );
25
+ }
26
+
27
+ export function isComponent(value: PiPrivateValue): value is Component {
28
+ if (!isRecord(value)) return false;
29
+ return typeof value.render === "function" && typeof value.invalidate === "function";
30
+ }
31
+
32
+ function parseLayoutScrollView(value: PiPrivateValue): LayoutScrollView | undefined {
33
+ if (!isRecord(value)) return undefined;
34
+ const scrollTop = value.scrollTop;
35
+ const viewportHeight = value.viewportHeight;
36
+ if (
37
+ typeof scrollTop !== "number" ||
38
+ !Number.isFinite(scrollTop) ||
39
+ typeof viewportHeight !== "number" ||
40
+ !Number.isFinite(viewportHeight) ||
41
+ viewportHeight < 0 ||
42
+ typeof value.scrollTo !== "function"
43
+ )
44
+ return undefined;
45
+ return value as unknown as LayoutScrollView;
46
+ }
47
+
48
+ export function parseLayoutScrollViewReference(value: PiPrivateValue): LayoutScrollViewReference | undefined {
49
+ if (!isRecord(value) || typeof value.scrollTop !== "number" || !Number.isFinite(value.scrollTop)) return undefined;
50
+ return value as unknown as LayoutScrollViewReference;
51
+ }
52
+
53
+ function parseLayoutBox(value: PiPrivateValue, ancestors = new Set<object>()): LayoutBox | undefined {
54
+ if (
55
+ !isRecord(value) ||
56
+ ancestors.has(value) ||
57
+ !isComponent(value.component) ||
58
+ !isRect(value.rect) ||
59
+ !isRect(value.clip) ||
60
+ !Array.isArray(value.children)
61
+ )
62
+ return undefined;
63
+ ancestors.add(value);
64
+ try {
65
+ const children: LayoutBox[] = [];
66
+ for (const child of value.children) {
67
+ const parsed = parseLayoutBox(child, ancestors);
68
+ if (!parsed) return undefined;
69
+ children.push(parsed);
70
+ }
71
+ let scrollView: LayoutScrollViewReference | undefined;
72
+ if (value.scrollView !== undefined) {
73
+ scrollView = parseLayoutScrollViewReference(value.scrollView);
74
+ if (!scrollView) return undefined;
75
+ }
76
+ let scrollContentLines: readonly string[] | undefined;
77
+ if (value.scrollContentLines !== undefined) {
78
+ if (
79
+ !Array.isArray(value.scrollContentLines) ||
80
+ !value.scrollContentLines.every((line) => typeof line === "string")
81
+ )
82
+ return undefined;
83
+ scrollContentLines = value.scrollContentLines;
84
+ }
85
+ return { component: value.component, rect: value.rect, clip: value.clip, children, scrollView, scrollContentLines };
86
+ } finally {
87
+ ancestors.delete(value);
88
+ }
89
+ }
90
+
91
+ function parseLayoutFrame(value: PiPrivateValue): LayoutFrame | undefined {
92
+ if (!isRecord(value)) return undefined;
93
+ const root = parseLayoutBox(value.root);
94
+ if (!root) return undefined;
95
+ let primaryScrollView: LayoutScrollView | undefined;
96
+ if (value.primaryScrollView !== undefined) {
97
+ primaryScrollView = parseLayoutScrollView(value.primaryScrollView);
98
+ if (!primaryScrollView) return undefined;
99
+ }
100
+ return { root, primaryScrollView };
101
+ }
102
+
103
+ const parsedLayoutFrames = new WeakMap<object, { source: object; frame: LayoutFrame | undefined }>();
104
+
105
+ export function rendererLayoutFrame(renderer: object): LayoutFrame | undefined {
106
+ const source = Reflect.get(renderer, "currentLayout") as PiPrivateValue;
107
+ if (!source || typeof source !== "object") return undefined;
108
+ const cached = parsedLayoutFrames.get(renderer);
109
+ if (cached?.source === source) return cached.frame;
110
+ const frame = parseLayoutFrame(source);
111
+ parsedLayoutFrames.set(renderer, { source, frame });
112
+ return frame;
113
+ }
114
+
115
+ export function contains(rect: MouseRect, x: number, y: number): boolean {
116
+ return x >= rect.x && y >= rect.y && x < rect.x + rect.width && y < rect.y + rect.height;
117
+ }
118
+
119
+ export function intersect(left: MouseRect, right: MouseRect): MouseRect {
120
+ const x = Math.max(left.x, right.x);
121
+ const y = Math.max(left.y, right.y);
122
+ return {
123
+ x,
124
+ y,
125
+ width: Math.max(0, Math.min(left.x + left.width, right.x + right.width) - x),
126
+ height: Math.max(0, Math.min(left.y + left.height, right.y + right.height) - y),
127
+ };
128
+ }
129
+
130
+ function structuralChildren(component: Component): readonly Component[] | undefined {
131
+ const candidate = component as Component & {
132
+ children?: readonly Component[];
133
+ getChildren?: () => readonly Component[];
134
+ };
135
+ if (Array.isArray(candidate.children) && candidate.children.every(isComponent)) return candidate.children;
136
+ // Shared stacks keep their children behind a method so replacement can preserve identity.
137
+ if (typeof candidate.getChildren !== "function") return undefined;
138
+ try {
139
+ const children = candidate.getChildren.call(component);
140
+ return Array.isArray(children) && children.every(isComponent) ? children : undefined;
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
146
+ function hasMouseHandler(component: Component): boolean {
147
+ return typeof (component as Component & { onMouse?: PiPrivateValue }).onMouse === "function";
148
+ }
149
+
150
+ interface CachedStructuralSpan {
151
+ component: Component;
152
+ row: number;
153
+ height: number;
154
+ width: number;
155
+ }
156
+
157
+ function cachedStructuralSpans(component: Component): readonly CachedStructuralSpan[] | undefined {
158
+ const candidate = component as Component & { getSpans?: PiPrivateValue };
159
+ if (typeof candidate.getSpans !== "function") return undefined;
160
+ let value: PiPrivateValue;
161
+ try {
162
+ value = Reflect.apply(candidate.getSpans, component, []) as PiPrivateValue;
163
+ } catch {
164
+ return [];
165
+ }
166
+ if (!Array.isArray(value)) return [];
167
+ const spans: CachedStructuralSpan[] = [];
168
+ for (const span of value) {
169
+ if (
170
+ !isRecord(span) ||
171
+ !isComponent(span.component) ||
172
+ typeof span.row !== "number" ||
173
+ typeof span.height !== "number" ||
174
+ typeof span.width !== "number" ||
175
+ !Number.isFinite(span.row) ||
176
+ !Number.isFinite(span.height) ||
177
+ !Number.isFinite(span.width)
178
+ )
179
+ return [];
180
+ spans.push({
181
+ component: span.component,
182
+ row: Math.max(0, Math.floor(span.row)),
183
+ height: Math.max(0, Math.floor(span.height)),
184
+ width: Math.max(0, Math.floor(span.width)),
185
+ });
186
+ }
187
+ return spans;
188
+ }
189
+
190
+ function hasMouseDescendant(component: Component, ancestors = new Set<Component>()): boolean {
191
+ if (ancestors.has(component)) return false;
192
+ const children = structuralChildren(component);
193
+ if (!children) return false;
194
+ ancestors.add(component);
195
+ const found = children.some((child) => hasMouseHandler(child) || hasMouseDescendant(child, ancestors));
196
+ ancestors.delete(component);
197
+ return found;
198
+ }
199
+
200
+ const derivedLayoutChildren = new WeakMap<LayoutBox, LayoutBox[]>();
201
+
202
+ export function derivedChildren(box: LayoutBox): readonly LayoutBox[] {
203
+ if (box.children.length > 0) return box.children;
204
+ const existing = derivedLayoutChildren.get(box);
205
+ if (existing) return existing;
206
+ const component = isComponent(box.component) ? box.component : undefined;
207
+ if (!component || !hasMouseDescendant(component)) {
208
+ derivedLayoutChildren.set(box, []);
209
+ return [];
210
+ }
211
+ const cached = cachedStructuralSpans(component);
212
+ if (cached) {
213
+ const derived = cached.map((span) => {
214
+ const rect = {
215
+ x: box.rect.x,
216
+ y: box.rect.y + span.row,
217
+ width: Math.min(box.rect.width, span.width),
218
+ height: span.height,
219
+ };
220
+ return { component: span.component, rect, clip: intersect(box.clip, rect), children: [] };
221
+ });
222
+ derivedLayoutChildren.set(box, derived);
223
+ return derived;
224
+ }
225
+ const childrenToMeasure = structuralChildren(component);
226
+ if (!childrenToMeasure) return [];
227
+ const children: LayoutBox[] = [];
228
+ let childY = box.rect.y;
229
+ for (const child of childrenToMeasure) {
230
+ let height: number;
231
+ try {
232
+ const lines = child.render(box.rect.width);
233
+ if (!Array.isArray(lines) || !lines.every((line) => typeof line === "string")) return [];
234
+ height = lines.length;
235
+ } catch {
236
+ return [];
237
+ }
238
+ const rect = { x: box.rect.x, y: childY, width: box.rect.width, height };
239
+ children.push({ component: child, rect, clip: intersect(box.clip, rect), children: [] });
240
+ childY += height;
241
+ }
242
+ derivedLayoutChildren.set(box, children);
243
+ return children;
244
+ }
245
+
246
+ export function isVisibleComponent(frame: LayoutFrame, component: Component): boolean {
247
+ let visible = false;
248
+ const visit = (box: LayoutBox, ancestors: ReadonlySet<object>): void => {
249
+ if (visible || ancestors.has(box.component)) return;
250
+ if (box.component === component) {
251
+ const clipped = intersect(box.rect, box.clip);
252
+ visible = clipped.width > 0 && clipped.height > 0;
253
+ return;
254
+ }
255
+ const nextAncestors = new Set(ancestors);
256
+ nextAncestors.add(box.component);
257
+ for (const child of derivedChildren(box)) visit(child, nextAncestors);
258
+ };
259
+ visit(frame.root, new Set());
260
+ return visible;
261
+ }
262
+
263
+ export function findScrollViewBox(frame: LayoutFrame, scrollView: object): LayoutBox | undefined {
264
+ let result: LayoutBox | undefined;
265
+ const visit = (box: LayoutBox): void => {
266
+ if (result) return;
267
+ if (box.scrollView === scrollView) {
268
+ result = box;
269
+ return;
270
+ }
271
+ for (const child of box.children) visit(child);
272
+ };
273
+ visit(frame.root);
274
+ return result;
275
+ }
276
+
277
+ export function terminalSize(renderer: object): { rows: number; columns: number } | undefined {
278
+ const terminal = Reflect.get(renderer, "terminal") as PiPrivateValue;
279
+ if (!isRecord(terminal) || typeof terminal.rows !== "number" || typeof terminal.columns !== "number")
280
+ return undefined;
281
+ return { rows: terminal.rows, columns: terminal.columns };
282
+ }
283
+
284
+ export function rendererHasOverlay(renderer: object): boolean {
285
+ const method = Reflect.get(renderer, "hasOverlay") as PiPrivateValue;
286
+ if (typeof method !== "function") return false;
287
+ try {
288
+ return Reflect.apply(method, renderer, []) === true;
289
+ } catch {
290
+ return false;
291
+ }
292
+ }
293
+
294
+ export function rendererHasSelection(renderer: object): boolean {
295
+ return (
296
+ Reflect.get(renderer, "selectionAnchor") !== undefined && Reflect.get(renderer, "selectionFocus") !== undefined
297
+ );
298
+ }