@flyingrobots/bijou-tui 1.2.0 → 1.4.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.
Files changed (63) hide show
  1. package/README.md +117 -4
  2. package/dist/animate.d.ts.map +1 -1
  3. package/dist/animate.js +13 -2
  4. package/dist/animate.js.map +1 -1
  5. package/dist/app-frame.d.ts +176 -0
  6. package/dist/app-frame.d.ts.map +1 -0
  7. package/dist/app-frame.js +937 -0
  8. package/dist/app-frame.js.map +1 -0
  9. package/dist/command-palette.js +1 -1
  10. package/dist/command-palette.js.map +1 -1
  11. package/dist/driver.d.ts +18 -5
  12. package/dist/driver.d.ts.map +1 -1
  13. package/dist/driver.js +19 -2
  14. package/dist/driver.js.map +1 -1
  15. package/dist/eventbus.d.ts +7 -2
  16. package/dist/eventbus.d.ts.map +1 -1
  17. package/dist/eventbus.js +17 -4
  18. package/dist/eventbus.js.map +1 -1
  19. package/dist/focus-area.d.ts.map +1 -1
  20. package/dist/focus-area.js +10 -7
  21. package/dist/focus-area.js.map +1 -1
  22. package/dist/grid.d.ts +42 -0
  23. package/dist/grid.d.ts.map +1 -0
  24. package/dist/grid.js +182 -0
  25. package/dist/grid.js.map +1 -0
  26. package/dist/index.d.ts +5 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +8 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/keys.d.ts.map +1 -1
  31. package/dist/keys.js +4 -0
  32. package/dist/keys.js.map +1 -1
  33. package/dist/layout-rect.d.ts +14 -0
  34. package/dist/layout-rect.d.ts.map +1 -0
  35. package/dist/layout-rect.js +2 -0
  36. package/dist/layout-rect.js.map +1 -0
  37. package/dist/layout-utils.d.ts +2 -0
  38. package/dist/layout-utils.d.ts.map +1 -0
  39. package/dist/layout-utils.js +15 -0
  40. package/dist/layout-utils.js.map +1 -0
  41. package/dist/overlay.d.ts +29 -7
  42. package/dist/overlay.d.ts.map +1 -1
  43. package/dist/overlay.js +83 -12
  44. package/dist/overlay.js.map +1 -1
  45. package/dist/panels.d.ts.map +1 -1
  46. package/dist/panels.js +2 -3
  47. package/dist/panels.js.map +1 -1
  48. package/dist/runtime.d.ts.map +1 -1
  49. package/dist/runtime.js +30 -1
  50. package/dist/runtime.js.map +1 -1
  51. package/dist/split-pane.d.ts +89 -0
  52. package/dist/split-pane.d.ts.map +1 -0
  53. package/dist/split-pane.js +178 -0
  54. package/dist/split-pane.js.map +1 -0
  55. package/dist/transition-shaders.d.ts +43 -0
  56. package/dist/transition-shaders.d.ts.map +1 -0
  57. package/dist/transition-shaders.js +66 -0
  58. package/dist/transition-shaders.js.map +1 -0
  59. package/dist/viewport.d.ts +9 -0
  60. package/dist/viewport.d.ts.map +1 -1
  61. package/dist/viewport.js +57 -0
  62. package/dist/viewport.js.map +1 -1
  63. package/package.json +2 -2
@@ -0,0 +1,937 @@
1
+ /**
2
+ * `appFrame()` — high-level TEA app shell.
3
+ *
4
+ * Provides tabs, pane focus/scroll isolation, shell key handling, help,
5
+ * panel-scoped overlay context, and optional frame-level command palette.
6
+ */
7
+ import { resolveSafeCtx } from '@flyingrobots/bijou';
8
+ import { helpShort, helpView } from './help.js';
9
+ import { createKeyMap, formatKeyCombo, } from './keybindings.js';
10
+ import { isKeyMsg, isMouseMsg, isResizeMsg, QUIT } from './types.js';
11
+ import { composite, modal } from './overlay.js';
12
+ import { TRANSITION_SHADERS } from './transition-shaders.js';
13
+ import { commandPalette, commandPaletteKeyMap, createCommandPaletteState, cpFilter, cpFocusNext, cpFocusPrev, cpPageDown, cpPageUp, cpSelectedItem, } from './command-palette.js';
14
+ import { grid, gridLayout } from './grid.js';
15
+ import { createFocusAreaState, focusArea, focusAreaPageDown, focusAreaPageUp, focusAreaScrollBy, focusAreaScrollByX, focusAreaScrollToBottom, focusAreaScrollToTop, focusAreaScrollTo, focusAreaScrollToX, } from './focus-area.js';
16
+ import { splitPane, splitPaneLayout } from './split-pane.js';
17
+ import { clipToWidth, tokenizeAnsi, visibleLength } from './viewport.js';
18
+ import { EASINGS } from './spring.js';
19
+ import { timeline } from './timeline.js';
20
+ const PAGE_MSG_TOKEN = Symbol('app-frame-page-msg');
21
+ const FRAME_MSG_TOKEN = Symbol('app-frame-frame-msg');
22
+ /**
23
+ * Create a fully framed TEA app shell.
24
+ */
25
+ export function createFramedApp(options) {
26
+ if (options.pages.length === 0) {
27
+ throw new Error('createFramedApp: "pages" must contain at least one page');
28
+ }
29
+ const pagesById = new Map();
30
+ for (const page of options.pages) {
31
+ if (pagesById.has(page.id)) {
32
+ throw new Error(`createFramedApp: duplicate page id "${page.id}"`);
33
+ }
34
+ pagesById.set(page.id, page);
35
+ }
36
+ const pageOrder = options.pages.map((p) => p.id);
37
+ const defaultPageId = options.defaultPageId ?? pageOrder[0];
38
+ if (!pagesById.has(defaultPageId)) {
39
+ throw new Error(`createFramedApp: defaultPageId "${defaultPageId}" not found in pages`);
40
+ }
41
+ const frameKeys = createFrameKeyMap();
42
+ const paletteKeys = commandPaletteKeyMap({
43
+ focusNext: { type: 'cp-next' },
44
+ focusPrev: { type: 'cp-prev' },
45
+ pageDown: { type: 'cp-page-down' },
46
+ pageUp: { type: 'cp-page-up' },
47
+ select: { type: 'cp-select' },
48
+ close: { type: 'cp-close' },
49
+ });
50
+ const app = {
51
+ init() {
52
+ const pageModels = {};
53
+ const initCmds = [];
54
+ for (const page of options.pages) {
55
+ const [pageModel, cmds] = page.init();
56
+ pageModels[page.id] = pageModel;
57
+ initCmds.push(...cmds.map((cmd) => wrapCmdForPage(page.id, cmd)));
58
+ }
59
+ let model = {
60
+ activePageId: defaultPageId,
61
+ pageOrder,
62
+ pageModels,
63
+ focusedPaneByPage: {},
64
+ scrollByPage: {},
65
+ columns: Math.max(1, options.initialColumns ?? 80),
66
+ rows: Math.max(1, options.initialRows ?? 24),
67
+ helpOpen: false,
68
+ transitionProgress: 1,
69
+ transitionGeneration: 0,
70
+ };
71
+ for (const pageId of pageOrder) {
72
+ model = syncPageFrameState(model, pageId, pagesById);
73
+ }
74
+ return [model, initCmds];
75
+ },
76
+ update(msg, model) {
77
+ if (isFrameScopedMsg(msg)) {
78
+ const action = msg.action;
79
+ if (action.type === 'transition') {
80
+ // Ignore stale transition ticks from a previous generation
81
+ if (action.generation !== model.transitionGeneration)
82
+ return [model, []];
83
+ // Advance timeline using wall-clock elapsed time
84
+ if (model.transitionTimeline && model.transitionTimelineState && model.transitionStartMs != null) {
85
+ const elapsedMs = Date.now() - model.transitionStartMs;
86
+ const elapsedSec = Math.max(0, elapsedMs / 1000);
87
+ // Step from init to current elapsed time (tweens are deterministic)
88
+ const state = model.transitionTimeline.step(model.transitionTimeline.init(), elapsedSec);
89
+ const vals = model.transitionTimeline.values(state);
90
+ const progress = Math.min(1, Math.max(0, vals['progress'] ?? action.progress));
91
+ if (model.transitionTimeline.done(state) || progress >= 1) {
92
+ return [{
93
+ ...model,
94
+ transitionProgress: 1,
95
+ previousPageId: undefined,
96
+ activeTransition: undefined,
97
+ transitionStartMs: undefined,
98
+ transitionTimeline: undefined,
99
+ transitionTimelineState: undefined,
100
+ }, []];
101
+ }
102
+ return [{
103
+ ...model,
104
+ transitionProgress: progress,
105
+ transitionTimelineState: state,
106
+ }, []];
107
+ }
108
+ // Fallback for non-timeline transitions (backward compat)
109
+ return [{ ...model, transitionProgress: action.progress }, []];
110
+ }
111
+ if (action.type === 'transition-complete') {
112
+ if (action.generation !== model.transitionGeneration)
113
+ return [model, []];
114
+ return [{
115
+ ...model,
116
+ transitionProgress: 1,
117
+ previousPageId: undefined,
118
+ activeTransition: undefined,
119
+ transitionStartMs: undefined,
120
+ transitionTimeline: undefined,
121
+ transitionTimelineState: undefined,
122
+ }, []];
123
+ }
124
+ return applyFrameAction(action, model, frameKeys, options, pagesById);
125
+ }
126
+ if (isResizeMsg(msg)) {
127
+ return [{
128
+ ...model,
129
+ columns: msg.columns,
130
+ rows: msg.rows,
131
+ }, []];
132
+ }
133
+ if (isKeyMsg(msg)) {
134
+ if (model.commandPalette != null) {
135
+ return handlePaletteKey(msg, model, paletteKeys, frameKeys, options, pagesById);
136
+ }
137
+ // Help acts as a modal layer when open: only close keys are handled.
138
+ if (model.helpOpen) {
139
+ if (!msg.ctrl && !msg.alt && (msg.key === 'escape' || msg.key === '?')) {
140
+ return [{ ...model, helpOpen: false }, []];
141
+ }
142
+ return [model, []];
143
+ }
144
+ const frameAction = frameKeys.handle(msg);
145
+ if (frameAction !== undefined) {
146
+ return applyFrameAction(frameAction, model, frameKeys, options, pagesById);
147
+ }
148
+ const globalAction = options.globalKeys?.handle(msg);
149
+ if (globalAction !== undefined) {
150
+ return [model, [emitMsg(globalAction)]];
151
+ }
152
+ const activePage = pagesById.get(model.activePageId);
153
+ const pageAction = activePage.keyMap?.handle(msg);
154
+ if (pageAction !== undefined) {
155
+ return [model, [emitMsgForPage(model.activePageId, pageAction)]];
156
+ }
157
+ return [model, []];
158
+ }
159
+ if (isMouseMsg(msg)) {
160
+ return [model, []];
161
+ }
162
+ // Custom message path: route to originating page when command messages are scoped.
163
+ const scoped = isPageScopedMsg(msg) ? msg : undefined;
164
+ const targetPageId = scoped?.pageId ?? model.activePageId;
165
+ const targetPage = pagesById.get(targetPageId);
166
+ if (targetPage == null)
167
+ return [model, []];
168
+ const targetMsg = scoped?.msg ?? msg;
169
+ const pageModel = model.pageModels[targetPageId];
170
+ const [nextPageModel, cmds] = targetPage.update(targetMsg, pageModel);
171
+ const nextModels = { ...model.pageModels, [targetPageId]: nextPageModel };
172
+ const synced = syncPageFrameState({ ...model, pageModels: nextModels }, targetPageId, pagesById);
173
+ return [synced, cmds.map((cmd) => wrapCmdForPage(targetPageId, cmd))];
174
+ },
175
+ view(model) {
176
+ const activePage = pagesById.get(model.activePageId);
177
+ const header = renderHeaderLine(model, options, pagesById);
178
+ const helpLine = renderHelpLine(model, frameKeys, options, activePage);
179
+ const bodyRect = frameBodyRect(model.columns, model.rows);
180
+ const activeResult = renderPageContent(model.activePageId, model, bodyRect, pagesById);
181
+ let bodyOutput = activeResult.output;
182
+ const activeTransition = model.activeTransition ?? options.transition;
183
+ if (model.previousPageId != null && model.transitionProgress < 1 && activeTransition && activeTransition !== 'none') {
184
+ const ctx = resolveSafeCtx();
185
+ if (ctx) {
186
+ const prevResult = renderPageContent(model.previousPageId, model, bodyRect, pagesById);
187
+ bodyOutput = renderTransition(prevResult.output, activeResult.output, activeTransition, model.transitionProgress, bodyRect.width, bodyRect.height, ctx);
188
+ }
189
+ }
190
+ const bodyLines = fitBlock(bodyOutput, bodyRect.width, bodyRect.height);
191
+ const rows = [header, helpLine, ...bodyLines];
192
+ while (rows.length < model.rows)
193
+ rows.push(' '.repeat(Math.max(0, model.columns)));
194
+ let output = rows.slice(0, model.rows).join('\n');
195
+ const overlays = [];
196
+ if (options.overlayFactory != null) {
197
+ overlays.push(...options.overlayFactory({
198
+ activePageId: model.activePageId,
199
+ pageModel: model.pageModels[model.activePageId],
200
+ paneRects: activeResult.paneRects,
201
+ screenRect: { row: 0, col: 0, width: model.columns, height: model.rows },
202
+ }));
203
+ }
204
+ if (model.helpOpen) {
205
+ const full = helpView(mergeBindingSources(frameKeys, options.globalKeys, activePage.helpSource ?? activePage.keyMap));
206
+ overlays.push(modal({
207
+ title: 'Keyboard Help',
208
+ body: full.length > 0 ? full : 'No bindings',
209
+ hint: 'Press ? to close',
210
+ screenWidth: model.columns,
211
+ screenHeight: model.rows,
212
+ }));
213
+ }
214
+ if (model.commandPalette != null) {
215
+ const paletteWidth = Math.max(20, Math.min(80, model.columns - 4));
216
+ const paletteBody = commandPalette(model.commandPalette, { width: Math.max(16, paletteWidth - 4) });
217
+ overlays.push(modal({
218
+ title: 'Command Palette',
219
+ body: paletteBody,
220
+ hint: 'Enter select • Esc close',
221
+ width: paletteWidth,
222
+ screenWidth: model.columns,
223
+ screenHeight: model.rows,
224
+ }));
225
+ }
226
+ if (overlays.length > 0) {
227
+ output = composite(output, overlays, { dim: true });
228
+ }
229
+ return output;
230
+ },
231
+ };
232
+ return app;
233
+ }
234
+ function handlePaletteKey(msg, model, paletteKeys, frameKeys, options, pagesById) {
235
+ const cp = model.commandPalette;
236
+ const action = paletteKeys.handle(msg);
237
+ if (action != null) {
238
+ switch (action.type) {
239
+ case 'cp-next':
240
+ return [{ ...model, commandPalette: cpFocusNext(cp) }, []];
241
+ case 'cp-prev':
242
+ return [{ ...model, commandPalette: cpFocusPrev(cp) }, []];
243
+ case 'cp-page-down':
244
+ return [{ ...model, commandPalette: cpPageDown(cp) }, []];
245
+ case 'cp-page-up':
246
+ return [{ ...model, commandPalette: cpPageUp(cp) }, []];
247
+ case 'cp-close':
248
+ return [{ ...model, commandPalette: undefined, commandPaletteEntries: undefined }, []];
249
+ case 'cp-select': {
250
+ const selected = cpSelectedItem(cp);
251
+ if (selected == null) {
252
+ return [{ ...model, commandPalette: undefined, commandPaletteEntries: undefined }, []];
253
+ }
254
+ const entry = model.commandPaletteEntries?.find((x) => x.id === selected.id);
255
+ if (entry?.frameAction != null) {
256
+ const closed = { ...model, commandPalette: undefined, commandPaletteEntries: undefined };
257
+ return applyFrameAction(entry.frameAction, closed, frameKeys, options, pagesById);
258
+ }
259
+ if (entry?.msgAction !== undefined) {
260
+ const cmd = entry.targetPageId != null
261
+ ? emitMsgForPage(entry.targetPageId, entry.msgAction)
262
+ : emitMsg(entry.msgAction);
263
+ return [{
264
+ ...model,
265
+ commandPalette: undefined,
266
+ commandPaletteEntries: undefined,
267
+ }, [cmd]];
268
+ }
269
+ return [{ ...model, commandPalette: undefined, commandPaletteEntries: undefined }, []];
270
+ }
271
+ }
272
+ }
273
+ if (msg.key === 'backspace') {
274
+ const next = cpFilter(cp, cp.query.slice(0, -1));
275
+ return [{ ...model, commandPalette: next }, []];
276
+ }
277
+ if (msg.ctrl && msg.key === 'c') {
278
+ return [{ ...model, commandPalette: undefined, commandPaletteEntries: undefined }, []];
279
+ }
280
+ if (!msg.ctrl && !msg.alt && !msg.shift && msg.key === 'q' && cp.query.length === 0) {
281
+ return [{ ...model, commandPalette: undefined, commandPaletteEntries: undefined }, []];
282
+ }
283
+ if (!msg.ctrl && !msg.alt && msg.key.length === 1) {
284
+ const next = cpFilter(cp, cp.query + msg.key);
285
+ return [{ ...model, commandPalette: next }, []];
286
+ }
287
+ return [model, []];
288
+ }
289
+ function applyFrameAction(action, model, frameKeys, options, pagesById) {
290
+ switch (action.type) {
291
+ case 'toggle-help':
292
+ return [{ ...model, helpOpen: !model.helpOpen }, []];
293
+ case 'prev-tab':
294
+ return switchTab(model, -1, pagesById, options);
295
+ case 'next-tab':
296
+ return switchTab(model, 1, pagesById, options);
297
+ case 'next-pane':
298
+ return [cyclePane(model, 1, pagesById), []];
299
+ case 'prev-pane':
300
+ return [cyclePane(model, -1, pagesById), []];
301
+ case 'open-palette':
302
+ if (!options.enableCommandPalette)
303
+ return [model, []];
304
+ return [openCommandPalette(model, frameKeys, options, pagesById), []];
305
+ case 'scroll-up':
306
+ case 'scroll-down':
307
+ case 'page-up':
308
+ case 'page-down':
309
+ case 'top':
310
+ case 'bottom':
311
+ case 'scroll-left':
312
+ case 'scroll-right':
313
+ return [scrollFocusedPane(model, action, pagesById), []];
314
+ case 'transition':
315
+ case 'transition-complete':
316
+ return [model, []];
317
+ }
318
+ }
319
+ function switchTab(model, delta, pagesById, options) {
320
+ const idx = model.pageOrder.indexOf(model.activePageId);
321
+ if (idx < 0)
322
+ return [model, []];
323
+ const nextIdx = (idx + delta + model.pageOrder.length) % model.pageOrder.length;
324
+ const nextId = model.pageOrder[nextIdx];
325
+ if (nextId === model.activePageId)
326
+ return [model, []];
327
+ const activePageModel = model.pageModels[model.activePageId];
328
+ const activeTransition = options.transitionOverride
329
+ ? options.transitionOverride(activePageModel)
330
+ : options.transition;
331
+ const hasTransition = activeTransition != null && activeTransition !== 'none';
332
+ const nextGeneration = model.transitionGeneration + 1;
333
+ // Use the user-supplied timeline if provided, otherwise build a default.
334
+ // The timeline MUST contain a 'progress' track (0 → 1).
335
+ const tl = hasTransition
336
+ ? (options.transitionTimeline ?? timeline()
337
+ .add('progress', {
338
+ type: 'tween',
339
+ from: 0,
340
+ to: 1,
341
+ duration: options.transitionDuration ?? 300,
342
+ ease: EASINGS.easeInOutCubic,
343
+ })
344
+ .build())
345
+ : undefined;
346
+ const durationMs = tl?.estimatedDurationMs ?? options.transitionDuration ?? 300;
347
+ const nextModel = syncPageFrameState({
348
+ ...model,
349
+ activePageId: nextId,
350
+ previousPageId: model.activePageId,
351
+ activeTransition,
352
+ transitionProgress: hasTransition ? 0 : 1,
353
+ transitionGeneration: nextGeneration,
354
+ transitionStartMs: hasTransition ? Date.now() : undefined,
355
+ transitionTimeline: tl,
356
+ transitionTimelineState: tl?.init(),
357
+ }, nextId, pagesById);
358
+ if (hasTransition) {
359
+ // Schedule render ticks at ~60fps for the duration of the transition.
360
+ // Each tick advances the timeline using wall-clock elapsed time.
361
+ const cmd = createTransitionTickCmd(durationMs, nextGeneration);
362
+ return [nextModel, [cmd]];
363
+ }
364
+ return [nextModel, []];
365
+ }
366
+ function cyclePane(model, delta, pagesById) {
367
+ const page = pagesById.get(model.activePageId);
368
+ const paneIds = collectPaneIds(page.layout(model.pageModels[model.activePageId]));
369
+ if (paneIds.length === 0)
370
+ return model;
371
+ const curr = model.focusedPaneByPage[model.activePageId];
372
+ const idx = curr == null ? 0 : paneIds.indexOf(curr);
373
+ const nextIdx = idx < 0
374
+ ? 0
375
+ : (idx + delta + paneIds.length) % paneIds.length;
376
+ const next = paneIds[nextIdx];
377
+ return {
378
+ ...model,
379
+ focusedPaneByPage: {
380
+ ...model.focusedPaneByPage,
381
+ [model.activePageId]: next,
382
+ },
383
+ };
384
+ }
385
+ function scrollFocusedPane(model, action, pagesById) {
386
+ const pageId = model.activePageId;
387
+ const focusedPaneId = model.focusedPaneByPage[pageId];
388
+ if (focusedPaneId == null)
389
+ return model;
390
+ const page = pagesById.get(pageId);
391
+ const layoutTree = page.layout(model.pageModels[pageId]);
392
+ const bodyRect = frameBodyRect(model.columns, model.rows);
393
+ const resolved = renderFrameNode(layoutTree, bodyRect, {
394
+ model,
395
+ pageId,
396
+ focusedPaneId,
397
+ scrollByPane: model.scrollByPage[pageId] ?? {},
398
+ });
399
+ const paneRect = resolved.paneRects.get(focusedPaneId);
400
+ if (paneRect == null || paneRect.width <= 0 || paneRect.height <= 0)
401
+ return model;
402
+ const paneNode = findPaneNode(layoutTree, focusedPaneId);
403
+ if (paneNode == null)
404
+ return model;
405
+ const content = paneNode.render(paneRect.width, paneRect.height);
406
+ let state = createFocusAreaState({
407
+ content,
408
+ width: paneRect.width,
409
+ height: paneRect.height,
410
+ overflowX: paneNode.overflowX ?? 'hidden',
411
+ });
412
+ const prior = model.scrollByPage[pageId]?.[focusedPaneId] ?? { x: 0, y: 0 };
413
+ state = focusAreaScrollTo(state, prior.y);
414
+ state = focusAreaScrollToX(state, prior.x);
415
+ switch (action.type) {
416
+ case 'scroll-up':
417
+ state = focusAreaScrollBy(state, -1);
418
+ break;
419
+ case 'scroll-down':
420
+ state = focusAreaScrollBy(state, 1);
421
+ break;
422
+ case 'page-up':
423
+ state = focusAreaPageUp(state);
424
+ break;
425
+ case 'page-down':
426
+ state = focusAreaPageDown(state);
427
+ break;
428
+ case 'top':
429
+ state = focusAreaScrollToTop(state);
430
+ break;
431
+ case 'bottom':
432
+ state = focusAreaScrollToBottom(state);
433
+ break;
434
+ case 'scroll-left':
435
+ state = focusAreaScrollByX(state, -1);
436
+ break;
437
+ case 'scroll-right':
438
+ state = focusAreaScrollByX(state, 1);
439
+ break;
440
+ }
441
+ const pageScroll = model.scrollByPage[pageId] ?? {};
442
+ return {
443
+ ...model,
444
+ scrollByPage: {
445
+ ...model.scrollByPage,
446
+ [pageId]: {
447
+ ...pageScroll,
448
+ [focusedPaneId]: { x: state.scroll.x, y: state.scroll.y },
449
+ },
450
+ },
451
+ };
452
+ }
453
+ function openCommandPalette(model, frameKeys, options, pagesById) {
454
+ const entries = buildPaletteEntries(model, frameKeys, options, pagesById);
455
+ const items = entries.map((x) => x.item);
456
+ return {
457
+ ...model,
458
+ commandPalette: createCommandPaletteState(items, Math.max(5, Math.min(10, model.rows - 8))),
459
+ commandPaletteEntries: entries,
460
+ };
461
+ }
462
+ function buildPaletteEntries(model, frameKeys, options, pagesById) {
463
+ const entries = [];
464
+ let seq = 0;
465
+ for (const b of frameKeys.bindings()) {
466
+ if (!b.enabled)
467
+ continue;
468
+ const action = frameKeys.handle(comboToMsg(b));
469
+ if (action === undefined)
470
+ continue;
471
+ const id = `frame:${seq++}`;
472
+ entries.push({
473
+ id,
474
+ item: {
475
+ id,
476
+ label: b.description,
477
+ category: 'Frame',
478
+ shortcut: formatKeyCombo(b.combo),
479
+ },
480
+ frameAction: action,
481
+ });
482
+ }
483
+ const global = options.globalKeys;
484
+ if (global != null) {
485
+ for (const b of global.bindings()) {
486
+ if (!b.enabled)
487
+ continue;
488
+ const action = global.handle(comboToMsg(b));
489
+ if (action === undefined)
490
+ continue;
491
+ const id = `global:${seq++}`;
492
+ entries.push({
493
+ id,
494
+ item: {
495
+ id,
496
+ label: b.description,
497
+ category: 'Global',
498
+ shortcut: formatKeyCombo(b.combo),
499
+ },
500
+ msgAction: action,
501
+ });
502
+ }
503
+ }
504
+ const page = pagesById.get(model.activePageId);
505
+ if (page.keyMap != null) {
506
+ for (const b of page.keyMap.bindings()) {
507
+ if (!b.enabled)
508
+ continue;
509
+ const action = page.keyMap.handle(comboToMsg(b));
510
+ if (action === undefined)
511
+ continue;
512
+ const id = `page:${seq++}`;
513
+ entries.push({
514
+ id,
515
+ item: {
516
+ id,
517
+ label: b.description,
518
+ category: page.title,
519
+ shortcut: formatKeyCombo(b.combo),
520
+ },
521
+ msgAction: action,
522
+ targetPageId: model.activePageId,
523
+ });
524
+ }
525
+ }
526
+ if (page.commandItems != null) {
527
+ for (const item of page.commandItems(model.pageModels[model.activePageId])) {
528
+ const id = `custom:${seq++}`;
529
+ entries.push({
530
+ id,
531
+ item: {
532
+ ...item,
533
+ id,
534
+ category: item.category ?? page.title,
535
+ },
536
+ msgAction: item.action,
537
+ targetPageId: model.activePageId,
538
+ });
539
+ }
540
+ }
541
+ return entries;
542
+ }
543
+ function syncPageFrameState(model, pageId, pagesById) {
544
+ const page = pagesById.get(pageId);
545
+ const paneIds = collectPaneIds(page.layout(model.pageModels[pageId]));
546
+ assertUniquePaneIds(paneIds, `page "${pageId}" layout`);
547
+ const prevScroll = model.scrollByPage[pageId] ?? {};
548
+ const nextScroll = {};
549
+ for (const paneId of paneIds) {
550
+ nextScroll[paneId] = prevScroll[paneId] ?? { x: 0, y: 0 };
551
+ }
552
+ const prevFocused = model.focusedPaneByPage[pageId];
553
+ const focused = prevFocused != null && paneIds.includes(prevFocused)
554
+ ? prevFocused
555
+ : paneIds[0];
556
+ return {
557
+ ...model,
558
+ focusedPaneByPage: {
559
+ ...model.focusedPaneByPage,
560
+ [pageId]: focused,
561
+ },
562
+ scrollByPage: {
563
+ ...model.scrollByPage,
564
+ [pageId]: nextScroll,
565
+ },
566
+ };
567
+ }
568
+ function renderFrameNode(node, rect, ctx) {
569
+ if (rect.width <= 0 || rect.height <= 0) {
570
+ return { output: '', paneRects: new Map(), paneOrder: [] };
571
+ }
572
+ if (node.kind === 'pane') {
573
+ const prior = ctx.scrollByPane[node.paneId] ?? { x: 0, y: 0 };
574
+ const content = node.render(rect.width, rect.height);
575
+ let state = createFocusAreaState({
576
+ content,
577
+ width: rect.width,
578
+ height: rect.height,
579
+ overflowX: node.overflowX ?? 'hidden',
580
+ });
581
+ state = focusAreaScrollTo(state, prior.y);
582
+ state = focusAreaScrollToX(state, prior.x);
583
+ const output = focusArea(state, { focused: node.paneId === ctx.focusedPaneId });
584
+ return {
585
+ output,
586
+ paneRects: new Map([[node.paneId, rect]]),
587
+ paneOrder: [node.paneId],
588
+ };
589
+ }
590
+ if (node.kind === 'split') {
591
+ const direction = node.direction ?? 'row';
592
+ const layout = splitPaneLayout(node.state, {
593
+ direction,
594
+ width: rect.width,
595
+ height: rect.height,
596
+ minA: node.minA,
597
+ minB: node.minB,
598
+ });
599
+ const aRect = offsetRect(layout.paneA, rect.row, rect.col);
600
+ const bRect = offsetRect(layout.paneB, rect.row, rect.col);
601
+ const a = renderFrameNode(node.paneA, aRect, ctx);
602
+ const b = renderFrameNode(node.paneB, bRect, ctx);
603
+ const output = splitPane(node.state, {
604
+ direction,
605
+ width: rect.width,
606
+ height: rect.height,
607
+ minA: node.minA,
608
+ minB: node.minB,
609
+ dividerChar: node.dividerChar,
610
+ paneA: () => a.output,
611
+ paneB: () => b.output,
612
+ });
613
+ return {
614
+ output,
615
+ paneRects: mergeMaps(a.paneRects, b.paneRects),
616
+ paneOrder: [...a.paneOrder, ...b.paneOrder],
617
+ };
618
+ }
619
+ const relRects = gridLayout({
620
+ width: rect.width,
621
+ height: rect.height,
622
+ columns: node.columns,
623
+ rows: node.rows,
624
+ areas: node.areas,
625
+ gap: node.gap,
626
+ });
627
+ const renderedByArea = new Map();
628
+ for (const [areaName, areaRect] of relRects) {
629
+ const absoluteAreaRect = offsetRect(areaRect, rect.row, rect.col);
630
+ const child = node.cells[areaName];
631
+ if (child == null) {
632
+ console.warn(`createFramedApp: grid cell "${areaName}" missing in page "${ctx.pageId}" — rendering placeholder`);
633
+ renderedByArea.set(areaName, renderMissingGridCell(areaName, absoluteAreaRect));
634
+ continue;
635
+ }
636
+ renderedByArea.set(areaName, renderFrameNode(child, absoluteAreaRect, ctx));
637
+ }
638
+ const output = grid({
639
+ width: rect.width,
640
+ height: rect.height,
641
+ columns: node.columns,
642
+ rows: node.rows,
643
+ areas: node.areas,
644
+ gap: node.gap,
645
+ cells: Object.fromEntries([...renderedByArea.entries()].map(([name, rendered]) => [name, () => rendered.output])),
646
+ });
647
+ let paneRects = new Map();
648
+ const seenPaneIds = new Set();
649
+ const paneOrder = [];
650
+ for (const rendered of renderedByArea.values()) {
651
+ for (const [paneId, paneRect] of rendered.paneRects.entries()) {
652
+ if (paneRects.has(paneId)) {
653
+ throw new Error(`createFramedApp: duplicate paneId "${paneId}" in rendered layout`);
654
+ }
655
+ paneRects.set(paneId, paneRect);
656
+ }
657
+ for (const paneId of rendered.paneOrder) {
658
+ if (seenPaneIds.has(paneId)) {
659
+ throw new Error(`createFramedApp: duplicate paneId "${paneId}" in rendered pane order`);
660
+ }
661
+ seenPaneIds.add(paneId);
662
+ paneOrder.push(paneId);
663
+ }
664
+ }
665
+ return { output, paneRects, paneOrder };
666
+ }
667
+ function renderMissingGridCell(areaName, rect) {
668
+ return {
669
+ output: fitBlock(`[missing grid cell: ${areaName}]`, rect.width, rect.height).join('\n'),
670
+ paneRects: new Map(),
671
+ paneOrder: [],
672
+ };
673
+ }
674
+ function collectPaneIds(node) {
675
+ if (node.kind === 'pane')
676
+ return [node.paneId];
677
+ if (node.kind === 'split')
678
+ return [...collectPaneIds(node.paneA), ...collectPaneIds(node.paneB)];
679
+ const ids = [];
680
+ for (const areaName of declaredAreaNames(node.areas)) {
681
+ const child = node.cells[areaName];
682
+ if (child == null)
683
+ continue;
684
+ ids.push(...collectPaneIds(child));
685
+ }
686
+ return ids;
687
+ }
688
+ function declaredAreaNames(areas) {
689
+ const names = new Set();
690
+ for (const row of areas) {
691
+ for (const token of row.trim().split(/\s+/)) {
692
+ if (token !== '' && token !== '.')
693
+ names.add(token);
694
+ }
695
+ }
696
+ return [...names];
697
+ }
698
+ function assertUniquePaneIds(paneIds, scope) {
699
+ const seen = new Set();
700
+ for (const paneId of paneIds) {
701
+ if (seen.has(paneId)) {
702
+ throw new Error(`createFramedApp: duplicate paneId "${paneId}" in ${scope}`);
703
+ }
704
+ seen.add(paneId);
705
+ }
706
+ }
707
+ function findPaneNode(node, paneId) {
708
+ if (node.kind === 'pane')
709
+ return node.paneId === paneId ? node : undefined;
710
+ if (node.kind === 'split')
711
+ return findPaneNode(node.paneA, paneId) ?? findPaneNode(node.paneB, paneId);
712
+ for (const key of Object.keys(node.cells)) {
713
+ const found = findPaneNode(node.cells[key], paneId);
714
+ if (found)
715
+ return found;
716
+ }
717
+ return undefined;
718
+ }
719
+ function createFrameKeyMap() {
720
+ return createKeyMap()
721
+ .group('Frame', (g) => g
722
+ .bind('?', 'Toggle help', { type: 'toggle-help' })
723
+ .bind('[', 'Previous tab', { type: 'prev-tab' })
724
+ .bind(']', 'Next tab', { type: 'next-tab' })
725
+ .bind('tab', 'Next pane', { type: 'next-pane' })
726
+ .bind('shift+tab', 'Previous pane', { type: 'prev-pane' })
727
+ .bind('ctrl+p', 'Open command palette', { type: 'open-palette' })
728
+ .bind(':', 'Open command palette', { type: 'open-palette' }))
729
+ .group('Scroll', (g) => g
730
+ .bind('j', 'Scroll down', { type: 'scroll-down' })
731
+ .bind('k', 'Scroll up', { type: 'scroll-up' })
732
+ .bind('d', 'Page down', { type: 'page-down' })
733
+ .bind('u', 'Page up', { type: 'page-up' })
734
+ .bind('g', 'Top', { type: 'top' })
735
+ .bind('shift+g', 'Bottom', { type: 'bottom' })
736
+ .bind('h', 'Scroll left', { type: 'scroll-left' })
737
+ .bind('l', 'Scroll right', { type: 'scroll-right' }));
738
+ }
739
+ function renderHeaderLine(model, options, pagesById) {
740
+ const title = options.title ?? 'App';
741
+ const tabs = model.pageOrder.map((id) => {
742
+ const page = pagesById.get(id);
743
+ return id === model.activePageId ? `[${page.title}]` : ` ${page.title} `;
744
+ }).join(' ');
745
+ return fitLine(`${title} ${tabs}`, model.columns);
746
+ }
747
+ function renderHelpLine(model, frameKeys, options, activePage) {
748
+ const mode = model.commandPalette != null
749
+ ? 'PALETTE'
750
+ : model.helpOpen
751
+ ? 'HELP'
752
+ : 'NORMAL';
753
+ const focusedPane = model.focusedPaneByPage[model.activePageId] ?? '-';
754
+ const status = `[${mode}] page:${model.activePageId} pane:${focusedPane}`;
755
+ const source = mergeBindingSources(frameKeys, options.globalKeys, activePage.helpSource ?? activePage.keyMap);
756
+ const hint = helpShort(source);
757
+ const line = hint.length > 0
758
+ ? ` ${status} ${hint}`
759
+ : ` ${status}`;
760
+ return fitLine(line, model.columns);
761
+ }
762
+ function mergeBindingSources(...sources) {
763
+ return {
764
+ bindings() {
765
+ const merged = [];
766
+ for (const src of sources) {
767
+ if (src == null)
768
+ continue;
769
+ merged.push(...src.bindings());
770
+ }
771
+ return merged;
772
+ },
773
+ };
774
+ }
775
+ function frameBodyRect(columns, rows) {
776
+ return {
777
+ row: Math.min(2, Math.max(0, rows)),
778
+ col: 0,
779
+ width: Math.max(0, columns),
780
+ height: Math.max(0, rows - 2),
781
+ };
782
+ }
783
+ function fitBlock(content, width, height) {
784
+ if (width <= 0 || height <= 0)
785
+ return Array.from({ length: Math.max(0, height) }, () => '');
786
+ const src = content.split('\n');
787
+ const out = [];
788
+ for (let i = 0; i < height; i++) {
789
+ const line = src[i] ?? '';
790
+ const clipped = clipToWidth(line, width);
791
+ out.push(clipped + ' '.repeat(Math.max(0, width - visibleLength(clipped))));
792
+ }
793
+ return out;
794
+ }
795
+ function fitLine(line, width) {
796
+ const clipped = clipToWidth(line, Math.max(0, width));
797
+ return clipped + ' '.repeat(Math.max(0, width - visibleLength(clipped)));
798
+ }
799
+ function mergeMaps(a, b) {
800
+ const out = new Map();
801
+ for (const [k, v] of a)
802
+ out.set(k, v);
803
+ for (const [k, v] of b)
804
+ out.set(k, v);
805
+ return out;
806
+ }
807
+ function offsetRect(rect, rowOffset, colOffset) {
808
+ return {
809
+ row: rowOffset + rect.row,
810
+ col: colOffset + rect.col,
811
+ width: rect.width,
812
+ height: rect.height,
813
+ };
814
+ }
815
+ function comboToMsg(binding) {
816
+ return {
817
+ type: 'key',
818
+ key: binding.combo.key,
819
+ ctrl: binding.combo.ctrl,
820
+ alt: binding.combo.alt,
821
+ shift: binding.combo.shift,
822
+ };
823
+ }
824
+ function isFrameScopedMsg(value) {
825
+ return typeof value === 'object'
826
+ && value !== null
827
+ && FRAME_MSG_TOKEN in value
828
+ && value[FRAME_MSG_TOKEN] === true;
829
+ }
830
+ function wrapFrameMsg(action) {
831
+ return {
832
+ [FRAME_MSG_TOKEN]: true,
833
+ action,
834
+ };
835
+ }
836
+ function emitMsg(msg) {
837
+ return () => Promise.resolve(msg);
838
+ }
839
+ function emitMsgForPage(pageId, msg) {
840
+ return async () => wrapPageMsg(pageId, msg);
841
+ }
842
+ function wrapCmdForPage(pageId, cmd) {
843
+ return async (emit) => {
844
+ const result = await cmd((msg) => emit(wrapPageMsg(pageId, msg)));
845
+ if (result === undefined || result === QUIT)
846
+ return result;
847
+ return wrapPageMsg(pageId, result);
848
+ };
849
+ }
850
+ function wrapPageMsg(pageId, msg) {
851
+ return {
852
+ [PAGE_MSG_TOKEN]: true,
853
+ pageId,
854
+ msg,
855
+ };
856
+ }
857
+ function isPageScopedMsg(value) {
858
+ return typeof value === 'object'
859
+ && value !== null
860
+ && PAGE_MSG_TOKEN in value
861
+ && value[PAGE_MSG_TOKEN] === true;
862
+ }
863
+ /**
864
+ * Create a TEA command that drives transition re-renders via `setInterval`.
865
+ *
866
+ * Each tick emits a frame-scoped 'transition' message. The actual progress
867
+ * is computed from wall-clock time in the update handler, not from the
868
+ * interval count. The interval just schedules re-renders.
869
+ */
870
+ function createTransitionTickCmd(durationMs, generation) {
871
+ return (emit) => new Promise((resolve) => {
872
+ const startMs = Date.now();
873
+ const intervalMs = Math.round(1000 / 60);
874
+ const id = setInterval(() => {
875
+ const elapsed = Date.now() - startMs;
876
+ const rawProgress = Math.min(1, elapsed / durationMs);
877
+ emit(wrapFrameMsg({ type: 'transition', progress: rawProgress, generation }));
878
+ if (rawProgress >= 1) {
879
+ clearInterval(id);
880
+ emit(wrapFrameMsg({ type: 'transition-complete', generation }));
881
+ resolve();
882
+ }
883
+ }, intervalMs);
884
+ });
885
+ }
886
+ function renderPageContent(pageId, model, bodyRect, pagesById) {
887
+ const page = pagesById.get(pageId);
888
+ const pageModel = model.pageModels[pageId];
889
+ const renderCtx = {
890
+ model,
891
+ pageId,
892
+ focusedPaneId: model.focusedPaneByPage[pageId],
893
+ scrollByPane: model.scrollByPage[pageId] ?? {},
894
+ };
895
+ return renderFrameNode(page.layout(pageModel), bodyRect, renderCtx);
896
+ }
897
+ /**
898
+ * Split a styled multiline string into a 2D grid of single-column characters.
899
+ * Each cell is a fully-styled string (including resets).
900
+ */
901
+ function stringToGrid(str, width, height) {
902
+ const lines = str.split('\n');
903
+ const grid = [];
904
+ for (let y = 0; y < height; y++) {
905
+ const line = lines[y] ?? '';
906
+ grid.push(tokenizeAnsi(line, width));
907
+ }
908
+ return grid;
909
+ }
910
+ function renderTransition(prev, next, style, progress, width, height, ctx) {
911
+ const shader = typeof style === 'function' ? style : TRANSITION_SHADERS[style];
912
+ if (!shader)
913
+ return next;
914
+ const prevGrid = stringToGrid(prev, width, height);
915
+ const nextGrid = stringToGrid(next, width, height);
916
+ const lines = [];
917
+ for (let y = 0; y < height; y++) {
918
+ let line = '';
919
+ for (let x = 0; x < width; x++) {
920
+ // Shared stable-ish pseudo-random seed based on coordinates
921
+ const seed = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
922
+ const rand = seed - Math.floor(seed);
923
+ const result = shader({ x, y, width, height, progress, rand, ctx });
924
+ const showNext = result.showNext;
925
+ const charOverride = result.char;
926
+ if (charOverride !== undefined) {
927
+ line += charOverride;
928
+ }
929
+ else {
930
+ line += (showNext ? nextGrid[y]?.[x] : prevGrid[y]?.[x]) ?? ' ';
931
+ }
932
+ }
933
+ lines.push(line);
934
+ }
935
+ return lines.join('\n');
936
+ }
937
+ //# sourceMappingURL=app-frame.js.map