@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.
- package/README.md +64 -21
- package/dist/animate.d.ts +0 -2
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +17 -26
- package/dist/animate.js.map +1 -1
- 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 +39 -0
- package/dist/app-frame-render.d.ts.map +1 -0
- package/dist/app-frame-render.js +319 -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 +13 -8
- package/dist/app-frame.d.ts.map +1 -1
- package/dist/app-frame.js +80 -906
- package/dist/app-frame.js.map +1 -1
- package/dist/canvas.d.ts +37 -25
- package/dist/canvas.d.ts.map +1 -1
- package/dist/canvas.js +116 -30
- package/dist/canvas.js.map +1 -1
- package/dist/commands.js +2 -2
- package/dist/commands.js.map +1 -1
- package/dist/css/install.d.ts +4 -0
- package/dist/css/install.d.ts.map +1 -0
- package/dist/css/install.js +24 -0
- package/dist/css/install.js.map +1 -0
- package/dist/css/parser.d.ts +14 -0
- package/dist/css/parser.d.ts.map +1 -0
- package/dist/css/parser.js +92 -0
- package/dist/css/parser.js.map +1 -0
- package/dist/css/resolver.d.ts +36 -0
- package/dist/css/resolver.d.ts.map +1 -0
- package/dist/css/resolver.js +130 -0
- package/dist/css/resolver.js.map +1 -0
- package/dist/css/text-style.d.ts +17 -0
- package/dist/css/text-style.d.ts.map +1 -0
- package/dist/css/text-style.js +59 -0
- package/dist/css/text-style.js.map +1 -0
- package/dist/css/types.d.ts +27 -0
- package/dist/css/types.d.ts.map +1 -0
- package/dist/css/types.js +5 -0
- package/dist/css/types.js.map +1 -0
- package/dist/driver.d.ts +10 -2
- package/dist/driver.d.ts.map +1 -1
- package/dist/driver.js +25 -2
- package/dist/driver.js.map +1 -1
- package/dist/eventbus.d.ts +42 -3
- package/dist/eventbus.d.ts.map +1 -1
- package/dist/eventbus.js +108 -7
- package/dist/eventbus.js.map +1 -1
- package/dist/focus-area.d.ts +4 -0
- package/dist/focus-area.d.ts.map +1 -1
- package/dist/focus-area.js +11 -1
- package/dist/focus-area.js.map +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -1
- package/dist/index.js.map +1 -1
- package/dist/layout-v3.d.ts +10 -0
- package/dist/layout-v3.d.ts.map +1 -0
- package/dist/layout-v3.js +35 -0
- package/dist/layout-v3.js.map +1 -0
- package/dist/motion/motion.d.ts +14 -0
- package/dist/motion/motion.d.ts.map +1 -0
- package/dist/motion/motion.js +31 -0
- package/dist/motion/motion.js.map +1 -0
- package/dist/motion/reconciler.d.ts +15 -0
- package/dist/motion/reconciler.d.ts.map +1 -0
- package/dist/motion/reconciler.js +109 -0
- package/dist/motion/reconciler.js.map +1 -0
- package/dist/motion/types.d.ts +52 -0
- package/dist/motion/types.d.ts.map +1 -0
- package/dist/motion/types.js +2 -0
- package/dist/motion/types.js.map +1 -0
- package/dist/pipeline/middleware/css.d.ts +20 -0
- package/dist/pipeline/middleware/css.d.ts.map +1 -0
- package/dist/pipeline/middleware/css.js +41 -0
- package/dist/pipeline/middleware/css.js.map +1 -0
- package/dist/pipeline/middleware/grayscale.d.ts +9 -0
- package/dist/pipeline/middleware/grayscale.d.ts.map +1 -0
- package/dist/pipeline/middleware/grayscale.js +39 -0
- package/dist/pipeline/middleware/grayscale.js.map +1 -0
- package/dist/pipeline/middleware/motion.d.ts +6 -0
- package/dist/pipeline/middleware/motion.d.ts.map +1 -0
- package/dist/pipeline/middleware/motion.js +19 -0
- package/dist/pipeline/middleware/motion.js.map +1 -0
- package/dist/pipeline/middleware/paint.d.ts +6 -0
- package/dist/pipeline/middleware/paint.d.ts.map +1 -0
- package/dist/pipeline/middleware/paint.js +21 -0
- package/dist/pipeline/middleware/paint.js.map +1 -0
- package/dist/pipeline/pipeline.d.ts +56 -0
- package/dist/pipeline/pipeline.d.ts.map +1 -0
- package/dist/pipeline/pipeline.js +45 -0
- package/dist/pipeline/pipeline.js.map +1 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +153 -8
- package/dist/runtime.js.map +1 -1
- package/dist/screen.d.ts +12 -1
- package/dist/screen.d.ts.map +1 -1
- package/dist/screen.js +14 -1
- package/dist/screen.js.map +1 -1
- package/dist/subapp/mount.d.ts +67 -0
- package/dist/subapp/mount.d.ts.map +1 -0
- package/dist/subapp/mount.js +60 -0
- package/dist/subapp/mount.js.map +1 -0
- 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/dist/types.d.ts +45 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -1
- package/dist/view-output.d.ts +15 -0
- package/dist/view-output.d.ts.map +1 -0
- package/dist/view-output.js +53 -0
- package/dist/view-output.js.map +1 -0
- 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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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 {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 [{
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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 (
|
|
305
|
-
|
|
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 (
|
|
308
|
-
|
|
292
|
+
if (options.dimBackground) {
|
|
293
|
+
dimSurface(frame);
|
|
309
294
|
}
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
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
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
753
|
-
|
|
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
|