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