pi-extension-utils 0.3.0 → 0.3.1
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 +18 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/pane-overlay.d.ts +75 -0
- package/dist/src/pane-overlay.js +492 -0
- package/dist/src/tui-chrome.js +22 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ The host owns one real widget per placement and composes registered sub-widgets
|
|
|
16
16
|
## API sketch
|
|
17
17
|
|
|
18
18
|
```ts
|
|
19
|
-
import { connect, createLogger } from "pi-extension-utils";
|
|
19
|
+
import { connect, createLogger, paneOverlay } from "pi-extension-utils";
|
|
20
20
|
|
|
21
21
|
const client = connect(pi, { ctx, clientId: "my-extension" });
|
|
22
22
|
client.widgets.set("belowEditor", "status", (tui, theme) => component, { order: 10 });
|
|
@@ -28,6 +28,23 @@ const result = await client.ui.fullscreen<string | null>(
|
|
|
28
28
|
(tui, theme, keybindings, done) => new MyOverlayComponent(tui, theme, done),
|
|
29
29
|
);
|
|
30
30
|
|
|
31
|
+
// Opinionated master/detail pane overlay helper with built-in key handling.
|
|
32
|
+
const result = await client.ui.fullscreen(
|
|
33
|
+
paneOverlay<string, string>({
|
|
34
|
+
primary: {
|
|
35
|
+
mode: "cursor",
|
|
36
|
+
rows: ["one", "two", "three"],
|
|
37
|
+
renderRow: (row) => row,
|
|
38
|
+
onSelectionChange: (row) => console.log("selected", row),
|
|
39
|
+
},
|
|
40
|
+
detail: {
|
|
41
|
+
title: "Details",
|
|
42
|
+
rows: (ctx) => [`Selected: ${ctx.selectedRow}`],
|
|
43
|
+
},
|
|
44
|
+
legendPlacement: "footer",
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
|
|
31
48
|
// Lower-level lease, for manual control over the blank/restore window.
|
|
32
49
|
const lease = client.fullscreen.acquire();
|
|
33
50
|
lease.release();
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { FullscreenComponentFactory } from "./client.ts";
|
|
2
|
+
export interface PaneOverlayContext<T = unknown, Row = unknown> {
|
|
3
|
+
tui: unknown;
|
|
4
|
+
primaryFocus: boolean;
|
|
5
|
+
detailFocus: boolean;
|
|
6
|
+
selectedRow: Row | undefined;
|
|
7
|
+
selectedIndex: number;
|
|
8
|
+
selectedKey: string;
|
|
9
|
+
primary: {
|
|
10
|
+
mode: "cursor" | "scroll";
|
|
11
|
+
cursor: number;
|
|
12
|
+
scrollOffset: number;
|
|
13
|
+
};
|
|
14
|
+
detail: {
|
|
15
|
+
scrollOffset: number;
|
|
16
|
+
};
|
|
17
|
+
close(result?: T): void;
|
|
18
|
+
requestRender(): void;
|
|
19
|
+
}
|
|
20
|
+
export interface PaneOverlayCustomAction<T = unknown, Row = unknown> {
|
|
21
|
+
keys: string | readonly string[];
|
|
22
|
+
label: string;
|
|
23
|
+
run(ctx: PaneOverlayContext<T, Row>): void;
|
|
24
|
+
when?(ctx: PaneOverlayContext<T, Row>): boolean;
|
|
25
|
+
showInLegend?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface PrimaryPaneOptions<Row = unknown> {
|
|
28
|
+
mode?: "cursor" | "scroll";
|
|
29
|
+
rows: Row[] | ((ctx: PaneOverlayContext<unknown, Row>) => Row[]);
|
|
30
|
+
renderRow?(row: Row, ctx: PaneOverlayContext<unknown, Row>): string;
|
|
31
|
+
selectionKey?(row: Row, index: number): string;
|
|
32
|
+
onSelectionChange?(row: Row | undefined, index: number, key: string, ctx: PaneOverlayContext<unknown, Row>): void;
|
|
33
|
+
title?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
|
|
34
|
+
footer?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
|
|
35
|
+
}
|
|
36
|
+
export interface DetailPaneOptions<Row = unknown> {
|
|
37
|
+
rows(ctx: PaneOverlayContext<unknown, Row>): string[];
|
|
38
|
+
title?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
|
|
39
|
+
footer?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
|
|
40
|
+
}
|
|
41
|
+
export interface PaneOverlaySplitOptions {
|
|
42
|
+
initialFraction?: number;
|
|
43
|
+
minPrimaryWidth?: number;
|
|
44
|
+
minDetailWidth?: number;
|
|
45
|
+
maxPrimaryWidth?: number;
|
|
46
|
+
stepCols?: number;
|
|
47
|
+
minFraction?: number;
|
|
48
|
+
maxFraction?: number;
|
|
49
|
+
fractionBasis?: "total" | "interior";
|
|
50
|
+
}
|
|
51
|
+
export interface PaneCollapseOptions {
|
|
52
|
+
key?: string;
|
|
53
|
+
collapsedWidth?: number;
|
|
54
|
+
label?: string;
|
|
55
|
+
}
|
|
56
|
+
export interface PaneOverlayOptions<T = unknown, Row = unknown> {
|
|
57
|
+
primary: PrimaryPaneOptions<Row>;
|
|
58
|
+
detail: DetailPaneOptions<Row>;
|
|
59
|
+
height?: number | ((tui: unknown) => number);
|
|
60
|
+
closeKeys?: readonly string[];
|
|
61
|
+
closeResult?: T;
|
|
62
|
+
customActions?: readonly PaneOverlayCustomAction<T, Row>[];
|
|
63
|
+
bannedKeys?: readonly string[];
|
|
64
|
+
split?: PaneOverlaySplitOptions;
|
|
65
|
+
legendPlacement?: "footer" | "primary";
|
|
66
|
+
collapse?: PaneCollapseOptions;
|
|
67
|
+
perSelectionScroll?: boolean;
|
|
68
|
+
stickyBottom?: boolean;
|
|
69
|
+
}
|
|
70
|
+
export interface PaneOverlayComponent {
|
|
71
|
+
render(width: number): string[];
|
|
72
|
+
handleInput(data: string): void;
|
|
73
|
+
dispose(): void;
|
|
74
|
+
}
|
|
75
|
+
export declare function paneOverlay<T = undefined, Row = unknown>(options: PaneOverlayOptions<T, Row>): FullscreenComponentFactory<T>;
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { dispatchNavKeys } from "./key-dispatch.js";
|
|
2
|
+
import { computeFixedSidebarLayout, computeSplitPaneLayout, endCursor, endScrollOffset, ensureCursorVisible, homeCursor, homeScrollOffset, moveCursor, moveScrollOffset, pageCursor, pageScrollOffset, resizeSplitPane, } from "./pane-state.js";
|
|
3
|
+
import { boxRow, clipStyled, flatRule, formatScrollInfo, padRight, renderKeyRow, titledBottomSegment, titledTopSegment, } from "./tui-chrome.js";
|
|
4
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
5
|
+
const DEFAULT_CLOSE_KEYS = ["escape", "ctrl+c", "q"];
|
|
6
|
+
function resolveValue(value, ctx) {
|
|
7
|
+
return typeof value === "function" ? value(ctx) : value;
|
|
8
|
+
}
|
|
9
|
+
function computeBodyHeight(tui, height) {
|
|
10
|
+
if (typeof height === "number")
|
|
11
|
+
return Math.max(1, height);
|
|
12
|
+
if (typeof height === "function")
|
|
13
|
+
return Math.max(1, height(tui));
|
|
14
|
+
const rows = tui?.terminal?.rows;
|
|
15
|
+
if (typeof rows === "number")
|
|
16
|
+
return Math.max(8, rows - 2);
|
|
17
|
+
return 16;
|
|
18
|
+
}
|
|
19
|
+
function matchesAny(data, binding) {
|
|
20
|
+
if (!binding)
|
|
21
|
+
return false;
|
|
22
|
+
const keys = typeof binding === "string" ? [binding] : binding;
|
|
23
|
+
return keys.some((key) => data === key || matchesKey(data, key));
|
|
24
|
+
}
|
|
25
|
+
function defaultRenderRow(row) {
|
|
26
|
+
return typeof row === "string" ? row : String(row);
|
|
27
|
+
}
|
|
28
|
+
function decorateRow(text, theme) {
|
|
29
|
+
return theme.bold ? theme.bold(theme.fg("accent", text)) : theme.fg("accent", text);
|
|
30
|
+
}
|
|
31
|
+
export function paneOverlay(options) {
|
|
32
|
+
const split = options.split ?? {};
|
|
33
|
+
const closeKeys = options.closeKeys ?? DEFAULT_CLOSE_KEYS;
|
|
34
|
+
const customActions = options.customActions ?? [];
|
|
35
|
+
const bannedKeys = options.bannedKeys ?? [];
|
|
36
|
+
const legendPlacement = options.legendPlacement ?? "footer";
|
|
37
|
+
const perSelectionScroll = options.perSelectionScroll ?? false;
|
|
38
|
+
const stickyBottom = options.stickyBottom ?? false;
|
|
39
|
+
const collapse = options.collapse;
|
|
40
|
+
const collapseKey = collapse?.key ?? "c";
|
|
41
|
+
const collapsedWidth = collapse?.collapsedWidth ?? 1;
|
|
42
|
+
return (tui, theme, _keybindings, done) => {
|
|
43
|
+
const chromeTheme = theme;
|
|
44
|
+
let finished = false;
|
|
45
|
+
let focus = "primary";
|
|
46
|
+
let collapsed = false;
|
|
47
|
+
let leftFraction = split.initialFraction ?? 0.5;
|
|
48
|
+
let lastRenderWidth = 80;
|
|
49
|
+
let lastSelectedKey;
|
|
50
|
+
const primaryState = { cursor: 0, scrollOffset: 0 };
|
|
51
|
+
const detailState = { scrollOffset: 0, sticky: stickyBottom };
|
|
52
|
+
const perSelectionOffsets = new Map();
|
|
53
|
+
const requestRender = () => {
|
|
54
|
+
tui?.requestRender?.();
|
|
55
|
+
};
|
|
56
|
+
const finish = (result) => {
|
|
57
|
+
if (finished)
|
|
58
|
+
return;
|
|
59
|
+
finished = true;
|
|
60
|
+
done(result);
|
|
61
|
+
};
|
|
62
|
+
const makeContext = (selectedIndex, selectedRow, selectedKey, primaryRows, detailRows) => ({
|
|
63
|
+
tui,
|
|
64
|
+
primaryFocus: focus === "primary",
|
|
65
|
+
detailFocus: focus === "detail",
|
|
66
|
+
selectedRow,
|
|
67
|
+
selectedIndex,
|
|
68
|
+
selectedKey,
|
|
69
|
+
primary: {
|
|
70
|
+
mode: options.primary.mode ?? "scroll",
|
|
71
|
+
cursor: primaryState.cursor,
|
|
72
|
+
scrollOffset: primaryState.scrollOffset,
|
|
73
|
+
},
|
|
74
|
+
detail: { scrollOffset: detailState.scrollOffset },
|
|
75
|
+
close: (result) => finish(result),
|
|
76
|
+
requestRender,
|
|
77
|
+
});
|
|
78
|
+
const getLayout = (totalWidth) => {
|
|
79
|
+
if (collapsed) {
|
|
80
|
+
return computeFixedSidebarLayout({
|
|
81
|
+
totalWidth,
|
|
82
|
+
collapsed: true,
|
|
83
|
+
leftWidth: collapsedWidth,
|
|
84
|
+
collapsedLeftWidth: collapsedWidth,
|
|
85
|
+
minLeftWidth: split.minPrimaryWidth ?? 0,
|
|
86
|
+
minRightWidth: split.minDetailWidth ?? 8,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return computeSplitPaneLayout({
|
|
90
|
+
totalWidth,
|
|
91
|
+
leftFraction,
|
|
92
|
+
minLeftWidth: split.minPrimaryWidth ?? 8,
|
|
93
|
+
minRightWidth: split.minDetailWidth ?? 8,
|
|
94
|
+
leftMaxWidth: split.maxPrimaryWidth,
|
|
95
|
+
minFraction: split.minFraction,
|
|
96
|
+
maxFraction: split.maxFraction,
|
|
97
|
+
fractionBasis: split.fractionBasis,
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
const resize = (direction) => {
|
|
101
|
+
if (collapsed)
|
|
102
|
+
return;
|
|
103
|
+
const next = resizeSplitPane({
|
|
104
|
+
totalWidth: lastRenderWidth,
|
|
105
|
+
leftFraction,
|
|
106
|
+
direction,
|
|
107
|
+
stepCols: split.stepCols ?? 4,
|
|
108
|
+
minLeftWidth: split.minPrimaryWidth ?? 8,
|
|
109
|
+
minRightWidth: split.minDetailWidth ?? 8,
|
|
110
|
+
leftMaxWidth: split.maxPrimaryWidth,
|
|
111
|
+
minFraction: split.minFraction,
|
|
112
|
+
maxFraction: split.maxFraction,
|
|
113
|
+
fractionBasis: split.fractionBasis,
|
|
114
|
+
});
|
|
115
|
+
leftFraction = next.leftFraction;
|
|
116
|
+
};
|
|
117
|
+
const toggleFocus = () => {
|
|
118
|
+
focus = focus === "primary" ? "detail" : "primary";
|
|
119
|
+
};
|
|
120
|
+
const ensureFocusDetailWhenCollapsed = () => {
|
|
121
|
+
if (collapsed)
|
|
122
|
+
focus = "detail";
|
|
123
|
+
};
|
|
124
|
+
const getDetailStateForKey = (key) => {
|
|
125
|
+
let state = perSelectionOffsets.get(key);
|
|
126
|
+
if (!state) {
|
|
127
|
+
state = { offset: 0, sticky: stickyBottom };
|
|
128
|
+
perSelectionOffsets.set(key, state);
|
|
129
|
+
}
|
|
130
|
+
return state;
|
|
131
|
+
};
|
|
132
|
+
const listViewport = (bodyHeight, legendLineCount) => {
|
|
133
|
+
const primaryHeight = legendPlacement === "primary"
|
|
134
|
+
? Math.max(0, bodyHeight - legendLineCount - 1)
|
|
135
|
+
: bodyHeight;
|
|
136
|
+
return { primaryHeight, detailHeight: bodyHeight };
|
|
137
|
+
};
|
|
138
|
+
const pageSizeHalf = (height) => Math.max(1, Math.floor(height / 2));
|
|
139
|
+
const withPrimaryViewport = (bodyHeight, legendLineCount) => {
|
|
140
|
+
const primaryRows = resolveValue(options.primary.rows, makeContext(0, undefined, "", [], []));
|
|
141
|
+
const viewportHeight = listViewport(bodyHeight, legendLineCount).primaryHeight;
|
|
142
|
+
return { primaryRows, viewportHeight: Math.max(1, viewportHeight) };
|
|
143
|
+
};
|
|
144
|
+
const applyPrimaryNav = (bodyHeight, legendLineCount, mutator) => {
|
|
145
|
+
const { primaryRows, viewportHeight } = withPrimaryViewport(bodyHeight, legendLineCount);
|
|
146
|
+
mutator(primaryState, primaryRows, viewportHeight);
|
|
147
|
+
};
|
|
148
|
+
const movePrimary = (delta, bodyHeight, legendLineCount) => {
|
|
149
|
+
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
150
|
+
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
151
|
+
const next = moveCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight }, delta);
|
|
152
|
+
state.cursor = next.cursor;
|
|
153
|
+
state.scrollOffset = next.scroll;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
state.scrollOffset = moveScrollOffset({ offset: state.scrollOffset, contentLength: rows.length, viewportHeight }, delta);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
const halfPagePrimary = (delta, bodyHeight, legendLineCount) => {
|
|
161
|
+
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
162
|
+
const pageSize = pageSizeHalf(viewportHeight);
|
|
163
|
+
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
164
|
+
const next = pageCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight }, delta, pageSize);
|
|
165
|
+
state.cursor = next.cursor;
|
|
166
|
+
state.scrollOffset = next.scroll;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
state.scrollOffset = pageScrollOffset({ offset: state.scrollOffset, contentLength: rows.length, viewportHeight }, delta, pageSize);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
const homePrimary = (bodyHeight, legendLineCount) => {
|
|
174
|
+
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
175
|
+
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
176
|
+
const next = homeCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
|
|
177
|
+
state.cursor = next.cursor;
|
|
178
|
+
state.scrollOffset = next.scroll;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
state.scrollOffset = homeScrollOffset();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
const endPrimary = (bodyHeight, legendLineCount) => {
|
|
186
|
+
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
187
|
+
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
188
|
+
const next = endCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
|
|
189
|
+
state.cursor = next.cursor;
|
|
190
|
+
state.scrollOffset = next.scroll;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
state.scrollOffset = endScrollOffset(rows.length, viewportHeight);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
const markDetailManual = () => {
|
|
198
|
+
detailState.sticky = false;
|
|
199
|
+
if (perSelectionScroll && lastSelectedKey !== undefined) {
|
|
200
|
+
getDetailStateForKey(lastSelectedKey).sticky = false;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const legendEntries = (ctx) => {
|
|
204
|
+
const entries = [];
|
|
205
|
+
entries.push({ key: "tab/←/→", label: "focus" });
|
|
206
|
+
const primaryMode = options.primary.mode ?? "scroll";
|
|
207
|
+
const moveLabel = ctx.detailFocus || primaryMode === "scroll" ? "scroll" : "select";
|
|
208
|
+
entries.push({ key: "j/k", label: moveLabel });
|
|
209
|
+
entries.push({ key: "u/d", label: "half-page" });
|
|
210
|
+
entries.push({ key: "g/G", label: "top/bottom" });
|
|
211
|
+
if (!collapsed)
|
|
212
|
+
entries.push({ key: "[/]", label: "resize" });
|
|
213
|
+
if (collapse)
|
|
214
|
+
entries.push({ key: collapseKey, label: collapse.label ?? "collapse" });
|
|
215
|
+
for (const action of customActions) {
|
|
216
|
+
if (action.showInLegend === false)
|
|
217
|
+
continue;
|
|
218
|
+
if (action.when && !action.when(ctx))
|
|
219
|
+
continue;
|
|
220
|
+
entries.push({ key: typeof action.keys === "string" ? action.keys : action.keys.join("/"), label: action.label });
|
|
221
|
+
}
|
|
222
|
+
const qCloses = closeKeys.includes("q");
|
|
223
|
+
entries.push({ key: qCloses ? "q/esc" : "esc", label: "close" });
|
|
224
|
+
return entries;
|
|
225
|
+
};
|
|
226
|
+
const renderLegendLines = (entries, totalWidth) => {
|
|
227
|
+
if (entries.length === 0 || totalWidth <= 0)
|
|
228
|
+
return [];
|
|
229
|
+
const keyWidth = Math.min(11, Math.max(3, Math.floor(totalWidth / 4)));
|
|
230
|
+
return entries.map((entry) => chromeTheme.fg("dim", renderKeyRow(entry.key, entry.label, totalWidth, keyWidth)));
|
|
231
|
+
};
|
|
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
|
+
const component = {
|
|
256
|
+
dispose() {
|
|
257
|
+
// no-op is acceptable
|
|
258
|
+
},
|
|
259
|
+
render(width) {
|
|
260
|
+
const totalWidth = Math.max(10, Math.floor(width));
|
|
261
|
+
lastRenderWidth = totalWidth;
|
|
262
|
+
const bodyHeight = computeBodyHeight(tui, options.height);
|
|
263
|
+
const layout = getLayout(totalWidth);
|
|
264
|
+
const primaryWidth = layout.leftWidth;
|
|
265
|
+
const detailWidth = layout.rightWidth;
|
|
266
|
+
const { primaryRows, selectedIndex, selectedRow, selectedKey } = computeSelection(bodyHeight);
|
|
267
|
+
const detailRows = resolveValue(options.detail.rows, makeContext(selectedIndex, selectedRow, selectedKey, primaryRows, []));
|
|
268
|
+
const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryRows, detailRows);
|
|
269
|
+
if (selectedKey !== lastSelectedKey) {
|
|
270
|
+
lastSelectedKey = selectedKey;
|
|
271
|
+
if (options.primary.onSelectionChange) {
|
|
272
|
+
options.primary.onSelectionChange(selectedRow, selectedIndex, selectedKey, ctx);
|
|
273
|
+
}
|
|
274
|
+
if (perSelectionScroll) {
|
|
275
|
+
const state = getDetailStateForKey(selectedKey);
|
|
276
|
+
detailState.scrollOffset = state.offset;
|
|
277
|
+
detailState.sticky = state.sticky;
|
|
278
|
+
}
|
|
279
|
+
else if (stickyBottom) {
|
|
280
|
+
detailState.sticky = true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
ensureFocusDetailWhenCollapsed();
|
|
284
|
+
const entries = legendEntries(ctx);
|
|
285
|
+
const legendLines = renderLegendLines(entries, totalWidth);
|
|
286
|
+
const { primaryHeight, detailHeight } = listViewport(bodyHeight, legendLines.length);
|
|
287
|
+
// Align primary viewport.
|
|
288
|
+
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
289
|
+
const viewport = ensureCursorVisible({
|
|
290
|
+
cursor: primaryState.cursor,
|
|
291
|
+
scroll: primaryState.scrollOffset,
|
|
292
|
+
itemCount: primaryRows.length,
|
|
293
|
+
viewportHeight: Math.max(1, primaryHeight),
|
|
294
|
+
});
|
|
295
|
+
primaryState.cursor = viewport.cursor;
|
|
296
|
+
primaryState.scrollOffset = viewport.scroll;
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
primaryState.scrollOffset = Math.max(0, Math.min(primaryState.scrollOffset, Math.max(0, primaryRows.length - primaryHeight)));
|
|
300
|
+
}
|
|
301
|
+
// Align detail viewport.
|
|
302
|
+
const detailMaxOffset = Math.max(0, detailRows.length - detailHeight);
|
|
303
|
+
if (stickyBottom && detailState.sticky) {
|
|
304
|
+
detailState.scrollOffset = detailMaxOffset;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
detailState.scrollOffset = Math.max(0, Math.min(detailState.scrollOffset, detailMaxOffset));
|
|
308
|
+
}
|
|
309
|
+
if (perSelectionScroll) {
|
|
310
|
+
const state = getDetailStateForKey(selectedKey);
|
|
311
|
+
state.offset = detailState.scrollOffset;
|
|
312
|
+
state.sticky = detailState.sticky;
|
|
313
|
+
}
|
|
314
|
+
const primaryTitle = resolveValue(options.primary.title, ctx);
|
|
315
|
+
const detailTitle = resolveValue(options.detail.title, ctx);
|
|
316
|
+
const topPrimary = titledTopSegment(chromeTheme, {
|
|
317
|
+
width: primaryWidth,
|
|
318
|
+
label: primaryTitle ?? "",
|
|
319
|
+
labelColor: focus === "primary" ? "accent" : "text",
|
|
320
|
+
labelBold: focus === "primary",
|
|
321
|
+
});
|
|
322
|
+
const topDetail = titledTopSegment(chromeTheme, {
|
|
323
|
+
width: detailWidth,
|
|
324
|
+
label: detailTitle ?? "",
|
|
325
|
+
labelColor: focus === "detail" ? "accent" : "text",
|
|
326
|
+
labelBold: focus === "detail",
|
|
327
|
+
});
|
|
328
|
+
const corner = (glyph) => chromeTheme.fg("dim", glyph);
|
|
329
|
+
const topBorder = primaryWidth === 0
|
|
330
|
+
? corner("╭") + topDetail + corner("╮")
|
|
331
|
+
: corner("╭") + topPrimary + corner("┬") + topDetail + corner("╮");
|
|
332
|
+
const renderFn = options.primary.renderRow ?? defaultRenderRow;
|
|
333
|
+
const primaryVisible = primaryRows
|
|
334
|
+
.slice(primaryState.scrollOffset, primaryState.scrollOffset + primaryHeight)
|
|
335
|
+
.map((row, index) => {
|
|
336
|
+
const absoluteIndex = primaryState.scrollOffset + index;
|
|
337
|
+
const text = renderFn(row, ctx);
|
|
338
|
+
const selected = (options.primary.mode ?? "scroll") === "cursor" && absoluteIndex === primaryState.cursor && focus === "primary";
|
|
339
|
+
return selected ? decorateRow(text, chromeTheme) : text;
|
|
340
|
+
});
|
|
341
|
+
const detailVisible = detailRows.slice(detailState.scrollOffset, detailState.scrollOffset + detailHeight);
|
|
342
|
+
const primaryFooterText = resolveValue(options.primary.footer, ctx)
|
|
343
|
+
?? ((options.primary.mode ?? "scroll") === "cursor" && primaryRows.length > 0
|
|
344
|
+
? `${primaryState.cursor + 1}/${primaryRows.length}`
|
|
345
|
+
: primaryRows.length > primaryHeight
|
|
346
|
+
? formatScrollInfo(primaryState.scrollOffset, Math.max(0, primaryRows.length - primaryHeight), { style: "position" })
|
|
347
|
+
: "");
|
|
348
|
+
const detailFooterText = resolveValue(options.detail.footer, ctx)
|
|
349
|
+
?? (detailRows.length > detailHeight
|
|
350
|
+
? formatScrollInfo(detailState.scrollOffset, Math.max(0, detailRows.length - detailHeight), { style: "position" })
|
|
351
|
+
: "");
|
|
352
|
+
const bottomPrimary = titledBottomSegment(chromeTheme, primaryWidth, primaryFooterText, focus === "primary");
|
|
353
|
+
const bottomDetail = titledBottomSegment(chromeTheme, detailWidth, detailFooterText, focus === "detail");
|
|
354
|
+
const bottomBorder = primaryWidth === 0
|
|
355
|
+
? corner("╰") + bottomDetail + corner("╯")
|
|
356
|
+
: corner("╰") + bottomPrimary + corner("┴") + bottomDetail + corner("╯");
|
|
357
|
+
const bodyLines = [];
|
|
358
|
+
for (let row = 0; row < bodyHeight; row++) {
|
|
359
|
+
let primaryCell = primaryVisible[row] ?? "";
|
|
360
|
+
if (legendPlacement === "primary" && row === primaryHeight && legendLines.length > 0) {
|
|
361
|
+
primaryCell = flatRule(chromeTheme, collapse?.label ?? "actions", primaryWidth);
|
|
362
|
+
}
|
|
363
|
+
if (legendPlacement === "primary" && row > primaryHeight) {
|
|
364
|
+
const legendIndex = row - primaryHeight - 1;
|
|
365
|
+
primaryCell = legendIndex < legendLines.length ? padRight(legendLines[legendIndex], primaryWidth) : "";
|
|
366
|
+
}
|
|
367
|
+
const detailCell = detailVisible[row] ?? "";
|
|
368
|
+
if (primaryWidth === 0) {
|
|
369
|
+
const border = chromeTheme.fg("dim", "│");
|
|
370
|
+
bodyLines.push(border + padRight(clipStyled(detailCell, detailWidth), detailWidth) + border);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
bodyLines.push(boxRow(chromeTheme, primaryCell, detailCell, primaryWidth, detailWidth));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const lines = [topBorder, ...bodyLines, bottomBorder];
|
|
377
|
+
if (legendPlacement === "footer") {
|
|
378
|
+
lines.push(...legendLines);
|
|
379
|
+
}
|
|
380
|
+
return lines.map((line) => padRight(line, totalWidth));
|
|
381
|
+
},
|
|
382
|
+
handleInput(data) {
|
|
383
|
+
if (finished)
|
|
384
|
+
return;
|
|
385
|
+
const bodyHeight = computeBodyHeight(tui, options.height);
|
|
386
|
+
if (matchesAny(data, closeKeys)) {
|
|
387
|
+
finish(options.closeResult);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const { primaryRows, selectedIndex, selectedRow, selectedKey } = computeSelection(bodyHeight);
|
|
391
|
+
const detailRows = resolveValue(options.detail.rows, makeContext(selectedIndex, selectedRow, selectedKey, primaryRows, []));
|
|
392
|
+
const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryRows, detailRows);
|
|
393
|
+
for (const action of customActions) {
|
|
394
|
+
if (action.when && !action.when(ctx))
|
|
395
|
+
continue;
|
|
396
|
+
if (matchesAny(data, action.keys)) {
|
|
397
|
+
action.run(ctx);
|
|
398
|
+
requestRender();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (matchesAny(data, bannedKeys)) {
|
|
403
|
+
requestRender();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (matchesAny(data, ["left", "right", "tab"])) {
|
|
407
|
+
toggleFocus();
|
|
408
|
+
ensureFocusDetailWhenCollapsed();
|
|
409
|
+
requestRender();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const legendLineCount = legendPlacement === "primary" ? renderLegendLines(legendEntries(ctx), lastRenderWidth).length : 0;
|
|
413
|
+
if (matchesAny(data, ["pageUp", "pageDown"]))
|
|
414
|
+
return;
|
|
415
|
+
const commonConsumed = dispatchNavKeys(data, {
|
|
416
|
+
close: () => undefined,
|
|
417
|
+
closeKeys: [],
|
|
418
|
+
move: (delta) => {
|
|
419
|
+
if (focus === "primary") {
|
|
420
|
+
movePrimary(delta, bodyHeight, legendLineCount);
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
markDetailManual();
|
|
424
|
+
detailState.scrollOffset = moveScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, delta);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
home: () => {
|
|
428
|
+
if (focus === "primary") {
|
|
429
|
+
homePrimary(bodyHeight, legendLineCount);
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
markDetailManual();
|
|
433
|
+
detailState.scrollOffset = homeScrollOffset();
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
end: () => {
|
|
437
|
+
if (focus === "primary") {
|
|
438
|
+
endPrimary(bodyHeight, legendLineCount);
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
markDetailManual();
|
|
442
|
+
detailState.scrollOffset = endScrollOffset(detailRows.length, Math.max(1, bodyHeight));
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
if (commonConsumed) {
|
|
447
|
+
requestRender();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (matchesAny(data, ["u", "U"])) {
|
|
451
|
+
if (focus === "primary") {
|
|
452
|
+
halfPagePrimary(-1, bodyHeight, legendLineCount);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
markDetailManual();
|
|
456
|
+
detailState.scrollOffset = pageScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, -1, pageSizeHalf(bodyHeight));
|
|
457
|
+
}
|
|
458
|
+
requestRender();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (matchesAny(data, ["d", "D"])) {
|
|
462
|
+
if (focus === "primary") {
|
|
463
|
+
halfPagePrimary(1, bodyHeight, legendLineCount);
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
markDetailManual();
|
|
467
|
+
detailState.scrollOffset = pageScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, 1, pageSizeHalf(bodyHeight));
|
|
468
|
+
}
|
|
469
|
+
requestRender();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (collapse && matchesAny(data, [collapseKey])) {
|
|
473
|
+
collapsed = !collapsed;
|
|
474
|
+
ensureFocusDetailWhenCollapsed();
|
|
475
|
+
requestRender();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (!collapsed && matchesAny(data, ["["])) {
|
|
479
|
+
resize(-1);
|
|
480
|
+
requestRender();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (!collapsed && matchesAny(data, ["]"])) {
|
|
484
|
+
resize(1);
|
|
485
|
+
requestRender();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
return component;
|
|
491
|
+
};
|
|
492
|
+
}
|
package/dist/src/tui-chrome.js
CHANGED
|
@@ -113,28 +113,37 @@ export function titledTopSegment(theme, opts) {
|
|
|
113
113
|
const fillDashes = Math.max(1, width - labelLen - 3);
|
|
114
114
|
return clipStyled(`${dash(1)} ${labelStyled} ${dash(fillDashes)}`, width);
|
|
115
115
|
}
|
|
116
|
+
const width = Math.max(0, opts.width);
|
|
117
|
+
if (width <= 0)
|
|
118
|
+
return "";
|
|
116
119
|
const labelColor = opts.labelColor ?? "text";
|
|
117
120
|
const tailColor = opts.tailColor ?? "dim";
|
|
118
|
-
|
|
119
|
-
const
|
|
121
|
+
const tailPlain = opts.tailPlain ?? opts.tail ?? "";
|
|
122
|
+
const rawTailRendered = opts.tailRendered ?? (opts.tail !== undefined ? theme.fg(tailColor, opts.tail) : "");
|
|
123
|
+
const tailLen = visibleWidth(tailPlain);
|
|
124
|
+
// With a tail, the minimum chrome is:
|
|
125
|
+
// `─ ` + label + ` ` + `─` + ` ` + tail + ` ` + `─` = label + tail + 7.
|
|
126
|
+
// The previous math reserved only 6 columns, so long label+tail combinations
|
|
127
|
+
// could render one column too wide and crash Pi's TUI at terminal width.
|
|
128
|
+
const tailFits = tailLen > 0 && width >= tailLen + 7;
|
|
129
|
+
const labelBudget = tailFits ? Math.max(0, width - tailLen - 7) : Math.max(0, width - 3);
|
|
120
130
|
// Defensive: callers occasionally pass undefined labels for transient/legacy
|
|
121
131
|
// runs that lack agent+mode+label; treat as empty rather than crashing pi.
|
|
122
|
-
const
|
|
132
|
+
const rawLabel = opts.label ?? "";
|
|
133
|
+
const labelText = truncateToWidth(rawLabel, labelBudget, "").replace(/\u001b\[[0-9;]*m/g, "");
|
|
123
134
|
const labelStyled = opts.labelBold && theme.bold
|
|
124
135
|
? theme.bold(theme.fg(labelColor, labelText))
|
|
125
136
|
: theme.fg(labelColor, labelText);
|
|
126
|
-
const tailPlain = opts.tailPlain ?? opts.tail ?? "";
|
|
127
|
-
const tailRendered = opts.tailRendered ?? (opts.tail !== undefined ? theme.fg(tailColor, opts.tail) : "");
|
|
128
137
|
const labelLen = visibleWidth(labelText);
|
|
129
|
-
|
|
130
|
-
// Layout
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const fillDashes = Math.max(1,
|
|
134
|
-
return `${dash(1)} ${labelStyled} ${dash(fillDashes)} ${tailRendered} ${dash(1)}
|
|
138
|
+
// Layout with tail: `─ <label> ──…── <tail> ─`.
|
|
139
|
+
// Layout without tail: `─ <label> ──…────`.
|
|
140
|
+
if (tailFits) {
|
|
141
|
+
const tailRendered = visibleWidth(rawTailRendered) > tailLen ? clipStyled(rawTailRendered, tailLen) : rawTailRendered;
|
|
142
|
+
const fillDashes = Math.max(1, width - (labelLen + tailLen + 6));
|
|
143
|
+
return clipStyled(`${dash(1)} ${labelStyled} ${dash(fillDashes)} ${tailRendered} ${dash(1)}`, width);
|
|
135
144
|
}
|
|
136
|
-
const fillDashes = Math.max(
|
|
137
|
-
return `${dash(1)} ${labelStyled} ${dash(fillDashes)}
|
|
145
|
+
const fillDashes = Math.max(0, width - (labelLen + 3));
|
|
146
|
+
return clipStyled(`${dash(1)} ${labelStyled} ${dash(fillDashes)}`, width);
|
|
138
147
|
}
|
|
139
148
|
/**
|
|
140
149
|
* Build one half of a bottom border with an embedded hint string.
|