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