@flyingrobots/bijou-tui 1.2.0 → 1.3.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 +117 -4
- package/dist/app-frame.d.ts +127 -0
- package/dist/app-frame.d.ts.map +1 -0
- package/dist/app-frame.js +751 -0
- package/dist/app-frame.js.map +1 -0
- package/dist/driver.d.ts +18 -5
- package/dist/driver.d.ts.map +1 -1
- package/dist/driver.js +19 -2
- package/dist/driver.js.map +1 -1
- package/dist/eventbus.d.ts +7 -2
- package/dist/eventbus.d.ts.map +1 -1
- package/dist/eventbus.js +17 -4
- package/dist/eventbus.js.map +1 -1
- package/dist/grid.d.ts +42 -0
- package/dist/grid.d.ts.map +1 -0
- package/dist/grid.js +182 -0
- package/dist/grid.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +4 -0
- package/dist/keys.js.map +1 -1
- package/dist/layout-rect.d.ts +14 -0
- package/dist/layout-rect.d.ts.map +1 -0
- package/dist/layout-rect.js +2 -0
- package/dist/layout-rect.js.map +1 -0
- package/dist/layout-utils.d.ts +2 -0
- package/dist/layout-utils.d.ts.map +1 -0
- package/dist/layout-utils.js +15 -0
- package/dist/layout-utils.js.map +1 -0
- package/dist/overlay.d.ts +29 -7
- package/dist/overlay.d.ts.map +1 -1
- package/dist/overlay.js +80 -9
- package/dist/overlay.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +30 -1
- package/dist/runtime.js.map +1 -1
- package/dist/split-pane.d.ts +89 -0
- package/dist/split-pane.d.ts.map +1 -0
- package/dist/split-pane.js +178 -0
- package/dist/split-pane.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `appFrame()` — high-level TEA app shell.
|
|
3
|
+
*
|
|
4
|
+
* Provides tabs, pane focus/scroll isolation, shell key handling, help,
|
|
5
|
+
* panel-scoped overlay context, and optional frame-level command palette.
|
|
6
|
+
*/
|
|
7
|
+
import { helpShort, helpView } from './help.js';
|
|
8
|
+
import { createKeyMap, formatKeyCombo, } from './keybindings.js';
|
|
9
|
+
import { isKeyMsg, isMouseMsg, isResizeMsg, QUIT } from './types.js';
|
|
10
|
+
import { composite, modal } from './overlay.js';
|
|
11
|
+
import { commandPalette, commandPaletteKeyMap, createCommandPaletteState, cpFilter, cpFocusNext, cpFocusPrev, cpPageDown, cpPageUp, cpSelectedItem, } from './command-palette.js';
|
|
12
|
+
import { grid, gridLayout } from './grid.js';
|
|
13
|
+
import { createFocusAreaState, focusArea, focusAreaPageDown, focusAreaPageUp, focusAreaScrollBy, focusAreaScrollByX, focusAreaScrollToBottom, focusAreaScrollToTop, focusAreaScrollTo, focusAreaScrollToX, } from './focus-area.js';
|
|
14
|
+
import { splitPane, splitPaneLayout, } from './split-pane.js';
|
|
15
|
+
import { clipToWidth, visibleLength } from './viewport.js';
|
|
16
|
+
const PAGE_MSG_TOKEN = Symbol('app-frame-page-msg');
|
|
17
|
+
/**
|
|
18
|
+
* Create a fully framed TEA app shell.
|
|
19
|
+
*/
|
|
20
|
+
export function createFramedApp(options) {
|
|
21
|
+
if (options.pages.length === 0) {
|
|
22
|
+
throw new Error('createFramedApp: "pages" must contain at least one page');
|
|
23
|
+
}
|
|
24
|
+
const pagesById = new Map();
|
|
25
|
+
for (const page of options.pages) {
|
|
26
|
+
if (pagesById.has(page.id)) {
|
|
27
|
+
throw new Error(`createFramedApp: duplicate page id "${page.id}"`);
|
|
28
|
+
}
|
|
29
|
+
pagesById.set(page.id, page);
|
|
30
|
+
}
|
|
31
|
+
const pageOrder = options.pages.map((p) => p.id);
|
|
32
|
+
const defaultPageId = options.defaultPageId ?? pageOrder[0];
|
|
33
|
+
if (!pagesById.has(defaultPageId)) {
|
|
34
|
+
throw new Error(`createFramedApp: defaultPageId "${defaultPageId}" not found in pages`);
|
|
35
|
+
}
|
|
36
|
+
const frameKeys = createFrameKeyMap();
|
|
37
|
+
const paletteKeys = commandPaletteKeyMap({
|
|
38
|
+
focusNext: { type: 'cp-next' },
|
|
39
|
+
focusPrev: { type: 'cp-prev' },
|
|
40
|
+
pageDown: { type: 'cp-page-down' },
|
|
41
|
+
pageUp: { type: 'cp-page-up' },
|
|
42
|
+
select: { type: 'cp-select' },
|
|
43
|
+
close: { type: 'cp-close' },
|
|
44
|
+
});
|
|
45
|
+
const app = {
|
|
46
|
+
init() {
|
|
47
|
+
const pageModels = {};
|
|
48
|
+
const initCmds = [];
|
|
49
|
+
for (const page of options.pages) {
|
|
50
|
+
const [pageModel, cmds] = page.init();
|
|
51
|
+
pageModels[page.id] = pageModel;
|
|
52
|
+
initCmds.push(...cmds.map((cmd) => wrapCmdForPage(page.id, cmd)));
|
|
53
|
+
}
|
|
54
|
+
let model = {
|
|
55
|
+
activePageId: defaultPageId,
|
|
56
|
+
pageOrder,
|
|
57
|
+
pageModels,
|
|
58
|
+
focusedPaneByPage: {},
|
|
59
|
+
scrollByPage: {},
|
|
60
|
+
columns: Math.max(1, options.initialColumns ?? 80),
|
|
61
|
+
rows: Math.max(1, options.initialRows ?? 24),
|
|
62
|
+
helpOpen: false,
|
|
63
|
+
};
|
|
64
|
+
for (const pageId of pageOrder) {
|
|
65
|
+
model = syncPageFrameState(model, pageId, pagesById);
|
|
66
|
+
}
|
|
67
|
+
return [model, initCmds];
|
|
68
|
+
},
|
|
69
|
+
update(msg, model) {
|
|
70
|
+
if (isResizeMsg(msg)) {
|
|
71
|
+
return [{
|
|
72
|
+
...model,
|
|
73
|
+
columns: msg.columns,
|
|
74
|
+
rows: msg.rows,
|
|
75
|
+
}, []];
|
|
76
|
+
}
|
|
77
|
+
if (isKeyMsg(msg)) {
|
|
78
|
+
if (model.commandPalette != null) {
|
|
79
|
+
return handlePaletteKey(msg, model, paletteKeys, frameKeys, options, pagesById);
|
|
80
|
+
}
|
|
81
|
+
// Help acts as a modal layer when open: only close keys are handled.
|
|
82
|
+
if (model.helpOpen) {
|
|
83
|
+
if (!msg.ctrl && !msg.alt && (msg.key === 'escape' || msg.key === '?')) {
|
|
84
|
+
return [{ ...model, helpOpen: false }, []];
|
|
85
|
+
}
|
|
86
|
+
return [model, []];
|
|
87
|
+
}
|
|
88
|
+
const frameAction = frameKeys.handle(msg);
|
|
89
|
+
if (frameAction !== undefined) {
|
|
90
|
+
return applyFrameAction(frameAction, model, frameKeys, options, pagesById);
|
|
91
|
+
}
|
|
92
|
+
const globalAction = options.globalKeys?.handle(msg);
|
|
93
|
+
if (globalAction !== undefined) {
|
|
94
|
+
return [model, [emitMsg(globalAction)]];
|
|
95
|
+
}
|
|
96
|
+
const activePage = pagesById.get(model.activePageId);
|
|
97
|
+
const pageAction = activePage.keyMap?.handle(msg);
|
|
98
|
+
if (pageAction !== undefined) {
|
|
99
|
+
return [model, [emitMsgForPage(model.activePageId, pageAction)]];
|
|
100
|
+
}
|
|
101
|
+
return [model, []];
|
|
102
|
+
}
|
|
103
|
+
if (isMouseMsg(msg)) {
|
|
104
|
+
return [model, []];
|
|
105
|
+
}
|
|
106
|
+
// Custom message path: route to originating page when command messages are scoped.
|
|
107
|
+
const scoped = isPageScopedMsg(msg) ? msg : undefined;
|
|
108
|
+
const targetPageId = scoped?.pageId ?? model.activePageId;
|
|
109
|
+
const targetPage = pagesById.get(targetPageId);
|
|
110
|
+
if (targetPage == null)
|
|
111
|
+
return [model, []];
|
|
112
|
+
const targetMsg = scoped?.msg ?? msg;
|
|
113
|
+
const pageModel = model.pageModels[targetPageId];
|
|
114
|
+
const [nextPageModel, cmds] = targetPage.update(targetMsg, pageModel);
|
|
115
|
+
const nextModels = { ...model.pageModels, [targetPageId]: nextPageModel };
|
|
116
|
+
const synced = syncPageFrameState({ ...model, pageModels: nextModels }, targetPageId, pagesById);
|
|
117
|
+
return [synced, cmds.map((cmd) => wrapCmdForPage(targetPageId, cmd))];
|
|
118
|
+
},
|
|
119
|
+
view(model) {
|
|
120
|
+
const activePage = pagesById.get(model.activePageId);
|
|
121
|
+
const activePageModel = model.pageModels[model.activePageId];
|
|
122
|
+
const header = renderHeaderLine(model, options, pagesById);
|
|
123
|
+
const helpLine = renderHelpLine(model, frameKeys, options, activePage);
|
|
124
|
+
const bodyRect = frameBodyRect(model.columns, model.rows);
|
|
125
|
+
const renderCtx = {
|
|
126
|
+
model,
|
|
127
|
+
pageId: model.activePageId,
|
|
128
|
+
focusedPaneId: model.focusedPaneByPage[model.activePageId],
|
|
129
|
+
scrollByPane: model.scrollByPage[model.activePageId] ?? {},
|
|
130
|
+
};
|
|
131
|
+
const rendered = renderFrameNode(activePage.layout(activePageModel), bodyRect, renderCtx);
|
|
132
|
+
const bodyLines = fitBlock(rendered.output, bodyRect.width, bodyRect.height);
|
|
133
|
+
const rows = [header, helpLine, ...bodyLines];
|
|
134
|
+
while (rows.length < model.rows)
|
|
135
|
+
rows.push(' '.repeat(Math.max(0, model.columns)));
|
|
136
|
+
let output = rows.slice(0, model.rows).join('\n');
|
|
137
|
+
const overlays = [];
|
|
138
|
+
if (options.overlayFactory != null) {
|
|
139
|
+
overlays.push(...options.overlayFactory({
|
|
140
|
+
activePageId: model.activePageId,
|
|
141
|
+
pageModel: activePageModel,
|
|
142
|
+
paneRects: rendered.paneRects,
|
|
143
|
+
screenRect: { row: 0, col: 0, width: model.columns, height: model.rows },
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
if (model.helpOpen) {
|
|
147
|
+
const full = helpView(mergeBindingSources(frameKeys, options.globalKeys, activePage.helpSource ?? activePage.keyMap));
|
|
148
|
+
overlays.push(modal({
|
|
149
|
+
title: 'Keyboard Help',
|
|
150
|
+
body: full.length > 0 ? full : 'No bindings',
|
|
151
|
+
hint: 'Press ? to close',
|
|
152
|
+
screenWidth: model.columns,
|
|
153
|
+
screenHeight: model.rows,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
if (model.commandPalette != null) {
|
|
157
|
+
const paletteWidth = Math.max(20, Math.min(80, model.columns - 4));
|
|
158
|
+
const paletteBody = commandPalette(model.commandPalette, { width: Math.max(16, paletteWidth - 4) });
|
|
159
|
+
overlays.push(modal({
|
|
160
|
+
title: 'Command Palette',
|
|
161
|
+
body: paletteBody,
|
|
162
|
+
hint: 'Enter select • Esc close',
|
|
163
|
+
width: paletteWidth,
|
|
164
|
+
screenWidth: model.columns,
|
|
165
|
+
screenHeight: model.rows,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
if (overlays.length > 0) {
|
|
169
|
+
output = composite(output, overlays, { dim: true });
|
|
170
|
+
}
|
|
171
|
+
return output;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
return app;
|
|
175
|
+
}
|
|
176
|
+
function handlePaletteKey(msg, model, paletteKeys, frameKeys, options, pagesById) {
|
|
177
|
+
const cp = model.commandPalette;
|
|
178
|
+
const action = paletteKeys.handle(msg);
|
|
179
|
+
if (action != null) {
|
|
180
|
+
switch (action.type) {
|
|
181
|
+
case 'cp-next':
|
|
182
|
+
return [{ ...model, commandPalette: cpFocusNext(cp) }, []];
|
|
183
|
+
case 'cp-prev':
|
|
184
|
+
return [{ ...model, commandPalette: cpFocusPrev(cp) }, []];
|
|
185
|
+
case 'cp-page-down':
|
|
186
|
+
return [{ ...model, commandPalette: cpPageDown(cp) }, []];
|
|
187
|
+
case 'cp-page-up':
|
|
188
|
+
return [{ ...model, commandPalette: cpPageUp(cp) }, []];
|
|
189
|
+
case 'cp-close':
|
|
190
|
+
return [{ ...model, commandPalette: undefined, commandPaletteEntries: undefined }, []];
|
|
191
|
+
case 'cp-select': {
|
|
192
|
+
const selected = cpSelectedItem(cp);
|
|
193
|
+
if (selected == null) {
|
|
194
|
+
return [{ ...model, commandPalette: undefined, commandPaletteEntries: undefined }, []];
|
|
195
|
+
}
|
|
196
|
+
const entry = model.commandPaletteEntries?.find((x) => x.id === selected.id);
|
|
197
|
+
if (entry?.frameAction != null) {
|
|
198
|
+
const closed = { ...model, commandPalette: undefined, commandPaletteEntries: undefined };
|
|
199
|
+
return applyFrameAction(entry.frameAction, closed, frameKeys, options, pagesById);
|
|
200
|
+
}
|
|
201
|
+
if (entry?.msgAction !== undefined) {
|
|
202
|
+
const cmd = entry.targetPageId != null
|
|
203
|
+
? emitMsgForPage(entry.targetPageId, entry.msgAction)
|
|
204
|
+
: emitMsg(entry.msgAction);
|
|
205
|
+
return [{
|
|
206
|
+
...model,
|
|
207
|
+
commandPalette: undefined,
|
|
208
|
+
commandPaletteEntries: undefined,
|
|
209
|
+
}, [cmd]];
|
|
210
|
+
}
|
|
211
|
+
return [{ ...model, commandPalette: undefined, commandPaletteEntries: undefined }, []];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (msg.key === 'backspace') {
|
|
216
|
+
const next = cpFilter(cp, cp.query.slice(0, -1));
|
|
217
|
+
return [{ ...model, commandPalette: next }, []];
|
|
218
|
+
}
|
|
219
|
+
if (msg.ctrl && msg.key === 'c') {
|
|
220
|
+
return [{ ...model, commandPalette: undefined, commandPaletteEntries: undefined }, []];
|
|
221
|
+
}
|
|
222
|
+
if (!msg.ctrl && !msg.alt && !msg.shift && msg.key === 'q' && cp.query.length === 0) {
|
|
223
|
+
return [{ ...model, commandPalette: undefined, commandPaletteEntries: undefined }, []];
|
|
224
|
+
}
|
|
225
|
+
if (!msg.ctrl && !msg.alt && msg.key.length === 1) {
|
|
226
|
+
const next = cpFilter(cp, cp.query + msg.key);
|
|
227
|
+
return [{ ...model, commandPalette: next }, []];
|
|
228
|
+
}
|
|
229
|
+
return [model, []];
|
|
230
|
+
}
|
|
231
|
+
function applyFrameAction(action, model, frameKeys, options, pagesById) {
|
|
232
|
+
switch (action.type) {
|
|
233
|
+
case 'toggle-help':
|
|
234
|
+
return [{ ...model, helpOpen: !model.helpOpen }, []];
|
|
235
|
+
case 'prev-tab':
|
|
236
|
+
return [switchTab(model, -1, pagesById), []];
|
|
237
|
+
case 'next-tab':
|
|
238
|
+
return [switchTab(model, 1, pagesById), []];
|
|
239
|
+
case 'next-pane':
|
|
240
|
+
return [cyclePane(model, 1, pagesById), []];
|
|
241
|
+
case 'prev-pane':
|
|
242
|
+
return [cyclePane(model, -1, pagesById), []];
|
|
243
|
+
case 'open-palette':
|
|
244
|
+
if (!options.enableCommandPalette)
|
|
245
|
+
return [model, []];
|
|
246
|
+
return [openCommandPalette(model, frameKeys, options, pagesById), []];
|
|
247
|
+
case 'scroll-up':
|
|
248
|
+
case 'scroll-down':
|
|
249
|
+
case 'page-up':
|
|
250
|
+
case 'page-down':
|
|
251
|
+
case 'top':
|
|
252
|
+
case 'bottom':
|
|
253
|
+
case 'scroll-left':
|
|
254
|
+
case 'scroll-right':
|
|
255
|
+
return [scrollFocusedPane(model, action, pagesById), []];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function switchTab(model, delta, pagesById) {
|
|
259
|
+
const idx = model.pageOrder.indexOf(model.activePageId);
|
|
260
|
+
if (idx < 0)
|
|
261
|
+
return model;
|
|
262
|
+
const nextIdx = (idx + delta + model.pageOrder.length) % model.pageOrder.length;
|
|
263
|
+
const nextId = model.pageOrder[nextIdx];
|
|
264
|
+
return syncPageFrameState({ ...model, activePageId: nextId }, nextId, pagesById);
|
|
265
|
+
}
|
|
266
|
+
function cyclePane(model, delta, pagesById) {
|
|
267
|
+
const page = pagesById.get(model.activePageId);
|
|
268
|
+
const paneIds = collectPaneIds(page.layout(model.pageModels[model.activePageId]));
|
|
269
|
+
if (paneIds.length === 0)
|
|
270
|
+
return model;
|
|
271
|
+
const curr = model.focusedPaneByPage[model.activePageId];
|
|
272
|
+
const idx = curr == null ? 0 : paneIds.indexOf(curr);
|
|
273
|
+
const nextIdx = idx < 0
|
|
274
|
+
? 0
|
|
275
|
+
: (idx + delta + paneIds.length) % paneIds.length;
|
|
276
|
+
const next = paneIds[nextIdx];
|
|
277
|
+
return {
|
|
278
|
+
...model,
|
|
279
|
+
focusedPaneByPage: {
|
|
280
|
+
...model.focusedPaneByPage,
|
|
281
|
+
[model.activePageId]: next,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function scrollFocusedPane(model, action, pagesById) {
|
|
286
|
+
const pageId = model.activePageId;
|
|
287
|
+
const focusedPaneId = model.focusedPaneByPage[pageId];
|
|
288
|
+
if (focusedPaneId == null)
|
|
289
|
+
return model;
|
|
290
|
+
const page = pagesById.get(pageId);
|
|
291
|
+
const layoutTree = page.layout(model.pageModels[pageId]);
|
|
292
|
+
const bodyRect = frameBodyRect(model.columns, model.rows);
|
|
293
|
+
const resolved = renderFrameNode(layoutTree, bodyRect, {
|
|
294
|
+
model,
|
|
295
|
+
pageId,
|
|
296
|
+
focusedPaneId,
|
|
297
|
+
scrollByPane: model.scrollByPage[pageId] ?? {},
|
|
298
|
+
});
|
|
299
|
+
const paneRect = resolved.paneRects.get(focusedPaneId);
|
|
300
|
+
if (paneRect == null || paneRect.width <= 0 || paneRect.height <= 0)
|
|
301
|
+
return model;
|
|
302
|
+
const paneNode = findPaneNode(layoutTree, focusedPaneId);
|
|
303
|
+
if (paneNode == null)
|
|
304
|
+
return model;
|
|
305
|
+
const content = paneNode.render(paneRect.width, paneRect.height);
|
|
306
|
+
let state = createFocusAreaState({
|
|
307
|
+
content,
|
|
308
|
+
width: paneRect.width,
|
|
309
|
+
height: paneRect.height,
|
|
310
|
+
overflowX: paneNode.overflowX ?? 'hidden',
|
|
311
|
+
});
|
|
312
|
+
const prior = model.scrollByPage[pageId]?.[focusedPaneId] ?? { x: 0, y: 0 };
|
|
313
|
+
state = focusAreaScrollTo(state, prior.y);
|
|
314
|
+
state = focusAreaScrollToX(state, prior.x);
|
|
315
|
+
switch (action.type) {
|
|
316
|
+
case 'scroll-up':
|
|
317
|
+
state = focusAreaScrollBy(state, -1);
|
|
318
|
+
break;
|
|
319
|
+
case 'scroll-down':
|
|
320
|
+
state = focusAreaScrollBy(state, 1);
|
|
321
|
+
break;
|
|
322
|
+
case 'page-up':
|
|
323
|
+
state = focusAreaPageUp(state);
|
|
324
|
+
break;
|
|
325
|
+
case 'page-down':
|
|
326
|
+
state = focusAreaPageDown(state);
|
|
327
|
+
break;
|
|
328
|
+
case 'top':
|
|
329
|
+
state = focusAreaScrollToTop(state);
|
|
330
|
+
break;
|
|
331
|
+
case 'bottom':
|
|
332
|
+
state = focusAreaScrollToBottom(state);
|
|
333
|
+
break;
|
|
334
|
+
case 'scroll-left':
|
|
335
|
+
state = focusAreaScrollByX(state, -1);
|
|
336
|
+
break;
|
|
337
|
+
case 'scroll-right':
|
|
338
|
+
state = focusAreaScrollByX(state, 1);
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
const pageScroll = model.scrollByPage[pageId] ?? {};
|
|
342
|
+
return {
|
|
343
|
+
...model,
|
|
344
|
+
scrollByPage: {
|
|
345
|
+
...model.scrollByPage,
|
|
346
|
+
[pageId]: {
|
|
347
|
+
...pageScroll,
|
|
348
|
+
[focusedPaneId]: { x: state.scroll.x, y: state.scroll.y },
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function openCommandPalette(model, frameKeys, options, pagesById) {
|
|
354
|
+
const entries = buildPaletteEntries(model, frameKeys, options, pagesById);
|
|
355
|
+
const items = entries.map((x) => x.item);
|
|
356
|
+
return {
|
|
357
|
+
...model,
|
|
358
|
+
commandPalette: createCommandPaletteState(items, Math.max(5, Math.min(10, model.rows - 8))),
|
|
359
|
+
commandPaletteEntries: entries,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function buildPaletteEntries(model, frameKeys, options, pagesById) {
|
|
363
|
+
const entries = [];
|
|
364
|
+
let seq = 0;
|
|
365
|
+
for (const b of frameKeys.bindings()) {
|
|
366
|
+
if (!b.enabled)
|
|
367
|
+
continue;
|
|
368
|
+
const action = frameKeys.handle(comboToMsg(b));
|
|
369
|
+
if (action === undefined)
|
|
370
|
+
continue;
|
|
371
|
+
const id = `frame:${seq++}`;
|
|
372
|
+
entries.push({
|
|
373
|
+
id,
|
|
374
|
+
item: {
|
|
375
|
+
id,
|
|
376
|
+
label: b.description,
|
|
377
|
+
category: 'Frame',
|
|
378
|
+
shortcut: formatKeyCombo(b.combo),
|
|
379
|
+
},
|
|
380
|
+
frameAction: action,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
const global = options.globalKeys;
|
|
384
|
+
if (global != null) {
|
|
385
|
+
for (const b of global.bindings()) {
|
|
386
|
+
if (!b.enabled)
|
|
387
|
+
continue;
|
|
388
|
+
const action = global.handle(comboToMsg(b));
|
|
389
|
+
if (action === undefined)
|
|
390
|
+
continue;
|
|
391
|
+
const id = `global:${seq++}`;
|
|
392
|
+
entries.push({
|
|
393
|
+
id,
|
|
394
|
+
item: {
|
|
395
|
+
id,
|
|
396
|
+
label: b.description,
|
|
397
|
+
category: 'Global',
|
|
398
|
+
shortcut: formatKeyCombo(b.combo),
|
|
399
|
+
},
|
|
400
|
+
msgAction: action,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const page = pagesById.get(model.activePageId);
|
|
405
|
+
if (page.keyMap != null) {
|
|
406
|
+
for (const b of page.keyMap.bindings()) {
|
|
407
|
+
if (!b.enabled)
|
|
408
|
+
continue;
|
|
409
|
+
const action = page.keyMap.handle(comboToMsg(b));
|
|
410
|
+
if (action === undefined)
|
|
411
|
+
continue;
|
|
412
|
+
const id = `page:${seq++}`;
|
|
413
|
+
entries.push({
|
|
414
|
+
id,
|
|
415
|
+
item: {
|
|
416
|
+
id,
|
|
417
|
+
label: b.description,
|
|
418
|
+
category: page.title,
|
|
419
|
+
shortcut: formatKeyCombo(b.combo),
|
|
420
|
+
},
|
|
421
|
+
msgAction: action,
|
|
422
|
+
targetPageId: model.activePageId,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (page.commandItems != null) {
|
|
427
|
+
for (const item of page.commandItems(model.pageModels[model.activePageId])) {
|
|
428
|
+
const id = `custom:${seq++}`;
|
|
429
|
+
entries.push({
|
|
430
|
+
id,
|
|
431
|
+
item: {
|
|
432
|
+
...item,
|
|
433
|
+
id,
|
|
434
|
+
category: item.category ?? page.title,
|
|
435
|
+
},
|
|
436
|
+
msgAction: item.action,
|
|
437
|
+
targetPageId: model.activePageId,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return entries;
|
|
442
|
+
}
|
|
443
|
+
function syncPageFrameState(model, pageId, pagesById) {
|
|
444
|
+
const page = pagesById.get(pageId);
|
|
445
|
+
const paneIds = collectPaneIds(page.layout(model.pageModels[pageId]));
|
|
446
|
+
assertUniquePaneIds(paneIds, `page "${pageId}" layout`);
|
|
447
|
+
const prevScroll = model.scrollByPage[pageId] ?? {};
|
|
448
|
+
const nextScroll = {};
|
|
449
|
+
for (const paneId of paneIds) {
|
|
450
|
+
nextScroll[paneId] = prevScroll[paneId] ?? { x: 0, y: 0 };
|
|
451
|
+
}
|
|
452
|
+
const prevFocused = model.focusedPaneByPage[pageId];
|
|
453
|
+
const focused = prevFocused != null && paneIds.includes(prevFocused)
|
|
454
|
+
? prevFocused
|
|
455
|
+
: paneIds[0];
|
|
456
|
+
return {
|
|
457
|
+
...model,
|
|
458
|
+
focusedPaneByPage: {
|
|
459
|
+
...model.focusedPaneByPage,
|
|
460
|
+
[pageId]: focused,
|
|
461
|
+
},
|
|
462
|
+
scrollByPage: {
|
|
463
|
+
...model.scrollByPage,
|
|
464
|
+
[pageId]: nextScroll,
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
function renderFrameNode(node, rect, ctx) {
|
|
469
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
470
|
+
return { output: '', paneRects: new Map(), paneOrder: [] };
|
|
471
|
+
}
|
|
472
|
+
if (node.kind === 'pane') {
|
|
473
|
+
const prior = ctx.scrollByPane[node.paneId] ?? { x: 0, y: 0 };
|
|
474
|
+
const content = node.render(rect.width, rect.height);
|
|
475
|
+
let state = createFocusAreaState({
|
|
476
|
+
content,
|
|
477
|
+
width: rect.width,
|
|
478
|
+
height: rect.height,
|
|
479
|
+
overflowX: node.overflowX ?? 'hidden',
|
|
480
|
+
});
|
|
481
|
+
state = focusAreaScrollTo(state, prior.y);
|
|
482
|
+
state = focusAreaScrollToX(state, prior.x);
|
|
483
|
+
const output = focusArea(state, { focused: node.paneId === ctx.focusedPaneId });
|
|
484
|
+
return {
|
|
485
|
+
output,
|
|
486
|
+
paneRects: new Map([[node.paneId, rect]]),
|
|
487
|
+
paneOrder: [node.paneId],
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
if (node.kind === 'split') {
|
|
491
|
+
const direction = node.direction ?? 'row';
|
|
492
|
+
const layout = splitPaneLayout(node.state, {
|
|
493
|
+
direction,
|
|
494
|
+
width: rect.width,
|
|
495
|
+
height: rect.height,
|
|
496
|
+
minA: node.minA,
|
|
497
|
+
minB: node.minB,
|
|
498
|
+
});
|
|
499
|
+
const aRect = offsetRect(layout.paneA, rect.row, rect.col);
|
|
500
|
+
const bRect = offsetRect(layout.paneB, rect.row, rect.col);
|
|
501
|
+
const a = renderFrameNode(node.paneA, aRect, ctx);
|
|
502
|
+
const b = renderFrameNode(node.paneB, bRect, ctx);
|
|
503
|
+
const output = splitPane(node.state, {
|
|
504
|
+
direction,
|
|
505
|
+
width: rect.width,
|
|
506
|
+
height: rect.height,
|
|
507
|
+
minA: node.minA,
|
|
508
|
+
minB: node.minB,
|
|
509
|
+
dividerChar: node.dividerChar,
|
|
510
|
+
paneA: () => a.output,
|
|
511
|
+
paneB: () => b.output,
|
|
512
|
+
});
|
|
513
|
+
return {
|
|
514
|
+
output,
|
|
515
|
+
paneRects: mergeMaps(a.paneRects, b.paneRects),
|
|
516
|
+
paneOrder: [...a.paneOrder, ...b.paneOrder],
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
const relRects = gridLayout({
|
|
520
|
+
width: rect.width,
|
|
521
|
+
height: rect.height,
|
|
522
|
+
columns: node.columns,
|
|
523
|
+
rows: node.rows,
|
|
524
|
+
areas: node.areas,
|
|
525
|
+
gap: node.gap,
|
|
526
|
+
});
|
|
527
|
+
const renderedByArea = new Map();
|
|
528
|
+
for (const [areaName, areaRect] of relRects) {
|
|
529
|
+
const absoluteAreaRect = offsetRect(areaRect, rect.row, rect.col);
|
|
530
|
+
const child = node.cells[areaName];
|
|
531
|
+
if (child == null) {
|
|
532
|
+
console.warn(`createFramedApp: grid cell "${areaName}" missing in page "${ctx.pageId}" — rendering placeholder`);
|
|
533
|
+
renderedByArea.set(areaName, renderMissingGridCell(areaName, absoluteAreaRect));
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
renderedByArea.set(areaName, renderFrameNode(child, absoluteAreaRect, ctx));
|
|
537
|
+
}
|
|
538
|
+
const output = grid({
|
|
539
|
+
width: rect.width,
|
|
540
|
+
height: rect.height,
|
|
541
|
+
columns: node.columns,
|
|
542
|
+
rows: node.rows,
|
|
543
|
+
areas: node.areas,
|
|
544
|
+
gap: node.gap,
|
|
545
|
+
cells: Object.fromEntries([...renderedByArea.entries()].map(([name, rendered]) => [name, () => rendered.output])),
|
|
546
|
+
});
|
|
547
|
+
let paneRects = new Map();
|
|
548
|
+
const seenPaneIds = new Set();
|
|
549
|
+
const paneOrder = [];
|
|
550
|
+
for (const rendered of renderedByArea.values()) {
|
|
551
|
+
for (const [paneId, paneRect] of rendered.paneRects.entries()) {
|
|
552
|
+
if (paneRects.has(paneId)) {
|
|
553
|
+
throw new Error(`createFramedApp: duplicate paneId "${paneId}" in rendered layout`);
|
|
554
|
+
}
|
|
555
|
+
paneRects.set(paneId, paneRect);
|
|
556
|
+
}
|
|
557
|
+
for (const paneId of rendered.paneOrder) {
|
|
558
|
+
if (seenPaneIds.has(paneId)) {
|
|
559
|
+
throw new Error(`createFramedApp: duplicate paneId "${paneId}" in rendered pane order`);
|
|
560
|
+
}
|
|
561
|
+
seenPaneIds.add(paneId);
|
|
562
|
+
paneOrder.push(paneId);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return { output, paneRects, paneOrder };
|
|
566
|
+
}
|
|
567
|
+
function renderMissingGridCell(areaName, rect) {
|
|
568
|
+
return {
|
|
569
|
+
output: fitBlock(`[missing grid cell: ${areaName}]`, rect.width, rect.height).join('\n'),
|
|
570
|
+
paneRects: new Map(),
|
|
571
|
+
paneOrder: [],
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
function collectPaneIds(node) {
|
|
575
|
+
if (node.kind === 'pane')
|
|
576
|
+
return [node.paneId];
|
|
577
|
+
if (node.kind === 'split')
|
|
578
|
+
return [...collectPaneIds(node.paneA), ...collectPaneIds(node.paneB)];
|
|
579
|
+
const ids = [];
|
|
580
|
+
for (const areaName of declaredAreaNames(node.areas)) {
|
|
581
|
+
const child = node.cells[areaName];
|
|
582
|
+
if (child == null)
|
|
583
|
+
continue;
|
|
584
|
+
ids.push(...collectPaneIds(child));
|
|
585
|
+
}
|
|
586
|
+
return ids;
|
|
587
|
+
}
|
|
588
|
+
function declaredAreaNames(areas) {
|
|
589
|
+
const names = new Set();
|
|
590
|
+
for (const row of areas) {
|
|
591
|
+
for (const token of row.trim().split(/\s+/)) {
|
|
592
|
+
if (token !== '' && token !== '.')
|
|
593
|
+
names.add(token);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return [...names];
|
|
597
|
+
}
|
|
598
|
+
function assertUniquePaneIds(paneIds, scope) {
|
|
599
|
+
const seen = new Set();
|
|
600
|
+
for (const paneId of paneIds) {
|
|
601
|
+
if (seen.has(paneId)) {
|
|
602
|
+
throw new Error(`createFramedApp: duplicate paneId "${paneId}" in ${scope}`);
|
|
603
|
+
}
|
|
604
|
+
seen.add(paneId);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function findPaneNode(node, paneId) {
|
|
608
|
+
if (node.kind === 'pane')
|
|
609
|
+
return node.paneId === paneId ? node : undefined;
|
|
610
|
+
if (node.kind === 'split')
|
|
611
|
+
return findPaneNode(node.paneA, paneId) ?? findPaneNode(node.paneB, paneId);
|
|
612
|
+
for (const key of Object.keys(node.cells)) {
|
|
613
|
+
const found = findPaneNode(node.cells[key], paneId);
|
|
614
|
+
if (found)
|
|
615
|
+
return found;
|
|
616
|
+
}
|
|
617
|
+
return undefined;
|
|
618
|
+
}
|
|
619
|
+
function createFrameKeyMap() {
|
|
620
|
+
return createKeyMap()
|
|
621
|
+
.group('Frame', (g) => g
|
|
622
|
+
.bind('?', 'Toggle help', { type: 'toggle-help' })
|
|
623
|
+
.bind('[', 'Previous tab', { type: 'prev-tab' })
|
|
624
|
+
.bind(']', 'Next tab', { type: 'next-tab' })
|
|
625
|
+
.bind('tab', 'Next pane', { type: 'next-pane' })
|
|
626
|
+
.bind('shift+tab', 'Previous pane', { type: 'prev-pane' })
|
|
627
|
+
.bind('ctrl+p', 'Open command palette', { type: 'open-palette' })
|
|
628
|
+
.bind(':', 'Open command palette', { type: 'open-palette' }))
|
|
629
|
+
.group('Scroll', (g) => g
|
|
630
|
+
.bind('j', 'Scroll down', { type: 'scroll-down' })
|
|
631
|
+
.bind('k', 'Scroll up', { type: 'scroll-up' })
|
|
632
|
+
.bind('d', 'Page down', { type: 'page-down' })
|
|
633
|
+
.bind('u', 'Page up', { type: 'page-up' })
|
|
634
|
+
.bind('g', 'Top', { type: 'top' })
|
|
635
|
+
.bind('shift+g', 'Bottom', { type: 'bottom' })
|
|
636
|
+
.bind('h', 'Scroll left', { type: 'scroll-left' })
|
|
637
|
+
.bind('l', 'Scroll right', { type: 'scroll-right' }));
|
|
638
|
+
}
|
|
639
|
+
function renderHeaderLine(model, options, pagesById) {
|
|
640
|
+
const title = options.title ?? 'App';
|
|
641
|
+
const tabs = model.pageOrder.map((id) => {
|
|
642
|
+
const page = pagesById.get(id);
|
|
643
|
+
return id === model.activePageId ? `[${page.title}]` : ` ${page.title} `;
|
|
644
|
+
}).join(' ');
|
|
645
|
+
return fitLine(`${title} ${tabs}`, model.columns);
|
|
646
|
+
}
|
|
647
|
+
function renderHelpLine(model, frameKeys, options, activePage) {
|
|
648
|
+
const mode = model.commandPalette != null
|
|
649
|
+
? 'PALETTE'
|
|
650
|
+
: model.helpOpen
|
|
651
|
+
? 'HELP'
|
|
652
|
+
: 'NORMAL';
|
|
653
|
+
const focusedPane = model.focusedPaneByPage[model.activePageId] ?? '-';
|
|
654
|
+
const status = `[${mode}] page:${model.activePageId} pane:${focusedPane}`;
|
|
655
|
+
const source = mergeBindingSources(frameKeys, options.globalKeys, activePage.helpSource ?? activePage.keyMap);
|
|
656
|
+
const hint = helpShort(source);
|
|
657
|
+
const line = hint.length > 0
|
|
658
|
+
? ` ${status} ${hint}`
|
|
659
|
+
: ` ${status}`;
|
|
660
|
+
return fitLine(line, model.columns);
|
|
661
|
+
}
|
|
662
|
+
function mergeBindingSources(...sources) {
|
|
663
|
+
return {
|
|
664
|
+
bindings() {
|
|
665
|
+
const merged = [];
|
|
666
|
+
for (const src of sources) {
|
|
667
|
+
if (src == null)
|
|
668
|
+
continue;
|
|
669
|
+
merged.push(...src.bindings());
|
|
670
|
+
}
|
|
671
|
+
return merged;
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
function frameBodyRect(columns, rows) {
|
|
676
|
+
return {
|
|
677
|
+
row: Math.min(2, Math.max(0, rows)),
|
|
678
|
+
col: 0,
|
|
679
|
+
width: Math.max(0, columns),
|
|
680
|
+
height: Math.max(0, rows - 2),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
function fitBlock(content, width, height) {
|
|
684
|
+
if (width <= 0 || height <= 0)
|
|
685
|
+
return Array.from({ length: Math.max(0, height) }, () => '');
|
|
686
|
+
const src = content.split('\n');
|
|
687
|
+
const out = [];
|
|
688
|
+
for (let i = 0; i < height; i++) {
|
|
689
|
+
const line = src[i] ?? '';
|
|
690
|
+
const clipped = clipToWidth(line, width);
|
|
691
|
+
out.push(clipped + ' '.repeat(Math.max(0, width - visibleLength(clipped))));
|
|
692
|
+
}
|
|
693
|
+
return out;
|
|
694
|
+
}
|
|
695
|
+
function fitLine(line, width) {
|
|
696
|
+
const clipped = clipToWidth(line, Math.max(0, width));
|
|
697
|
+
return clipped + ' '.repeat(Math.max(0, width - visibleLength(clipped)));
|
|
698
|
+
}
|
|
699
|
+
function mergeMaps(a, b) {
|
|
700
|
+
const out = new Map();
|
|
701
|
+
for (const [k, v] of a)
|
|
702
|
+
out.set(k, v);
|
|
703
|
+
for (const [k, v] of b)
|
|
704
|
+
out.set(k, v);
|
|
705
|
+
return out;
|
|
706
|
+
}
|
|
707
|
+
function offsetRect(rect, rowOffset, colOffset) {
|
|
708
|
+
return {
|
|
709
|
+
row: rowOffset + rect.row,
|
|
710
|
+
col: colOffset + rect.col,
|
|
711
|
+
width: rect.width,
|
|
712
|
+
height: rect.height,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
function comboToMsg(binding) {
|
|
716
|
+
return {
|
|
717
|
+
type: 'key',
|
|
718
|
+
key: binding.combo.key,
|
|
719
|
+
ctrl: binding.combo.ctrl,
|
|
720
|
+
alt: binding.combo.alt,
|
|
721
|
+
shift: binding.combo.shift,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
function emitMsg(msg) {
|
|
725
|
+
return () => Promise.resolve(msg);
|
|
726
|
+
}
|
|
727
|
+
function emitMsgForPage(pageId, msg) {
|
|
728
|
+
return async () => wrapPageMsg(pageId, msg);
|
|
729
|
+
}
|
|
730
|
+
function wrapCmdForPage(pageId, cmd) {
|
|
731
|
+
return async (emit) => {
|
|
732
|
+
const result = await cmd((msg) => emit(wrapPageMsg(pageId, msg)));
|
|
733
|
+
if (result === undefined || result === QUIT)
|
|
734
|
+
return result;
|
|
735
|
+
return wrapPageMsg(pageId, result);
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
function wrapPageMsg(pageId, msg) {
|
|
739
|
+
return {
|
|
740
|
+
[PAGE_MSG_TOKEN]: true,
|
|
741
|
+
pageId,
|
|
742
|
+
msg,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
function isPageScopedMsg(value) {
|
|
746
|
+
return typeof value === 'object'
|
|
747
|
+
&& value !== null
|
|
748
|
+
&& PAGE_MSG_TOKEN in value
|
|
749
|
+
&& value[PAGE_MSG_TOKEN] === true;
|
|
750
|
+
}
|
|
751
|
+
//# sourceMappingURL=app-frame.js.map
|