pi-extension-utils 0.4.1 → 0.5.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/src/pane/overlay.d.ts +27 -4
- package/dist/src/pane/overlay.js +176 -58
- package/docs/pane-overlay.md +29 -2
- package/examples/pane-overlay.ts +8 -4
- package/package.json +1 -1
|
@@ -10,13 +10,30 @@ export interface PaneOverlayContext<T = unknown, Row = unknown> {
|
|
|
10
10
|
mode: "cursor" | "scroll";
|
|
11
11
|
cursor: number;
|
|
12
12
|
scrollOffset: number;
|
|
13
|
+
width: number;
|
|
13
14
|
};
|
|
14
15
|
detail: {
|
|
15
16
|
scrollOffset: number;
|
|
17
|
+
width: number;
|
|
16
18
|
};
|
|
17
19
|
close(result?: T): void;
|
|
18
20
|
requestRender(): void;
|
|
19
21
|
}
|
|
22
|
+
export interface PaneOverlaySeparatorRow {
|
|
23
|
+
kind: "separator";
|
|
24
|
+
label?: string;
|
|
25
|
+
}
|
|
26
|
+
export type PaneOverlayPrimaryRow<Row = unknown> = Row | PaneOverlaySeparatorRow;
|
|
27
|
+
export interface PaneOverlayTitle {
|
|
28
|
+
label: string;
|
|
29
|
+
tail?: string;
|
|
30
|
+
tailRendered?: string;
|
|
31
|
+
tailPlain?: string;
|
|
32
|
+
labelColor?: string;
|
|
33
|
+
tailColor?: string;
|
|
34
|
+
labelBold?: boolean;
|
|
35
|
+
}
|
|
36
|
+
type PaneOverlayTitleValue<Row> = string | PaneOverlayTitle | ((ctx: PaneOverlayContext<unknown, Row>) => string | PaneOverlayTitle);
|
|
20
37
|
export interface PaneOverlayCustomAction<T = unknown, Row = unknown> {
|
|
21
38
|
keys: string | readonly string[];
|
|
22
39
|
label: string;
|
|
@@ -26,16 +43,20 @@ export interface PaneOverlayCustomAction<T = unknown, Row = unknown> {
|
|
|
26
43
|
}
|
|
27
44
|
export interface PrimaryPaneOptions<Row = unknown> {
|
|
28
45
|
mode?: "cursor" | "scroll";
|
|
29
|
-
rows: Row[] | ((ctx: PaneOverlayContext<unknown, Row>) => Row[]);
|
|
30
|
-
renderRow?(row: Row, ctx: PaneOverlayContext<unknown, Row
|
|
46
|
+
rows: PaneOverlayPrimaryRow<Row>[] | ((ctx: PaneOverlayContext<unknown, Row>) => PaneOverlayPrimaryRow<Row>[]);
|
|
47
|
+
renderRow?(row: Row, ctx: PaneOverlayContext<unknown, Row>, width: number): string;
|
|
31
48
|
selectionKey?(row: Row, index: number): string;
|
|
49
|
+
initialSelectionKey?: string;
|
|
50
|
+
initialIndex?: number;
|
|
32
51
|
onSelectionChange?(row: Row | undefined, index: number, key: string, ctx: PaneOverlayContext<unknown, Row>): void;
|
|
33
|
-
title?:
|
|
52
|
+
title?: PaneOverlayTitleValue<Row>;
|
|
53
|
+
info?: string[] | ((ctx: PaneOverlayContext<unknown, Row>) => string[]);
|
|
54
|
+
infoTitle?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
|
|
34
55
|
footer?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
|
|
35
56
|
}
|
|
36
57
|
export interface DetailPaneOptions<Row = unknown> {
|
|
37
58
|
rows(ctx: PaneOverlayContext<unknown, Row>): string[];
|
|
38
|
-
title?:
|
|
59
|
+
title?: PaneOverlayTitleValue<Row>;
|
|
39
60
|
footer?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
|
|
40
61
|
}
|
|
41
62
|
export interface PaneOverlaySplitOptions {
|
|
@@ -66,6 +87,7 @@ export interface PaneOverlayOptions<T = unknown, Row = unknown> {
|
|
|
66
87
|
collapse?: PaneCollapseOptions;
|
|
67
88
|
perSelectionScroll?: boolean;
|
|
68
89
|
stickyBottom?: boolean;
|
|
90
|
+
onRender?(ctx: PaneOverlayContext<T, Row>): void;
|
|
69
91
|
}
|
|
70
92
|
export interface PaneOverlayComponent {
|
|
71
93
|
render(width: number): string[];
|
|
@@ -73,3 +95,4 @@ export interface PaneOverlayComponent {
|
|
|
73
95
|
dispose(): void;
|
|
74
96
|
}
|
|
75
97
|
export declare function paneOverlay<T = undefined, Row = unknown>(options: PaneOverlayOptions<T, Row>): FullscreenComponentFactory<T>;
|
|
98
|
+
export {};
|
package/dist/src/pane/overlay.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { dispatchNavKeys } from "./key-dispatch.js";
|
|
2
|
-
import { computeFixedSidebarLayout, computeSplitPaneLayout,
|
|
2
|
+
import { computeFixedSidebarLayout, computeSplitPaneLayout, endScrollOffset, ensureCursorVisible, homeScrollOffset, moveScrollOffset, pageScrollOffset, resizeSplitPane, } from "./state.js";
|
|
3
3
|
import { boxRow, clipStyled, flatRule, formatScrollInfo, padRight, renderKeyRow, titledBottomSegment, titledTopSegment, } from "./chrome.js";
|
|
4
4
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
5
5
|
const DEFAULT_CLOSE_KEYS = ["escape", "ctrl+c", "q"];
|
|
@@ -28,6 +28,51 @@ function defaultRenderRow(row) {
|
|
|
28
28
|
function decorateRow(text, theme) {
|
|
29
29
|
return theme.bold ? theme.bold(theme.fg("accent", text)) : theme.fg("accent", text);
|
|
30
30
|
}
|
|
31
|
+
function isSeparatorRow(row) {
|
|
32
|
+
return typeof row === "object" && row !== null && row.kind === "separator";
|
|
33
|
+
}
|
|
34
|
+
function selectableIndexes(rows) {
|
|
35
|
+
const indexes = [];
|
|
36
|
+
for (let index = 0; index < rows.length; index++) {
|
|
37
|
+
if (!isSeparatorRow(rows[index]))
|
|
38
|
+
indexes.push(index);
|
|
39
|
+
}
|
|
40
|
+
return indexes;
|
|
41
|
+
}
|
|
42
|
+
function nearestSelectableIndex(rows, start) {
|
|
43
|
+
if (rows.length === 0)
|
|
44
|
+
return 0;
|
|
45
|
+
const clamped = Math.max(0, Math.min(start, rows.length - 1));
|
|
46
|
+
if (!isSeparatorRow(rows[clamped]))
|
|
47
|
+
return clamped;
|
|
48
|
+
for (let offset = 1; offset < rows.length; offset++) {
|
|
49
|
+
const after = clamped + offset;
|
|
50
|
+
if (after < rows.length && !isSeparatorRow(rows[after]))
|
|
51
|
+
return after;
|
|
52
|
+
const before = clamped - offset;
|
|
53
|
+
if (before >= 0 && !isSeparatorRow(rows[before]))
|
|
54
|
+
return before;
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
function titleOptions(title, focused) {
|
|
59
|
+
if (typeof title === "object" && title !== null) {
|
|
60
|
+
return {
|
|
61
|
+
label: title.label,
|
|
62
|
+
tail: title.tail,
|
|
63
|
+
tailRendered: title.tailRendered,
|
|
64
|
+
tailPlain: title.tailPlain,
|
|
65
|
+
labelColor: title.labelColor ?? (focused ? "accent" : "text"),
|
|
66
|
+
tailColor: title.tailColor,
|
|
67
|
+
labelBold: title.labelBold ?? focused,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
label: title ?? "",
|
|
72
|
+
labelColor: focused ? "accent" : "text",
|
|
73
|
+
labelBold: focused,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
31
76
|
export function paneOverlay(options) {
|
|
32
77
|
const split = options.split ?? {};
|
|
33
78
|
const closeKeys = options.closeKeys ?? DEFAULT_CLOSE_KEYS;
|
|
@@ -47,6 +92,7 @@ export function paneOverlay(options) {
|
|
|
47
92
|
let leftFraction = split.initialFraction ?? 0.5;
|
|
48
93
|
let lastRenderWidth = 80;
|
|
49
94
|
let lastSelectedKey;
|
|
95
|
+
let initialSelectionApplied = false;
|
|
50
96
|
const primaryState = { cursor: 0, scrollOffset: 0 };
|
|
51
97
|
const detailState = { scrollOffset: 0, sticky: stickyBottom };
|
|
52
98
|
const perSelectionOffsets = new Map();
|
|
@@ -59,7 +105,7 @@ export function paneOverlay(options) {
|
|
|
59
105
|
finished = true;
|
|
60
106
|
done(result);
|
|
61
107
|
};
|
|
62
|
-
const makeContext = (selectedIndex, selectedRow, selectedKey,
|
|
108
|
+
const makeContext = (selectedIndex, selectedRow, selectedKey, primaryWidth = 0, detailWidth = 0) => ({
|
|
63
109
|
tui,
|
|
64
110
|
primaryFocus: focus === "primary",
|
|
65
111
|
detailFocus: focus === "detail",
|
|
@@ -70,8 +116,9 @@ export function paneOverlay(options) {
|
|
|
70
116
|
mode: options.primary.mode ?? "scroll",
|
|
71
117
|
cursor: primaryState.cursor,
|
|
72
118
|
scrollOffset: primaryState.scrollOffset,
|
|
119
|
+
width: primaryWidth,
|
|
73
120
|
},
|
|
74
|
-
detail: { scrollOffset: detailState.scrollOffset },
|
|
121
|
+
detail: { scrollOffset: detailState.scrollOffset, width: detailWidth },
|
|
75
122
|
close: (result) => finish(result),
|
|
76
123
|
requestRender,
|
|
77
124
|
});
|
|
@@ -97,6 +144,60 @@ export function paneOverlay(options) {
|
|
|
97
144
|
fractionBasis: split.fractionBasis,
|
|
98
145
|
});
|
|
99
146
|
};
|
|
147
|
+
const currentWidths = () => {
|
|
148
|
+
const layout = getLayout(lastRenderWidth);
|
|
149
|
+
return { primaryWidth: layout.leftWidth, detailWidth: layout.rightWidth };
|
|
150
|
+
};
|
|
151
|
+
const getPrimaryRows = (ctx) => {
|
|
152
|
+
return resolveValue(options.primary.rows, ctx) ?? [];
|
|
153
|
+
};
|
|
154
|
+
const selectionKeyFor = (row, index) => {
|
|
155
|
+
return (options.primary.selectionKey ?? ((_row, rowIndex) => String(rowIndex)))(row, index);
|
|
156
|
+
};
|
|
157
|
+
const applyInitialSelection = (rows) => {
|
|
158
|
+
if (initialSelectionApplied || rows.length === 0)
|
|
159
|
+
return;
|
|
160
|
+
let targetIndex = -1;
|
|
161
|
+
if (options.primary.initialSelectionKey !== undefined) {
|
|
162
|
+
targetIndex = rows.findIndex((row, index) => !isSeparatorRow(row) && selectionKeyFor(row, index) === options.primary.initialSelectionKey);
|
|
163
|
+
}
|
|
164
|
+
if (targetIndex < 0 && options.primary.initialIndex !== undefined) {
|
|
165
|
+
targetIndex = options.primary.initialIndex;
|
|
166
|
+
}
|
|
167
|
+
if (targetIndex < 0)
|
|
168
|
+
return;
|
|
169
|
+
initialSelectionApplied = true;
|
|
170
|
+
const selectableIndex = nearestSelectableIndex(rows, targetIndex);
|
|
171
|
+
primaryState.cursor = selectableIndex;
|
|
172
|
+
primaryState.scrollOffset = selectableIndex;
|
|
173
|
+
};
|
|
174
|
+
const computeSelectionFromRows = (primaryRows, bodyHeight) => {
|
|
175
|
+
applyInitialSelection(primaryRows);
|
|
176
|
+
const primaryMode = options.primary.mode ?? "scroll";
|
|
177
|
+
let selectedIndex = 0;
|
|
178
|
+
if (primaryMode === "cursor") {
|
|
179
|
+
const viewport = ensureCursorVisible({
|
|
180
|
+
cursor: nearestSelectableIndex(primaryRows, primaryState.cursor),
|
|
181
|
+
scroll: primaryState.scrollOffset,
|
|
182
|
+
itemCount: primaryRows.length,
|
|
183
|
+
viewportHeight: Math.max(1, bodyHeight),
|
|
184
|
+
});
|
|
185
|
+
primaryState.cursor = nearestSelectableIndex(primaryRows, viewport.cursor);
|
|
186
|
+
primaryState.scrollOffset = viewport.scroll;
|
|
187
|
+
selectedIndex = primaryState.cursor;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
primaryState.scrollOffset = Math.min(primaryState.scrollOffset, Math.max(0, primaryRows.length - 1));
|
|
191
|
+
selectedIndex = nearestSelectableIndex(primaryRows, primaryState.scrollOffset);
|
|
192
|
+
primaryState.scrollOffset = selectedIndex;
|
|
193
|
+
}
|
|
194
|
+
if (primaryRows.length === 0 || selectableIndexes(primaryRows).length === 0)
|
|
195
|
+
selectedIndex = 0;
|
|
196
|
+
const selectedCandidate = primaryRows[selectedIndex];
|
|
197
|
+
const selectedRow = !isSeparatorRow(selectedCandidate) ? selectedCandidate : undefined;
|
|
198
|
+
const selectedKey = selectedRow !== undefined ? selectionKeyFor(selectedRow, selectedIndex) : String(selectedIndex);
|
|
199
|
+
return { primaryRows, selectedIndex, selectedRow, selectedKey };
|
|
200
|
+
};
|
|
100
201
|
const resize = (direction) => {
|
|
101
202
|
if (collapsed)
|
|
102
203
|
return;
|
|
@@ -129,16 +230,27 @@ export function paneOverlay(options) {
|
|
|
129
230
|
}
|
|
130
231
|
return state;
|
|
131
232
|
};
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
233
|
+
const primarySections = (bodyHeight, legendLineCount, infoLineCount) => {
|
|
234
|
+
const legendBlockHeight = legendPlacement === "primary" && legendLineCount > 0 ? legendLineCount + 1 : 0;
|
|
235
|
+
const availableBeforeLegend = Math.max(0, bodyHeight - legendBlockHeight);
|
|
236
|
+
const infoVisibleCount = infoLineCount > 0
|
|
237
|
+
? Math.min(infoLineCount, Math.max(0, availableBeforeLegend - 2))
|
|
238
|
+
: 0;
|
|
239
|
+
const infoBlockHeight = infoVisibleCount > 0 ? infoVisibleCount + 1 : 0;
|
|
240
|
+
return {
|
|
241
|
+
primaryHeight: Math.max(0, availableBeforeLegend - infoBlockHeight),
|
|
242
|
+
detailHeight: bodyHeight,
|
|
243
|
+
infoVisibleCount,
|
|
244
|
+
};
|
|
137
245
|
};
|
|
138
246
|
const pageSizeHalf = (height) => Math.max(1, Math.floor(height / 2));
|
|
139
247
|
const withPrimaryViewport = (bodyHeight, legendLineCount) => {
|
|
140
|
-
const
|
|
141
|
-
const
|
|
248
|
+
const { primaryWidth, detailWidth } = currentWidths();
|
|
249
|
+
const primaryRows = getPrimaryRows(makeContext(0, undefined, "", primaryWidth, detailWidth));
|
|
250
|
+
const selection = computeSelectionFromRows(primaryRows, bodyHeight);
|
|
251
|
+
const ctx = makeContext(selection.selectedIndex, selection.selectedRow, selection.selectedKey, primaryWidth, detailWidth);
|
|
252
|
+
const infoLines = resolveValue(options.primary.info, ctx) ?? [];
|
|
253
|
+
const viewportHeight = primarySections(bodyHeight, legendLineCount, infoLines.length).primaryHeight;
|
|
142
254
|
return { primaryRows, viewportHeight: Math.max(1, viewportHeight) };
|
|
143
255
|
};
|
|
144
256
|
const applyPrimaryNav = (bodyHeight, legendLineCount, mutator) => {
|
|
@@ -148,8 +260,13 @@ export function paneOverlay(options) {
|
|
|
148
260
|
const movePrimary = (delta, bodyHeight, legendLineCount) => {
|
|
149
261
|
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
150
262
|
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
151
|
-
const
|
|
152
|
-
|
|
263
|
+
const indexes = selectableIndexes(rows);
|
|
264
|
+
if (indexes.length === 0)
|
|
265
|
+
return;
|
|
266
|
+
const currentIndex = nearestSelectableIndex(rows, state.cursor);
|
|
267
|
+
const currentOrdinal = Math.max(0, indexes.indexOf(currentIndex));
|
|
268
|
+
state.cursor = indexes[Math.max(0, Math.min(indexes.length - 1, currentOrdinal + delta))] ?? currentIndex;
|
|
269
|
+
const next = ensureCursorVisible({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
|
|
153
270
|
state.scrollOffset = next.scroll;
|
|
154
271
|
}
|
|
155
272
|
else {
|
|
@@ -161,8 +278,13 @@ export function paneOverlay(options) {
|
|
|
161
278
|
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
162
279
|
const pageSize = pageSizeHalf(viewportHeight);
|
|
163
280
|
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
164
|
-
const
|
|
165
|
-
|
|
281
|
+
const indexes = selectableIndexes(rows);
|
|
282
|
+
if (indexes.length === 0)
|
|
283
|
+
return;
|
|
284
|
+
const currentIndex = nearestSelectableIndex(rows, state.cursor);
|
|
285
|
+
const currentOrdinal = Math.max(0, indexes.indexOf(currentIndex));
|
|
286
|
+
state.cursor = indexes[Math.max(0, Math.min(indexes.length - 1, currentOrdinal + delta * pageSize))] ?? currentIndex;
|
|
287
|
+
const next = ensureCursorVisible({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
|
|
166
288
|
state.scrollOffset = next.scroll;
|
|
167
289
|
}
|
|
168
290
|
else {
|
|
@@ -173,7 +295,10 @@ export function paneOverlay(options) {
|
|
|
173
295
|
const homePrimary = (bodyHeight, legendLineCount) => {
|
|
174
296
|
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
175
297
|
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
176
|
-
const
|
|
298
|
+
const indexes = selectableIndexes(rows);
|
|
299
|
+
if (indexes.length === 0)
|
|
300
|
+
return;
|
|
301
|
+
const next = ensureCursorVisible({ cursor: indexes[0] ?? 0, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
|
|
177
302
|
state.cursor = next.cursor;
|
|
178
303
|
state.scrollOffset = next.scroll;
|
|
179
304
|
}
|
|
@@ -185,7 +310,10 @@ export function paneOverlay(options) {
|
|
|
185
310
|
const endPrimary = (bodyHeight, legendLineCount) => {
|
|
186
311
|
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
187
312
|
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
188
|
-
const
|
|
313
|
+
const indexes = selectableIndexes(rows);
|
|
314
|
+
if (indexes.length === 0)
|
|
315
|
+
return;
|
|
316
|
+
const next = ensureCursorVisible({ cursor: indexes[indexes.length - 1] ?? 0, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
|
|
189
317
|
state.cursor = next.cursor;
|
|
190
318
|
state.scrollOffset = next.scroll;
|
|
191
319
|
}
|
|
@@ -229,29 +357,6 @@ export function paneOverlay(options) {
|
|
|
229
357
|
const keyWidth = Math.min(11, Math.max(3, Math.floor(totalWidth / 4)));
|
|
230
358
|
return entries.map((entry) => chromeTheme.fg("dim", renderKeyRow(entry.key, entry.label, totalWidth, keyWidth)));
|
|
231
359
|
};
|
|
232
|
-
const computeSelection = (bodyHeight) => {
|
|
233
|
-
const provisionalCtx = makeContext(0, undefined, "", [], []);
|
|
234
|
-
const primaryRows = resolveValue(options.primary.rows, provisionalCtx);
|
|
235
|
-
const primaryMode = options.primary.mode ?? "scroll";
|
|
236
|
-
let selectedIndex = 0;
|
|
237
|
-
if (primaryMode === "cursor") {
|
|
238
|
-
selectedIndex = ensureCursorVisible({
|
|
239
|
-
cursor: primaryState.cursor,
|
|
240
|
-
scroll: primaryState.scrollOffset,
|
|
241
|
-
itemCount: primaryRows.length,
|
|
242
|
-
viewportHeight: Math.max(1, bodyHeight),
|
|
243
|
-
}).cursor;
|
|
244
|
-
}
|
|
245
|
-
else {
|
|
246
|
-
selectedIndex = Math.min(primaryState.scrollOffset, Math.max(0, primaryRows.length - 1));
|
|
247
|
-
}
|
|
248
|
-
if (primaryRows.length === 0)
|
|
249
|
-
selectedIndex = 0;
|
|
250
|
-
const selectedRow = primaryRows[selectedIndex];
|
|
251
|
-
const selectionKeyFn = options.primary.selectionKey ?? ((_row, index) => String(index));
|
|
252
|
-
const selectedKey = selectedRow !== undefined ? selectionKeyFn(selectedRow, selectedIndex) : String(selectedIndex);
|
|
253
|
-
return { primaryRows, selectedIndex, selectedRow, selectedKey };
|
|
254
|
-
};
|
|
255
360
|
const component = {
|
|
256
361
|
dispose() {
|
|
257
362
|
// no-op is acceptable
|
|
@@ -263,9 +368,11 @@ export function paneOverlay(options) {
|
|
|
263
368
|
const layout = getLayout(totalWidth);
|
|
264
369
|
const primaryWidth = layout.leftWidth;
|
|
265
370
|
const detailWidth = layout.rightWidth;
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
const ctx = makeContext(selectedIndex, selectedRow, selectedKey,
|
|
371
|
+
const primaryRows = getPrimaryRows(makeContext(0, undefined, "", primaryWidth, detailWidth));
|
|
372
|
+
const { selectedIndex, selectedRow, selectedKey } = computeSelectionFromRows(primaryRows, bodyHeight);
|
|
373
|
+
const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryWidth, detailWidth);
|
|
374
|
+
options.onRender?.(ctx);
|
|
375
|
+
const detailRows = options.detail.rows(ctx) ?? [];
|
|
269
376
|
if (selectedKey !== lastSelectedKey) {
|
|
270
377
|
lastSelectedKey = selectedKey;
|
|
271
378
|
if (options.primary.onSelectionChange) {
|
|
@@ -283,7 +390,9 @@ export function paneOverlay(options) {
|
|
|
283
390
|
ensureFocusDetailWhenCollapsed();
|
|
284
391
|
const entries = legendEntries(ctx);
|
|
285
392
|
const legendLines = renderLegendLines(entries, totalWidth);
|
|
286
|
-
const
|
|
393
|
+
const infoLines = resolveValue(options.primary.info, ctx) ?? [];
|
|
394
|
+
const infoTitle = resolveValue(options.primary.infoTitle, ctx) ?? "info";
|
|
395
|
+
const { primaryHeight, detailHeight, infoVisibleCount } = primarySections(bodyHeight, legendLines.length, infoLines.length);
|
|
287
396
|
// Align primary viewport.
|
|
288
397
|
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
289
398
|
const viewport = ensureCursorVisible({
|
|
@@ -315,15 +424,11 @@ export function paneOverlay(options) {
|
|
|
315
424
|
const detailTitle = resolveValue(options.detail.title, ctx);
|
|
316
425
|
const topPrimary = titledTopSegment(chromeTheme, {
|
|
317
426
|
width: primaryWidth,
|
|
318
|
-
|
|
319
|
-
labelColor: focus === "primary" ? "accent" : "text",
|
|
320
|
-
labelBold: focus === "primary",
|
|
427
|
+
...titleOptions(primaryTitle, focus === "primary"),
|
|
321
428
|
});
|
|
322
429
|
const topDetail = titledTopSegment(chromeTheme, {
|
|
323
430
|
width: detailWidth,
|
|
324
|
-
|
|
325
|
-
labelColor: focus === "detail" ? "accent" : "text",
|
|
326
|
-
labelBold: focus === "detail",
|
|
431
|
+
...titleOptions(detailTitle, focus === "detail"),
|
|
327
432
|
});
|
|
328
433
|
const corner = (glyph) => chromeTheme.fg("dim", glyph);
|
|
329
434
|
const topBorder = primaryWidth === 0
|
|
@@ -334,14 +439,17 @@ export function paneOverlay(options) {
|
|
|
334
439
|
.slice(primaryState.scrollOffset, primaryState.scrollOffset + primaryHeight)
|
|
335
440
|
.map((row, index) => {
|
|
336
441
|
const absoluteIndex = primaryState.scrollOffset + index;
|
|
337
|
-
|
|
442
|
+
if (isSeparatorRow(row))
|
|
443
|
+
return flatRule(chromeTheme, row.label ?? "", primaryWidth);
|
|
444
|
+
const text = renderFn(row, ctx, primaryWidth);
|
|
338
445
|
const selected = (options.primary.mode ?? "scroll") === "cursor" && absoluteIndex === primaryState.cursor && focus === "primary";
|
|
339
446
|
return selected ? decorateRow(text, chromeTheme) : text;
|
|
340
447
|
});
|
|
341
448
|
const detailVisible = detailRows.slice(detailState.scrollOffset, detailState.scrollOffset + detailHeight);
|
|
449
|
+
const selectable = selectableIndexes(primaryRows);
|
|
342
450
|
const primaryFooterText = resolveValue(options.primary.footer, ctx)
|
|
343
|
-
?? ((options.primary.mode ?? "scroll") === "cursor" &&
|
|
344
|
-
? `${primaryState.cursor + 1}/${
|
|
451
|
+
?? ((options.primary.mode ?? "scroll") === "cursor" && selectable.length > 0
|
|
452
|
+
? `${Math.max(0, selectable.indexOf(primaryState.cursor)) + 1}/${selectable.length}`
|
|
345
453
|
: primaryRows.length > primaryHeight
|
|
346
454
|
? formatScrollInfo(primaryState.scrollOffset, Math.max(0, primaryRows.length - primaryHeight), { style: "position" })
|
|
347
455
|
: "");
|
|
@@ -355,13 +463,21 @@ export function paneOverlay(options) {
|
|
|
355
463
|
? corner("╰") + bottomDetail + corner("╯")
|
|
356
464
|
: corner("╰") + bottomPrimary + corner("┴") + bottomDetail + corner("╯");
|
|
357
465
|
const bodyLines = [];
|
|
466
|
+
const infoStart = primaryHeight;
|
|
467
|
+
const legendStart = primaryHeight + (infoVisibleCount > 0 ? infoVisibleCount + 1 : 0);
|
|
358
468
|
for (let row = 0; row < bodyHeight; row++) {
|
|
359
469
|
let primaryCell = primaryVisible[row] ?? "";
|
|
360
|
-
if (
|
|
470
|
+
if (infoVisibleCount > 0 && row === infoStart) {
|
|
471
|
+
primaryCell = flatRule(chromeTheme, infoTitle, primaryWidth);
|
|
472
|
+
}
|
|
473
|
+
else if (infoVisibleCount > 0 && row > infoStart && row < legendStart) {
|
|
474
|
+
primaryCell = infoLines[row - infoStart - 1] ?? "";
|
|
475
|
+
}
|
|
476
|
+
else if (legendPlacement === "primary" && row === legendStart && legendLines.length > 0) {
|
|
361
477
|
primaryCell = flatRule(chromeTheme, collapse?.label ?? "actions", primaryWidth);
|
|
362
478
|
}
|
|
363
|
-
if (legendPlacement === "primary" && row >
|
|
364
|
-
const legendIndex = row -
|
|
479
|
+
if (legendPlacement === "primary" && row > legendStart) {
|
|
480
|
+
const legendIndex = row - legendStart - 1;
|
|
365
481
|
primaryCell = legendIndex < legendLines.length ? padRight(legendLines[legendIndex], primaryWidth) : "";
|
|
366
482
|
}
|
|
367
483
|
const detailCell = detailVisible[row] ?? "";
|
|
@@ -387,9 +503,11 @@ export function paneOverlay(options) {
|
|
|
387
503
|
finish(options.closeResult);
|
|
388
504
|
return;
|
|
389
505
|
}
|
|
390
|
-
const {
|
|
391
|
-
const
|
|
392
|
-
const
|
|
506
|
+
const { primaryWidth, detailWidth } = currentWidths();
|
|
507
|
+
const primaryRows = getPrimaryRows(makeContext(0, undefined, "", primaryWidth, detailWidth));
|
|
508
|
+
const { selectedIndex, selectedRow, selectedKey } = computeSelectionFromRows(primaryRows, bodyHeight);
|
|
509
|
+
const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryWidth, detailWidth);
|
|
510
|
+
const detailRows = options.detail.rows(ctx) ?? [];
|
|
393
511
|
for (const action of customActions) {
|
|
394
512
|
if (action.when && !action.when(ctx))
|
|
395
513
|
continue;
|
package/docs/pane-overlay.md
CHANGED
|
@@ -19,12 +19,16 @@ await client.ui.fullscreen(
|
|
|
19
19
|
mode: "cursor",
|
|
20
20
|
rows: () => runs,
|
|
21
21
|
selectionKey: (run) => run.id,
|
|
22
|
-
|
|
22
|
+
initialSelectionKey: currentRunId,
|
|
23
|
+
renderRow: (run, ctx, width) => width > 40 ? `${run.label} ${run.status}` : run.label,
|
|
24
|
+
infoTitle: "selected",
|
|
25
|
+
info: (ctx) => ctx.selectedRow ? [`status: ${ctx.selectedRow.status}`] : [],
|
|
23
26
|
},
|
|
24
27
|
detail: {
|
|
25
|
-
title: (ctx) => ctx.selectedRow?.label ?? "Details",
|
|
28
|
+
title: (ctx) => ({ label: ctx.selectedRow?.label ?? "Details", tail: "VAL" }),
|
|
26
29
|
rows: (ctx) => renderDetails(ctx.selectedRow),
|
|
27
30
|
},
|
|
31
|
+
onRender: (ctx) => tickFlashMessage(),
|
|
28
32
|
customActions: [
|
|
29
33
|
{
|
|
30
34
|
keys: ["enter", "o"],
|
|
@@ -62,6 +66,29 @@ await client.ui.fullscreen(
|
|
|
62
66
|
| `collapse` | Optional primary/sidebar collapse key |
|
|
63
67
|
| `perSelectionScroll` | Keep separate detail scroll per selected key |
|
|
64
68
|
| `stickyBottom` | Detail starts/follows at bottom until user scrolls |
|
|
69
|
+
| `onRender` | Per-frame hook for external transient UI state |
|
|
70
|
+
|
|
71
|
+
## Primary pane extras
|
|
72
|
+
|
|
73
|
+
The primary pane can render more than a flat selectable list:
|
|
74
|
+
|
|
75
|
+
- `renderRow(row, ctx, width)` receives the current primary pane width. The same value is also exposed as `ctx.primary.width`; `ctx.detail.width` exposes the detail pane width.
|
|
76
|
+
- `primary.info` renders a selected-row-derived block below the list and above a primary-placed legend. Use `primary.infoTitle` to label its divider.
|
|
77
|
+
- `rows` may include `{ kind: "separator", label: "done" }`. Separator rows render as inline rules and are skipped by cursor movement and selection keys.
|
|
78
|
+
- `initialSelectionKey` or `initialIndex` controls the first selected row when the overlay opens. `initialSelectionKey` applies on the first frame where that key is present, including async-arriving rows.
|
|
79
|
+
|
|
80
|
+
Pane titles may be strings or structured objects forwarded to `titledTopSegment`:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
detail: {
|
|
84
|
+
title: (ctx) => ({
|
|
85
|
+
label: ctx.selectedRow?.name ?? "Details",
|
|
86
|
+
tailRendered: renderStatusTail(ctx.selectedRow),
|
|
87
|
+
tailPlain: plainStatusTail(ctx.selectedRow),
|
|
88
|
+
}),
|
|
89
|
+
rows: renderDetailRows,
|
|
90
|
+
}
|
|
91
|
+
```
|
|
65
92
|
|
|
66
93
|
## Custom actions
|
|
67
94
|
|
package/examples/pane-overlay.ts
CHANGED
|
@@ -4,12 +4,13 @@ import { connect, paneOverlay } from "pi-extension-utils";
|
|
|
4
4
|
interface RunRow {
|
|
5
5
|
id: string;
|
|
6
6
|
label: string;
|
|
7
|
+
status: string;
|
|
7
8
|
lines: string[];
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
const runs: RunRow[] = [
|
|
11
|
-
{ id: "a", label: "explorer", lines: ["status: running", "task: inspect files"] },
|
|
12
|
-
{ id: "b", label: "qa", lines: ["status: done", "tests: passed"] },
|
|
12
|
+
{ id: "a", label: "explorer", status: "running", lines: ["status: running", "task: inspect files"] },
|
|
13
|
+
{ id: "b", label: "qa", status: "done", lines: ["status: done", "tests: passed"] },
|
|
13
14
|
];
|
|
14
15
|
|
|
15
16
|
export default function (pi: ExtensionAPI) {
|
|
@@ -25,10 +26,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
25
26
|
mode: "cursor",
|
|
26
27
|
rows: runs,
|
|
27
28
|
selectionKey: (run) => run.id,
|
|
28
|
-
|
|
29
|
+
initialSelectionKey: "b",
|
|
30
|
+
renderRow: (run, _overlay, width) => width > 28 ? `${run.label} ${run.status}` : run.label,
|
|
31
|
+
infoTitle: "selected",
|
|
32
|
+
info: (overlay) => overlay.selectedRow ? [`status: ${overlay.selectedRow.status}`] : [],
|
|
29
33
|
},
|
|
30
34
|
detail: {
|
|
31
|
-
title: (overlay) => overlay.selectedRow?.label ?? "Details",
|
|
35
|
+
title: (overlay) => ({ label: overlay.selectedRow?.label ?? "Details", tail: overlay.selectedRow?.status }),
|
|
32
36
|
rows: (overlay) => overlay.selectedRow?.lines ?? ["No selection"],
|
|
33
37
|
},
|
|
34
38
|
split: { initialFraction: 0.35, minPrimaryWidth: 20, minDetailWidth: 30 },
|