gloomberb 0.9.5 → 0.9.7

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 (99) hide show
  1. package/README.md +49 -8
  2. package/package.json +2 -2
  3. package/src/app/global-shortcuts.test.tsx +20 -1
  4. package/src/app/global-shortcuts.ts +2 -0
  5. package/src/app/pane-runtime/index.tsx +11 -4
  6. package/src/app/pane-runtime/pane-settings-runtime.tsx +1 -1
  7. package/src/app.tsx +41 -17
  8. package/src/cli/commands/remote.ts +206 -0
  9. package/src/cli/index.ts +2 -0
  10. package/src/components/chart/core/keyboard.ts +10 -1
  11. package/src/components/chart/static/bar-chart-surface.test.tsx +4 -0
  12. package/src/components/chart/static/chart/surface.test.tsx +59 -4
  13. package/src/components/chart/static/chart/surface.tsx +4 -0
  14. package/src/components/chart/stock-chart/index.test.ts +7 -0
  15. package/src/components/command-bar/list/view.tsx +47 -0
  16. package/src/components/command-bar/multi-select-picker.tsx +7 -0
  17. package/src/components/command-bar/routing/navigation-state.ts +10 -1
  18. package/src/components/command-bar/surface/index.test.tsx +14 -0
  19. package/src/components/command-bar/theme-picker.tsx +91 -79
  20. package/src/components/command-bar/workflow/field-row.tsx +30 -5
  21. package/src/components/index.ts +7 -0
  22. package/src/components/layout/pane/footer/index.tsx +23 -0
  23. package/src/components/layout/shell/index.test.tsx +2 -0
  24. package/src/components/layout/shell/index.tsx +8 -3
  25. package/src/components/layout/shell/pane/actions.ts +21 -7
  26. package/src/components/metric-treemap/index.tsx +317 -0
  27. package/src/components/metric-treemap/layout.test.ts +77 -0
  28. package/src/components/metric-treemap/layout.ts +476 -0
  29. package/src/components/toggle-list.tsx +10 -0
  30. package/src/components/ui/button.tsx +12 -0
  31. package/src/components/ui/data-table/index.tsx +51 -0
  32. package/src/components/ui/fields.tsx +18 -0
  33. package/src/components/ui/list-view.tsx +83 -3
  34. package/src/components/ui/message-composer.tsx +22 -0
  35. package/src/components/ui/tabs.tsx +45 -0
  36. package/src/core/state/app/layout-reducer.ts +7 -1
  37. package/src/core/state/app/layout.ts +6 -0
  38. package/src/core/state/app/state.ts +23 -1
  39. package/src/core/state/app/types.ts +4 -2
  40. package/src/pane-settings.test.ts +1 -0
  41. package/src/plugins/builtin/chat/channels.ts +5 -18
  42. package/src/plugins/builtin/chat/chat.test.tsx +1 -7
  43. package/src/plugins/builtin/chat/status-widget.tsx +1 -7
  44. package/src/plugins/builtin/cloud/plugin.tsx +4 -13
  45. package/src/plugins/builtin/holders/treemap.tsx +28 -297
  46. package/src/plugins/builtin/holders/types.ts +0 -34
  47. package/src/plugins/builtin/market-heatmap/data.test.ts +121 -0
  48. package/src/plugins/builtin/market-heatmap/data.ts +296 -0
  49. package/src/plugins/builtin/market-heatmap/index.tsx +320 -0
  50. package/src/plugins/builtin/plugin-groups.ts +2 -0
  51. package/src/plugins/builtin/portfolio-list/grid.tsx +197 -0
  52. package/src/plugins/builtin/portfolio-list/index.test.ts +52 -1
  53. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  54. package/src/plugins/builtin/portfolio-list/pane/data.ts +15 -1
  55. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +149 -0
  56. package/src/plugins/builtin/portfolio-list/pane/index.tsx +67 -26
  57. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +54 -4
  58. package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
  59. package/src/plugins/ibkr/gateway/service/index.test.ts +22 -0
  60. package/src/plugins/ibkr/gateway/service/index.ts +6 -9
  61. package/src/plugins/ibkr/gateway/service/native-loader.ts +12 -0
  62. package/src/remote/app-host.tsx +77 -0
  63. package/src/remote/client.ts +34 -0
  64. package/src/remote/command-bar.ts +98 -0
  65. package/src/remote/controller-utils.ts +111 -0
  66. package/src/remote/controller.test.ts +365 -0
  67. package/src/remote/controller.ts +434 -0
  68. package/src/remote/endpoint.ts +41 -0
  69. package/src/remote/json-patch.test.ts +31 -0
  70. package/src/remote/json-patch.ts +89 -0
  71. package/src/remote/layout-helpers.ts +62 -0
  72. package/src/remote/resources.ts +186 -0
  73. package/src/remote/revision.ts +18 -0
  74. package/src/remote/schema.ts +143 -0
  75. package/src/remote/semantic-helpers.ts +161 -0
  76. package/src/remote/semantic-tree.tsx +112 -0
  77. package/src/remote/server.test.ts +50 -0
  78. package/src/remote/server.ts +121 -0
  79. package/src/remote/types.ts +119 -0
  80. package/src/renderers/electrobun/bun/index.ts +55 -1
  81. package/src/renderers/electrobun/shared/protocol.ts +11 -1
  82. package/src/renderers/electrobun/view/backend-rpc.ts +28 -1
  83. package/src/renderers/electrobun/view/build-assets.ts +1 -0
  84. package/src/renderers/electrobun/view/host/box.tsx +5 -0
  85. package/src/renderers/electrobun/view/host/mouse.test.ts +37 -0
  86. package/src/renderers/electrobun/view/host/mouse.ts +2 -0
  87. package/src/renderers/electrobun/view/main.tsx +5 -1
  88. package/src/renderers/electrobun/view/native-stubs/ibkr-native-loader.ts +3 -0
  89. package/src/renderers/opentui/chart-surface.test.tsx +101 -0
  90. package/src/renderers/opentui/chart-surface.tsx +15 -20
  91. package/src/renderers/opentui/start.tsx +32 -0
  92. package/src/sources/yahoo-finance/http.ts +31 -4
  93. package/src/state/app/context/index.test.tsx +47 -0
  94. package/src/state/app/context/index.tsx +48 -18
  95. package/src/types/config.ts +1 -0
  96. package/src/ui/host.tsx +1 -0
  97. package/src/ui/index.tsx +183 -7
  98. package/src/version.ts +1 -1
  99. package/src/plugins/builtin/holders/treemap-layout.ts +0 -231
@@ -0,0 +1,476 @@
1
+ export interface MetricTreemapItem<T = unknown> {
2
+ id: string;
3
+ label: string;
4
+ weight: number | null | undefined;
5
+ colorValue?: number | null;
6
+ primaryText?: string | null;
7
+ secondaryText?: string | null;
8
+ tertiaryText?: string | null;
9
+ data: T;
10
+ }
11
+
12
+ export interface MetricTreemapTile<T = unknown> {
13
+ item: MetricTreemapItem<T>;
14
+ x: number;
15
+ y: number;
16
+ width: number;
17
+ height: number;
18
+ }
19
+
20
+ export interface FloatMetricTreemapTile<T = unknown> {
21
+ item: MetricTreemapItem<T>;
22
+ x: number;
23
+ y: number;
24
+ width: number;
25
+ height: number;
26
+ }
27
+
28
+ export type MetricTreemapDirection = "left" | "right" | "up" | "down";
29
+ export type MetricTreemapLayoutMode = "integer" | "float";
30
+
31
+ export interface MetricTreemapLayoutOptions {
32
+ maxItems?: number;
33
+ minTileWidth?: number;
34
+ minTileHeight?: number;
35
+ }
36
+
37
+ interface WeightedTreemapItem<T> {
38
+ item: MetricTreemapItem<T>;
39
+ weight: number;
40
+ area: number;
41
+ }
42
+
43
+ interface TreemapGroup<T> {
44
+ items: WeightedTreemapItem<T>[];
45
+ weight: number;
46
+ }
47
+
48
+ interface FloatRect {
49
+ x: number;
50
+ y: number;
51
+ width: number;
52
+ height: number;
53
+ }
54
+
55
+ const MAX_TREEMAP_ITEMS = 160;
56
+ const DEFAULT_MIN_TILE_WIDTH = 3;
57
+ const DEFAULT_MIN_TILE_HEIGHT = 1.8;
58
+
59
+ function itemWeight(item: MetricTreemapItem): number {
60
+ return Math.max(item.weight ?? 0, 0);
61
+ }
62
+
63
+ function worstAspectRatio<T>(items: WeightedTreemapItem<T>[], sideLength: number): number {
64
+ if (items.length === 0 || sideLength <= 0) return Number.POSITIVE_INFINITY;
65
+ const areaSum = items.reduce((sum, item) => sum + item.area, 0);
66
+ const minArea = Math.min(...items.map((item) => item.area));
67
+ const maxArea = Math.max(...items.map((item) => item.area));
68
+ if (areaSum <= 0 || minArea <= 0) return Number.POSITIVE_INFINITY;
69
+
70
+ const sideSquared = sideLength * sideLength;
71
+ return Math.max(
72
+ (sideSquared * maxArea) / (areaSum * areaSum),
73
+ (areaSum * areaSum) / (sideSquared * minArea),
74
+ );
75
+ }
76
+
77
+ function layoutFloatGroup<T>(items: WeightedTreemapItem<T>[], rect: FloatRect): FloatRect {
78
+ const areaSum = items.reduce((sum, item) => sum + item.area, 0);
79
+ if (areaSum <= 0 || rect.width <= 0 || rect.height <= 0) return rect;
80
+
81
+ if (rect.width >= rect.height) {
82
+ const columnWidth = Math.min(rect.width, areaSum / rect.height);
83
+ return {
84
+ x: rect.x + columnWidth,
85
+ y: rect.y,
86
+ width: Math.max(0, rect.width - columnWidth),
87
+ height: rect.height,
88
+ };
89
+ }
90
+
91
+ const rowHeight = Math.min(rect.height, areaSum / rect.width);
92
+ return {
93
+ x: rect.x,
94
+ y: rect.y + rowHeight,
95
+ width: rect.width,
96
+ height: Math.max(0, rect.height - rowHeight),
97
+ };
98
+ }
99
+
100
+ function buildSquarifiedGroups<T>(items: WeightedTreemapItem<T>[], width: number, height: number): TreemapGroup<T>[] {
101
+ const groups: TreemapGroup<T>[] = [];
102
+ let rect: FloatRect = { x: 0, y: 0, width, height };
103
+ let index = 0;
104
+
105
+ while (index < items.length && rect.width > 0 && rect.height > 0) {
106
+ const group: WeightedTreemapItem<T>[] = [];
107
+ let currentWorst = Number.POSITIVE_INFINITY;
108
+ const sideLength = Math.min(rect.width, rect.height);
109
+
110
+ while (index < items.length) {
111
+ const candidate = items[index]!;
112
+ const nextGroup = [...group, candidate];
113
+ const nextWorst = worstAspectRatio(nextGroup, sideLength);
114
+ if (group.length > 0 && nextWorst > currentWorst) break;
115
+ group.push(candidate);
116
+ currentWorst = nextWorst;
117
+ index += 1;
118
+ }
119
+
120
+ groups.push({
121
+ items: group,
122
+ weight: group.reduce((sum, item) => sum + item.weight, 0),
123
+ });
124
+ rect = layoutFloatGroup(group, rect);
125
+ }
126
+
127
+ return groups;
128
+ }
129
+
130
+ function normalizeWeightedItems<T>(
131
+ items: MetricTreemapItem<T>[],
132
+ width: number,
133
+ height: number,
134
+ cellAspect: number,
135
+ maxItems: number,
136
+ ): WeightedTreemapItem<T>[] {
137
+ const weightedItems = items
138
+ .map((item) => ({ item, weight: itemWeight(item) }))
139
+ .filter((item) => item.weight > 0)
140
+ .slice(0, maxItems);
141
+ const totalWeight = weightedItems.reduce((sum, item) => sum + item.weight, 0);
142
+ if (totalWeight <= 0) return [];
143
+
144
+ const normalizedHeight = height * cellAspect;
145
+ const totalArea = width * normalizedHeight;
146
+ return weightedItems.map((item) => ({
147
+ ...item,
148
+ area: item.weight / totalWeight * totalArea,
149
+ }));
150
+ }
151
+
152
+ function maxItemsForLayout(width: number, height: number, options?: MetricTreemapLayoutOptions): number {
153
+ const requested = options?.maxItems ?? width * height;
154
+ return Math.max(1, Math.min(MAX_TREEMAP_ITEMS, Math.floor(requested)));
155
+ }
156
+
157
+ function isTooSmall(
158
+ tile: { width: number; height: number },
159
+ options: MetricTreemapLayoutOptions | undefined,
160
+ defaultMinTileWidth: number,
161
+ defaultMinTileHeight: number,
162
+ ): boolean {
163
+ const minTileWidth = options?.minTileWidth ?? defaultMinTileWidth;
164
+ const minTileHeight = options?.minTileHeight ?? defaultMinTileHeight;
165
+ return tile.width < minTileWidth || tile.height < minTileHeight;
166
+ }
167
+
168
+ function allocateLengths(totalLength: number, weights: number[]): number[] {
169
+ if (weights.length === 0 || totalLength <= 0) return [];
170
+ const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
171
+ let remainingLength = totalLength;
172
+ let remainingWeight = totalWeight;
173
+
174
+ return weights.map((weight, index) => {
175
+ const remainingItems = weights.length - index;
176
+ if (remainingItems === 1) return remainingLength;
177
+ const ideal = remainingWeight > 0 ? Math.round(remainingLength * weight / remainingWeight) : 1;
178
+ const length = Math.max(1, Math.min(remainingLength - (remainingItems - 1), ideal));
179
+ remainingLength -= length;
180
+ remainingWeight -= weight;
181
+ return length;
182
+ });
183
+ }
184
+
185
+ function buildMetricTreemapRectsForLimit<T>(
186
+ items: MetricTreemapItem<T>[],
187
+ width: number,
188
+ height: number,
189
+ cellAspect = 1,
190
+ maxItems = maxItemsForLayout(width, height),
191
+ ): FloatMetricTreemapTile<T>[] {
192
+ if (width <= 0 || height <= 0) return [];
193
+ const normalizedCellAspect = Math.max(0.25, cellAspect);
194
+ const weightedItems = normalizeWeightedItems(items, width, height, normalizedCellAspect, maxItems);
195
+ if (weightedItems.length === 0) return [];
196
+
197
+ const normalizedHeight = height * normalizedCellAspect;
198
+ const groups = buildSquarifiedGroups(weightedItems, width, normalizedHeight);
199
+ const tiles: FloatMetricTreemapTile<T>[] = [];
200
+ let rect: FloatRect = { x: 0, y: 0, width, height: normalizedHeight };
201
+
202
+ for (const group of groups) {
203
+ const areaSum = group.items.reduce((sum, item) => sum + item.area, 0);
204
+ if (areaSum <= 0 || rect.width <= 0 || rect.height <= 0) break;
205
+
206
+ if (rect.width >= rect.height) {
207
+ const columnWidth = Math.min(rect.width, areaSum / rect.height);
208
+ let itemY = rect.y;
209
+ for (const item of group.items) {
210
+ const itemHeight = Math.min(rect.y + rect.height - itemY, item.area / columnWidth);
211
+ tiles.push({
212
+ item: item.item,
213
+ x: rect.x,
214
+ y: itemY / normalizedCellAspect,
215
+ width: columnWidth,
216
+ height: itemHeight / normalizedCellAspect,
217
+ });
218
+ itemY += itemHeight;
219
+ }
220
+ rect = {
221
+ x: rect.x + columnWidth,
222
+ y: rect.y,
223
+ width: Math.max(0, rect.width - columnWidth),
224
+ height: rect.height,
225
+ };
226
+ } else {
227
+ const rowHeight = Math.min(rect.height, areaSum / rect.width);
228
+ let itemX = rect.x;
229
+ for (const item of group.items) {
230
+ const itemWidth = Math.min(rect.x + rect.width - itemX, item.area / rowHeight);
231
+ tiles.push({
232
+ item: item.item,
233
+ x: itemX,
234
+ y: rect.y / normalizedCellAspect,
235
+ width: itemWidth,
236
+ height: rowHeight / normalizedCellAspect,
237
+ });
238
+ itemX += itemWidth;
239
+ }
240
+ rect = {
241
+ x: rect.x,
242
+ y: rect.y + rowHeight,
243
+ width: rect.width,
244
+ height: Math.max(0, rect.height - rowHeight),
245
+ };
246
+ }
247
+ }
248
+
249
+ return tiles.filter((tile) => tile.width > 0 && tile.height > 0);
250
+ }
251
+
252
+ export function buildMetricTreemapRects<T>(
253
+ items: MetricTreemapItem<T>[],
254
+ width: number,
255
+ height: number,
256
+ cellAspect = 1,
257
+ options?: MetricTreemapLayoutOptions,
258
+ ): FloatMetricTreemapTile<T>[] {
259
+ let limit = maxItemsForLayout(width, height, options);
260
+ let lastTiles: FloatMetricTreemapTile<T>[] = [];
261
+
262
+ while (limit > 0) {
263
+ const tiles = buildMetricTreemapRectsForLimit(items, width, height, cellAspect, limit);
264
+ lastTiles = tiles;
265
+ const firstSmallIndex = tiles.findIndex((tile) => isTooSmall(tile, options, DEFAULT_MIN_TILE_WIDTH, DEFAULT_MIN_TILE_HEIGHT));
266
+ if (firstSmallIndex < 0) return tiles;
267
+ limit = Math.min(limit - 1, firstSmallIndex);
268
+ }
269
+
270
+ return lastTiles.filter((tile) => !isTooSmall(tile, options, DEFAULT_MIN_TILE_WIDTH, DEFAULT_MIN_TILE_HEIGHT));
271
+ }
272
+
273
+ function buildMetricTreemapForLimit<T>(
274
+ items: MetricTreemapItem<T>[],
275
+ width: number,
276
+ height: number,
277
+ cellAspect = 1,
278
+ maxItems = maxItemsForLayout(width, height),
279
+ ): MetricTreemapTile<T>[] {
280
+ if (width <= 0 || height <= 0) return [];
281
+ const normalizedCellAspect = Math.max(0.25, cellAspect);
282
+ const weightedItems = normalizeWeightedItems(items, width, height, normalizedCellAspect, maxItems);
283
+ if (weightedItems.length === 0) return [];
284
+
285
+ const normalizedHeight = height * normalizedCellAspect;
286
+ const groups = buildSquarifiedGroups(weightedItems, width, normalizedHeight);
287
+ const tiles: MetricTreemapTile<T>[] = [];
288
+
289
+ let x = 0;
290
+ let y = 0;
291
+ let remainingWidth = width;
292
+ let remainingHeight = height;
293
+ let remainingWeight = groups.reduce((sum, group) => sum + group.weight, 0);
294
+
295
+ groups.forEach((group, groupIndex) => {
296
+ if (remainingWidth <= 0 || remainingHeight <= 0) return;
297
+ const isLastGroup = groupIndex === groups.length - 1;
298
+ const remainingNormalizedHeight = remainingHeight * normalizedCellAspect;
299
+
300
+ if (remainingWidth >= remainingNormalizedHeight) {
301
+ const columnWidth = isLastGroup
302
+ ? remainingWidth
303
+ : Math.max(1, Math.min(remainingWidth - 1, Math.round(remainingWidth * group.weight / remainingWeight)));
304
+ const heights = allocateLengths(remainingHeight, group.items.map((item) => item.weight));
305
+ let tileY = y;
306
+ group.items.forEach((item, itemIndex) => {
307
+ const tileHeight = heights[itemIndex] ?? 0;
308
+ if (tileHeight > 0) {
309
+ tiles.push({ item: item.item, x, y: tileY, width: columnWidth, height: tileHeight });
310
+ }
311
+ tileY += tileHeight;
312
+ });
313
+ x += columnWidth;
314
+ remainingWidth -= columnWidth;
315
+ } else {
316
+ const rowHeight = isLastGroup
317
+ ? remainingHeight
318
+ : Math.max(1, Math.min(remainingHeight - 1, Math.round(remainingHeight * group.weight / remainingWeight)));
319
+ const widths = allocateLengths(remainingWidth, group.items.map((item) => item.weight));
320
+ let tileX = x;
321
+ group.items.forEach((item, itemIndex) => {
322
+ const tileWidth = widths[itemIndex] ?? 0;
323
+ if (tileWidth > 0) {
324
+ tiles.push({ item: item.item, x: tileX, y, width: tileWidth, height: rowHeight });
325
+ }
326
+ tileX += tileWidth;
327
+ });
328
+ y += rowHeight;
329
+ remainingHeight -= rowHeight;
330
+ }
331
+
332
+ remainingWeight -= group.weight;
333
+ });
334
+
335
+ return tiles.filter((tile) => tile.width > 0 && tile.height > 0);
336
+ }
337
+
338
+ export function buildMetricTreemap<T>(
339
+ items: MetricTreemapItem<T>[],
340
+ width: number,
341
+ height: number,
342
+ cellAspect = 1,
343
+ options?: MetricTreemapLayoutOptions,
344
+ ): MetricTreemapTile<T>[] {
345
+ let limit = maxItemsForLayout(width, height, options);
346
+ let lastTiles: MetricTreemapTile<T>[] = [];
347
+
348
+ while (limit > 0) {
349
+ const tiles = buildMetricTreemapForLimit(items, width, height, cellAspect, limit);
350
+ lastTiles = tiles;
351
+ const firstSmallIndex = tiles.findIndex((tile) => isTooSmall(tile, options, 2, 1));
352
+ if (firstSmallIndex < 0) return tiles;
353
+ limit = Math.min(limit - 1, firstSmallIndex);
354
+ }
355
+
356
+ return lastTiles.filter((tile) => !isTooSmall(tile, options, 2, 1));
357
+ }
358
+
359
+ function center(rect: { x: number; y: number; width: number; height: number }) {
360
+ return {
361
+ x: rect.x + rect.width / 2,
362
+ y: rect.y + rect.height / 2,
363
+ };
364
+ }
365
+
366
+ function rangeGap(startA: number, endA: number, startB: number, endB: number): number {
367
+ if (endA < startB) return startB - endA;
368
+ if (endB < startA) return startA - endB;
369
+ return 0;
370
+ }
371
+
372
+ function primaryGap(
373
+ current: { x: number; y: number; width: number; height: number },
374
+ candidate: { x: number; y: number; width: number; height: number },
375
+ direction: MetricTreemapDirection,
376
+ ): number {
377
+ switch (direction) {
378
+ case "left":
379
+ return Math.max(0, current.x - (candidate.x + candidate.width));
380
+ case "right":
381
+ return Math.max(0, candidate.x - (current.x + current.width));
382
+ case "up":
383
+ return Math.max(0, current.y - (candidate.y + candidate.height));
384
+ case "down":
385
+ return Math.max(0, candidate.y - (current.y + current.height));
386
+ }
387
+ }
388
+
389
+ function perpendicularGap(
390
+ current: { x: number; y: number; width: number; height: number },
391
+ candidate: { x: number; y: number; width: number; height: number },
392
+ direction: MetricTreemapDirection,
393
+ ): number {
394
+ if (direction === "left" || direction === "right") {
395
+ return rangeGap(current.y, current.y + current.height, candidate.y, candidate.y + candidate.height);
396
+ }
397
+ return rangeGap(current.x, current.x + current.width, candidate.x, candidate.x + candidate.width);
398
+ }
399
+
400
+ function isEdgeCandidate(
401
+ current: { x: number; y: number; width: number; height: number },
402
+ candidate: { x: number; y: number; width: number; height: number },
403
+ direction: MetricTreemapDirection,
404
+ ): boolean {
405
+ const epsilon = 0.0001;
406
+ switch (direction) {
407
+ case "left":
408
+ return candidate.x + candidate.width <= current.x + epsilon;
409
+ case "right":
410
+ return candidate.x >= current.x + current.width - epsilon;
411
+ case "up":
412
+ return candidate.y + candidate.height <= current.y + epsilon;
413
+ case "down":
414
+ return candidate.y >= current.y + current.height - epsilon;
415
+ }
416
+ }
417
+
418
+ function isCenterCandidate(
419
+ currentCenter: { x: number; y: number },
420
+ candidateCenter: { x: number; y: number },
421
+ direction: MetricTreemapDirection,
422
+ ): boolean {
423
+ switch (direction) {
424
+ case "left":
425
+ return candidateCenter.x < currentCenter.x;
426
+ case "right":
427
+ return candidateCenter.x > currentCenter.x;
428
+ case "up":
429
+ return candidateCenter.y < currentCenter.y;
430
+ case "down":
431
+ return candidateCenter.y > currentCenter.y;
432
+ }
433
+ }
434
+
435
+ export function findMetricTreemapNeighbor<T>(
436
+ tiles: Array<MetricTreemapTile<T> | FloatMetricTreemapTile<T>>,
437
+ selectedId: string | null,
438
+ direction: MetricTreemapDirection,
439
+ ): MetricTreemapTile<T> | FloatMetricTreemapTile<T> | null {
440
+ if (tiles.length === 0) return null;
441
+ const current = tiles.find((tile) => tile.item.id === selectedId) ?? tiles[0]!;
442
+ const currentCenter = center(current);
443
+ let best: { tile: MetricTreemapTile<T> | FloatMetricTreemapTile<T>; score: number } | null = null;
444
+ const hasEdgeCandidate = tiles.some((tile) => tile.item.id !== current.item.id && isEdgeCandidate(current, tile, direction));
445
+
446
+ for (const tile of tiles) {
447
+ if (tile.item.id === current.item.id) continue;
448
+ const candidateCenter = center(tile);
449
+ const isCandidate = hasEdgeCandidate
450
+ ? isEdgeCandidate(current, tile, direction)
451
+ : isCenterCandidate(currentCenter, candidateCenter, direction);
452
+ if (!isCandidate) continue;
453
+
454
+ const centerDistance = direction === "left" || direction === "right"
455
+ ? Math.abs(candidateCenter.x - currentCenter.x)
456
+ : Math.abs(candidateCenter.y - currentCenter.y);
457
+ const score = primaryGap(current, tile, direction) * 1000
458
+ + perpendicularGap(current, tile, direction) * 100
459
+ + centerDistance;
460
+ if (!best || score < best.score) best = { tile, score };
461
+ }
462
+
463
+ return best?.tile ?? null;
464
+ }
465
+
466
+ export function buildMetricTreemapNavigationTiles<T>(
467
+ items: MetricTreemapItem<T>[],
468
+ width: number,
469
+ height: number,
470
+ cellAspect: number,
471
+ mode: MetricTreemapLayoutMode,
472
+ ): Array<MetricTreemapTile<T> | FloatMetricTreemapTile<T>> {
473
+ return mode === "float"
474
+ ? buildMetricTreemapRects(items, width, height, cellAspect)
475
+ : buildMetricTreemap(items, width, height, cellAspect);
476
+ }
@@ -26,6 +26,9 @@ export interface ToggleListProps {
26
26
  rowGap?: number;
27
27
  rowHeight?: number;
28
28
  surface?: "framed" | "plain";
29
+ remoteLabel?: string;
30
+ remoteScope?: string;
31
+ remoteMetadata?: Record<string, unknown>;
29
32
  }
30
33
 
31
34
  function DesktopToggleRow({
@@ -124,6 +127,9 @@ export function ToggleList({
124
127
  rowGap,
125
128
  rowHeight,
126
129
  surface,
130
+ remoteLabel,
131
+ remoteScope,
132
+ remoteMetadata,
127
133
  }: ToggleListProps) {
128
134
  const isDesktopWeb = useUiHost().kind === "desktop-web";
129
135
  const listItems: ListViewItem[] = items.map((item) => ({
@@ -145,6 +151,10 @@ export function ToggleList({
145
151
  rowGap={rowGap ?? (isDesktopWeb ? 0 : undefined)}
146
152
  rowHeight={rowHeight}
147
153
  surface={surface}
154
+ remoteLabel={remoteLabel}
155
+ remoteScope={remoteScope}
156
+ remoteMetadata={remoteMetadata}
157
+ remoteItemKind="toggle"
148
158
  onSelect={onSelect}
149
159
  onActivate={(item) => {
150
160
  onToggle?.(item.id);
@@ -2,6 +2,7 @@ import { Box, Text, useUiHost } from "../../ui";
2
2
  import { TextAttributes } from "../../ui";
3
3
  import { type ComponentType } from "react";
4
4
  import { colors } from "../../theme/colors";
5
+ import { useRemoteUiNode } from "../../remote/semantic-tree";
5
6
 
6
7
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
7
8
 
@@ -45,6 +46,17 @@ export function Button({
45
46
  shortcut,
46
47
  width,
47
48
  }: ButtonProps) {
49
+ useRemoteUiNode({
50
+ role: "button",
51
+ label,
52
+ disabled,
53
+ actions: {
54
+ press: () => {
55
+ if (!disabled) onPress?.();
56
+ },
57
+ },
58
+ metadata: { variant, active, shortcut },
59
+ });
48
60
  const HostButton = useUiHost().Button as ComponentType<ButtonProps> | undefined;
49
61
  if (HostButton) {
50
62
  return (
@@ -5,6 +5,8 @@ import type {
5
5
  DataTableColumn,
6
6
  DataTableProps,
7
7
  } from "./types";
8
+ import { useRemoteUiNode } from "../../../remote/semantic-tree";
9
+ import { resolveRemoteItemIndex } from "../../../remote/semantic-helpers";
8
10
 
9
11
  export type {
10
12
  DataTableCell,
@@ -16,6 +18,46 @@ export type {
16
18
  export function DataTable<T, C extends DataTableColumn = DataTableColumn>(
17
19
  props: DataTableProps<T, C>,
18
20
  ) {
21
+ useRemoteUiNode({
22
+ role: "table",
23
+ label: "Data table",
24
+ actions: {
25
+ selectRow: (input) => {
26
+ const index = resolveTableIndex(input, props);
27
+ const item = index >= 0 ? props.items[index] : undefined;
28
+ if (item) props.onSelect(item, index);
29
+ },
30
+ activateRow: (input) => {
31
+ const index = resolveTableIndex(input, props);
32
+ const item = index >= 0 ? props.items[index] : undefined;
33
+ if (item) {
34
+ props.onSelect(item, index);
35
+ props.onActivate?.(item, index);
36
+ }
37
+ },
38
+ sort: (input) => {
39
+ const columnId = typeof input === "string"
40
+ ? input
41
+ : input && typeof input === "object" && typeof (input as { columnId?: unknown }).columnId === "string"
42
+ ? (input as { columnId: string }).columnId
43
+ : null;
44
+ if (columnId && props.columns.some((column) => column.id === columnId)) {
45
+ props.onHeaderClick(columnId);
46
+ }
47
+ },
48
+ },
49
+ metadata: {
50
+ sortColumnId: props.sortColumnId,
51
+ sortDirection: props.sortDirection,
52
+ columns: props.columns.map((column) => ({ id: column.id, label: column.label })),
53
+ rows: props.items.slice(0, 200).map((item, index) => ({
54
+ index,
55
+ key: props.getItemKey(item, index),
56
+ selected: props.isSelected(item, index),
57
+ })),
58
+ rowCount: props.items.length,
59
+ },
60
+ });
19
61
  const HostDataTable = useUiHost().DataTable as
20
62
  | ComponentType<DataTableProps<T, C>>
21
63
  | undefined;
@@ -24,3 +66,12 @@ export function DataTable<T, C extends DataTableColumn = DataTableColumn>(
24
66
  }
25
67
  return <OpenTuiDataTable {...props} />;
26
68
  }
69
+
70
+ function resolveTableIndex<T, C extends DataTableColumn>(
71
+ input: unknown,
72
+ props: DataTableProps<T, C>,
73
+ ): number {
74
+ return resolveRemoteItemIndex(input, props.items, {
75
+ key: (item, index) => props.getItemKey(item, index),
76
+ });
77
+ }
@@ -2,6 +2,8 @@ import { Box, Input, Span, Text, useUiHost } from "../../ui";
2
2
  import { useEffect, useRef, useState, type ComponentType, type RefObject } from "react";
3
3
  import { type InputRenderable } from "../../ui";
4
4
  import { colors } from "../../theme/colors";
5
+ import { useRemoteUiNode } from "../../remote/semantic-tree";
6
+ import { remoteStringValue } from "../../remote/semantic-helpers";
5
7
 
6
8
  export interface TextFieldProps {
7
9
  label?: string;
@@ -46,6 +48,22 @@ export function TextField({
46
48
  placeholderColor = colors.textDim,
47
49
  onMouseDown,
48
50
  }: TextFieldProps) {
51
+ useRemoteUiNode({
52
+ role: "text-field",
53
+ label: label ?? placeholder,
54
+ actions: {
55
+ setValue: (input) => {
56
+ const nextValue = remoteStringValue(input);
57
+ onChange?.(nextValue);
58
+ },
59
+ submit: (input) => {
60
+ const nextValue = remoteStringValue(input, value ?? "");
61
+ onSubmit?.(nextValue);
62
+ },
63
+ blur: () => onBlur?.(value ?? ""),
64
+ },
65
+ metadata: { value, placeholder, focused, type, variant },
66
+ });
49
67
  const HostTextField = useUiHost().TextField as ComponentType<TextFieldProps> | undefined;
50
68
  if (HostTextField) {
51
69
  return (