@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.
- package/dist/app-frame-actions.d.ts +37 -0
- package/dist/app-frame-actions.d.ts.map +1 -0
- package/dist/app-frame-actions.js +313 -0
- package/dist/app-frame-actions.js.map +1 -0
- package/dist/app-frame-palette.d.ts +16 -0
- package/dist/app-frame-palette.d.ts.map +1 -0
- package/dist/app-frame-palette.js +158 -0
- package/dist/app-frame-palette.js.map +1 -0
- package/dist/app-frame-render.d.ts +37 -0
- package/dist/app-frame-render.d.ts.map +1 -0
- package/dist/app-frame-render.js +277 -0
- package/dist/app-frame-render.js.map +1 -0
- package/dist/app-frame-types.d.ts +135 -0
- package/dist/app-frame-types.d.ts.map +1 -0
- package/dist/app-frame-types.js +72 -0
- package/dist/app-frame-types.js.map +1 -0
- package/dist/app-frame-utils.d.ts +37 -0
- package/dist/app-frame-utils.d.ts.map +1 -0
- package/dist/app-frame-utils.js +141 -0
- package/dist/app-frame-utils.js.map +1 -0
- package/dist/app-frame.d.ts +20 -4
- package/dist/app-frame.d.ts.map +1 -1
- package/dist/app-frame.js +52 -747
- package/dist/app-frame.js.map +1 -1
- package/dist/eventbus.d.ts +5 -0
- package/dist/eventbus.d.ts.map +1 -1
- package/dist/eventbus.js +17 -3
- package/dist/eventbus.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/layout-preset.d.ts +81 -0
- package/dist/layout-preset.d.ts.map +1 -0
- package/dist/layout-preset.js +107 -0
- package/dist/layout-preset.js.map +1 -0
- package/dist/panel-dock.d.ts +52 -0
- package/dist/panel-dock.d.ts.map +1 -0
- package/dist/panel-dock.js +112 -0
- package/dist/panel-dock.js.map +1 -0
- package/dist/panel-state.d.ts +30 -0
- package/dist/panel-state.d.ts.map +1 -0
- package/dist/panel-state.js +50 -0
- package/dist/panel-state.js.map +1 -0
- package/dist/screen.d.ts +2 -5
- package/dist/screen.d.ts.map +1 -1
- package/dist/screen.js +2 -4
- package/dist/screen.js.map +1 -1
- package/dist/split-pane.d.ts +5 -0
- package/dist/split-pane.d.ts.map +1 -1
- package/dist/split-pane.js +6 -8
- package/dist/split-pane.js.map +1 -1
- package/dist/transition-shaders.d.ts +101 -3
- package/dist/transition-shaders.d.ts.map +1 -1
- package/dist/transition-shaders.js +281 -6
- package/dist/transition-shaders.js.map +1 -1
- 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 {
|
|
9
|
-
import {
|
|
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 {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 [{
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|