@sigrun/tui 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/CHANGELOG.md +10 -1
- package/dist/actions.d.ts +119 -0
- package/dist/actions.js +12 -0
- package/dist/api.d.ts +209 -0
- package/dist/api.js +282 -0
- package/dist/blocks/braille.d.ts +41 -0
- package/dist/blocks/braille.js +131 -0
- package/dist/blocks/chart.d.ts +20 -0
- package/dist/blocks/chart.js +397 -0
- package/dist/blocks/chat-transport.d.ts +44 -0
- package/dist/blocks/chat-transport.js +116 -0
- package/dist/blocks/collection/calendar.d.ts +28 -0
- package/dist/blocks/collection/calendar.js +232 -0
- package/dist/blocks/collection/card.d.ts +36 -0
- package/dist/blocks/collection/card.js +128 -0
- package/dist/blocks/collection/collection.d.ts +9 -0
- package/dist/blocks/collection/collection.js +26 -0
- package/dist/blocks/collection/grid.d.ts +18 -0
- package/dist/blocks/collection/grid.js +72 -0
- package/dist/blocks/collection/lanes.d.ts +32 -0
- package/dist/blocks/collection/lanes.js +320 -0
- package/dist/blocks/collection/list.d.ts +30 -0
- package/dist/blocks/collection/list.js +273 -0
- package/dist/blocks/collection/move.d.ts +67 -0
- package/dist/blocks/collection/move.js +96 -0
- package/dist/blocks/collection/shared.d.ts +87 -0
- package/dist/blocks/collection/shared.js +177 -0
- package/dist/blocks/collection/table.d.ts +26 -0
- package/dist/blocks/collection/table.js +221 -0
- package/dist/blocks/content.d.ts +48 -0
- package/dist/blocks/content.js +253 -0
- package/dist/blocks/context.d.ts +117 -0
- package/dist/blocks/context.js +65 -0
- package/dist/blocks/diff.d.ts +21 -0
- package/dist/blocks/diff.js +25 -0
- package/dist/blocks/ext-transport.d.ts +70 -0
- package/dist/blocks/ext-transport.js +165 -0
- package/dist/blocks/form/choice.d.ts +54 -0
- package/dist/blocks/form/choice.js +175 -0
- package/dist/blocks/form/fields.d.ts +56 -0
- package/dist/blocks/form/fields.js +136 -0
- package/dist/blocks/form/file.d.ts +61 -0
- package/dist/blocks/form/file.js +121 -0
- package/dist/blocks/form/form.d.ts +66 -0
- package/dist/blocks/form/form.js +764 -0
- package/dist/blocks/form/list.d.ts +37 -0
- package/dist/blocks/form/list.js +70 -0
- package/dist/blocks/form/values.d.ts +20 -0
- package/dist/blocks/form/values.js +106 -0
- package/dist/blocks/icons.d.ts +14 -0
- package/dist/blocks/icons.js +74 -0
- package/dist/blocks/image.d.ts +39 -0
- package/dist/blocks/image.js +82 -0
- package/dist/blocks/interaction-transport.d.ts +42 -0
- package/dist/blocks/interaction-transport.js +79 -0
- package/dist/blocks/layout.d.ts +31 -0
- package/dist/blocks/layout.js +155 -0
- package/dist/blocks/log.d.ts +24 -0
- package/dist/blocks/log.js +111 -0
- package/dist/blocks/meter.d.ts +16 -0
- package/dist/blocks/meter.js +50 -0
- package/dist/blocks/page.d.ts +54 -0
- package/dist/blocks/page.js +360 -0
- package/dist/blocks/render.d.ts +31 -0
- package/dist/blocks/render.js +163 -0
- package/dist/blocks/surface-view.d.ts +41 -0
- package/dist/blocks/surface-view.js +137 -0
- package/dist/blocks/surface.d.ts +80 -0
- package/dist/blocks/surface.js +506 -0
- package/dist/blocks/tones.d.ts +23 -0
- package/dist/blocks/tones.js +63 -0
- package/dist/blocks/treeview.d.ts +22 -0
- package/dist/blocks/treeview.js +121 -0
- package/dist/blocks/width.d.ts +32 -0
- package/dist/blocks/width.js +70 -0
- package/dist/clipboard.d.ts +20 -0
- package/dist/clipboard.js +19 -0
- package/dist/editor-launch.d.ts +34 -0
- package/dist/editor-launch.js +70 -0
- package/dist/keymaps/emacs.d.ts +18 -0
- package/dist/keymaps/emacs.js +172 -0
- package/dist/keymaps/standard.d.ts +17 -0
- package/dist/keymaps/standard.js +175 -0
- package/dist/keymaps/types.d.ts +43 -0
- package/dist/keymaps/types.js +28 -0
- package/dist/keymaps/vim.d.ts +48 -0
- package/dist/keymaps/vim.js +291 -0
- package/dist/launch.d.ts +159 -0
- package/dist/launch.js +478 -0
- package/dist/sse.d.ts +112 -0
- package/dist/sse.js +418 -0
- package/dist/theme.d.ts +51 -0
- package/dist/theme.js +119 -0
- package/dist/ui/ambient.d.ts +78 -0
- package/dist/ui/ambient.js +264 -0
- package/dist/ui/app.d.ts +319 -0
- package/dist/ui/app.js +1539 -0
- package/dist/ui/composer.d.ts +66 -0
- package/dist/ui/composer.js +133 -0
- package/dist/ui/conversation-pane.d.ts +143 -0
- package/dist/ui/conversation-pane.js +615 -0
- package/dist/ui/help.d.ts +15 -0
- package/dist/ui/help.js +80 -0
- package/dist/ui/interactions.d.ts +91 -0
- package/dist/ui/interactions.js +477 -0
- package/dist/ui/picker.d.ts +26 -0
- package/dist/ui/picker.js +130 -0
- package/dist/ui/settings.d.ts +68 -0
- package/dist/ui/settings.js +254 -0
- package/dist/ui/sidebar.d.ts +99 -0
- package/dist/ui/sidebar.js +269 -0
- package/dist/ui/status-bar.d.ts +77 -0
- package/dist/ui/status-bar.js +146 -0
- package/package.json +4 -3
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `lanes` display (UI-blocks U2 Task 4, spec §5.5 + §12.3 + §12.4):
|
|
3
|
+
* one lane per group, side by side in regular/wide; compact shows ONE
|
|
4
|
+
* lane with a Tab/Shift+Tab switcher and a per-item "Move to…" menu
|
|
5
|
+
* (the `.` action-menu) for cross-lane moves.
|
|
6
|
+
*
|
|
7
|
+
* Keyboard moves (§12.4): `m` picks the item under the cursor up,
|
|
8
|
+
* arrows place it (left/right across lanes, up/down within), Enter
|
|
9
|
+
* drops with the U1 pointer-drop payload `{itemId, from: {group,
|
|
10
|
+
* index}, to: {group, index}}`, Esc cancels. Review Focus 1: the
|
|
11
|
+
* pick-up survives a resize — the placement is recomputed from the
|
|
12
|
+
* live block data on every key, never cached from the rendered lines.
|
|
13
|
+
*
|
|
14
|
+
* `x` checks, `Space` selects, Enter on an item with no pick-up in
|
|
15
|
+
* flight activates it. Quiet items fold behind a per-lane "N more"
|
|
16
|
+
* row. The cursor is (lane, row); at regular/wide lanes render side by
|
|
17
|
+
* side and the cursor lane is the compact-selected one.
|
|
18
|
+
*/
|
|
19
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
20
|
+
import type { CollectionBlock } from "@sigrun/shared/blocks";
|
|
21
|
+
import type { BlockCtx } from "../context.js";
|
|
22
|
+
import { isQuiet } from "./shared.js";
|
|
23
|
+
/** The lanes display. */
|
|
24
|
+
export declare class LanesComponent implements Component {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(block: CollectionBlock, ctx: BlockCtx);
|
|
27
|
+
render(width: number): string[];
|
|
28
|
+
handleInput(data: string): void;
|
|
29
|
+
invalidate(): void;
|
|
30
|
+
}
|
|
31
|
+
/** Re-exported so tests can assert the compact switcher boundary. */
|
|
32
|
+
export { isQuiet };
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `lanes` display (UI-blocks U2 Task 4, spec §5.5 + §12.3 + §12.4):
|
|
3
|
+
* one lane per group, side by side in regular/wide; compact shows ONE
|
|
4
|
+
* lane with a Tab/Shift+Tab switcher and a per-item "Move to…" menu
|
|
5
|
+
* (the `.` action-menu) for cross-lane moves.
|
|
6
|
+
*
|
|
7
|
+
* Keyboard moves (§12.4): `m` picks the item under the cursor up,
|
|
8
|
+
* arrows place it (left/right across lanes, up/down within), Enter
|
|
9
|
+
* drops with the U1 pointer-drop payload `{itemId, from: {group,
|
|
10
|
+
* index}, to: {group, index}}`, Esc cancels. Review Focus 1: the
|
|
11
|
+
* pick-up survives a resize — the placement is recomputed from the
|
|
12
|
+
* live block data on every key, never cached from the rendered lines.
|
|
13
|
+
*
|
|
14
|
+
* `x` checks, `Space` selects, Enter on an item with no pick-up in
|
|
15
|
+
* flight activates it. Quiet items fold behind a per-lane "N more"
|
|
16
|
+
* row. The cursor is (lane, row); at regular/wide lanes render side by
|
|
17
|
+
* side and the cursor lane is the compact-selected one.
|
|
18
|
+
*/
|
|
19
|
+
import { truncate, widthClassOf } from "../width.js";
|
|
20
|
+
import { cardLines } from "./card.js";
|
|
21
|
+
import { MoveSession, dispatchMove, lanes, movePayload, placementOf, shiftAcross, shiftWithin, } from "./move.js";
|
|
22
|
+
import { CollectionState, dispatchActivate, dispatchCheck, isQuiet, } from "./shared.js";
|
|
23
|
+
/** The lanes display. */
|
|
24
|
+
export class LanesComponent {
|
|
25
|
+
#block;
|
|
26
|
+
#ctx;
|
|
27
|
+
#state = new CollectionState();
|
|
28
|
+
#moves = new MoveSession();
|
|
29
|
+
#tick = 0;
|
|
30
|
+
/** The compact lane selection (survives width-class changes — the
|
|
31
|
+
* same rule as U1's Review Focus 3). */
|
|
32
|
+
#lane = 0;
|
|
33
|
+
/** The cursor: a lane id plus a row within it. */
|
|
34
|
+
#cursor = { group: "", row: 0 };
|
|
35
|
+
/** The item the "Move to…" menu was opened for. */
|
|
36
|
+
#menuItem;
|
|
37
|
+
constructor(block, ctx) {
|
|
38
|
+
this.#block = block;
|
|
39
|
+
this.#ctx = ctx;
|
|
40
|
+
}
|
|
41
|
+
#lanes() {
|
|
42
|
+
return lanes(this.#block, this.#ctx);
|
|
43
|
+
}
|
|
44
|
+
/** The cursor lane (defaulting to the first group / compact lane). */
|
|
45
|
+
#cursorLane(groups) {
|
|
46
|
+
if (this.#cursor.group === "") {
|
|
47
|
+
const first = groups[Math.min(this.#lane, groups.length - 1)];
|
|
48
|
+
if (first !== undefined)
|
|
49
|
+
this.#cursor = { group: first.id, row: 0 };
|
|
50
|
+
}
|
|
51
|
+
return (groups.find((group) => group.id === this.#cursor.group) ?? groups[0]);
|
|
52
|
+
}
|
|
53
|
+
/** The addressable rows of a lane: visible items, then quiet items
|
|
54
|
+
* (folded behind "N more" while the lane's fold is closed). */
|
|
55
|
+
#laneRows(group, items) {
|
|
56
|
+
const rows = [];
|
|
57
|
+
const visibleIds = new Set(items.map((row) => row.id));
|
|
58
|
+
const quiet = group.items.filter((row) => !visibleIds.has(row.id));
|
|
59
|
+
if (this.#state.quietShownFor(group.id)) {
|
|
60
|
+
for (const item of [...items, ...quiet]) {
|
|
61
|
+
rows.push({ kind: "item", group: group.id, itemId: item.id });
|
|
62
|
+
}
|
|
63
|
+
if (quiet.length > 0) {
|
|
64
|
+
rows.push({ kind: "quiet", group: group.id, quietCount: 0 });
|
|
65
|
+
}
|
|
66
|
+
return rows;
|
|
67
|
+
}
|
|
68
|
+
for (const item of items)
|
|
69
|
+
rows.push({ kind: "item", group: group.id, itemId: item.id });
|
|
70
|
+
if (quiet.length > 0) {
|
|
71
|
+
rows.push({ kind: "quiet", group: group.id, quietCount: quiet.length });
|
|
72
|
+
}
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
75
|
+
render(width) {
|
|
76
|
+
this.#tick += 1;
|
|
77
|
+
const { groups, visible } = this.#lanes();
|
|
78
|
+
const compact = widthClassOf(width) === "compact";
|
|
79
|
+
if (this.#lane >= groups.length)
|
|
80
|
+
this.#lane = 0;
|
|
81
|
+
const shownGroups = compact
|
|
82
|
+
? groups.filter((group) => group.id === (groups[this.#lane]?.id ?? ""))
|
|
83
|
+
: groups;
|
|
84
|
+
if (this.#state.menu?.kind === "moveTo") {
|
|
85
|
+
const lines = [truncate("Move to…", width)];
|
|
86
|
+
this.#moveTargets().forEach((group, index) => {
|
|
87
|
+
const cursor = index === this.#state.menuCursor ? "> " : " ";
|
|
88
|
+
lines.push(truncate(cursor + (group.title || group.id), width));
|
|
89
|
+
});
|
|
90
|
+
return lines;
|
|
91
|
+
}
|
|
92
|
+
const lines = [];
|
|
93
|
+
if (compact && groups.length > 1) {
|
|
94
|
+
const names = groups
|
|
95
|
+
.map((group, index) => {
|
|
96
|
+
const label = group.title || group.id;
|
|
97
|
+
return index === this.#lane ? this.#ctx.theme.bold(label) : label;
|
|
98
|
+
})
|
|
99
|
+
.join(" ");
|
|
100
|
+
lines.push(truncate(names, width));
|
|
101
|
+
}
|
|
102
|
+
const gutter = 2;
|
|
103
|
+
const laneWidth = shownGroups.length === 0
|
|
104
|
+
? width
|
|
105
|
+
: Math.max(8, Math.floor((width - gutter * (shownGroups.length - 1)) / shownGroups.length));
|
|
106
|
+
const perLane = shownGroups.map((group) => this.#laneLines(group, visible.get(group.id) ?? [], laneWidth));
|
|
107
|
+
const height = Math.max(0, ...perLane.map((column) => column.length));
|
|
108
|
+
for (let row = 0; row < height; row++) {
|
|
109
|
+
const parts = perLane.map((column) => (column[row] ?? "").padEnd(laneWidth));
|
|
110
|
+
lines.push(truncate(parts.join(" ".repeat(gutter)), width));
|
|
111
|
+
}
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
/** One lane's lines: heading, item cards (with a cursor column and
|
|
115
|
+
* the pick-up marker), the quiet fold, and the drop marker. */
|
|
116
|
+
#laneLines(group, items, width) {
|
|
117
|
+
const lines = [];
|
|
118
|
+
lines.push(this.#ctx.theme.bold(truncate(`## ${group.title || group.id}`, width)));
|
|
119
|
+
const rows = this.#laneRows(group, items);
|
|
120
|
+
rows.forEach((row, rowIndex) => {
|
|
121
|
+
const atCursor = this.#cursor.group === group.id && this.#cursor.row === rowIndex;
|
|
122
|
+
const cursor = atCursor ? "> " : " ";
|
|
123
|
+
if (row.kind === "quiet") {
|
|
124
|
+
const label = row.quietCount === 0 ? "Show less" : `${row.quietCount ?? 0} more`;
|
|
125
|
+
lines.push(truncate(cursor + this.#ctx.theme.dim(label), width));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const item = items.find((candidate) => candidate.id === row.itemId) ??
|
|
129
|
+
group.items.find((candidate) => candidate.id === row.itemId);
|
|
130
|
+
if (item === undefined)
|
|
131
|
+
return;
|
|
132
|
+
const held = this.#moves.held?.itemId === item.id;
|
|
133
|
+
let prefix = "";
|
|
134
|
+
if (this.#block.checkable !== undefined) {
|
|
135
|
+
prefix += this.#state.checkedValue(this.#block, item) ? "[x] " : "[ ] ";
|
|
136
|
+
}
|
|
137
|
+
if (this.#block.select !== undefined) {
|
|
138
|
+
prefix += this.#state.selected.has(item.id)
|
|
139
|
+
? this.#ctx.unicode
|
|
140
|
+
? "(•) "
|
|
141
|
+
: "(*) "
|
|
142
|
+
: "( ) ";
|
|
143
|
+
}
|
|
144
|
+
if (held)
|
|
145
|
+
prefix += this.#ctx.unicode ? "✋ " : "* ";
|
|
146
|
+
const card = cardLines(this.#block.item, item.item, this.#ctx, width - cursor.length - prefix.length, 0, this.#tick);
|
|
147
|
+
const [first, ...rest] = card;
|
|
148
|
+
lines.push(truncate(`${cursor}${prefix}${first ?? ""}`, width));
|
|
149
|
+
for (const line of rest) {
|
|
150
|
+
lines.push(truncate(`${cursor}${" ".repeat(prefix.length)}${line}`, width));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
const held = this.#moves.held;
|
|
154
|
+
if (held !== undefined && held.at.group === group.id) {
|
|
155
|
+
const marker = this.#ctx.unicode ? "⌄ drop here" : "v drop here";
|
|
156
|
+
lines.push(this.#ctx.theme.dim(truncate(` ${marker} (${held.at.index})`, width)));
|
|
157
|
+
}
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
160
|
+
/** The other groups (the compact "Move to…" menu entries). */
|
|
161
|
+
#moveTargets() {
|
|
162
|
+
const { groups } = this.#lanes();
|
|
163
|
+
return groups.filter((group) => group.id !== this.#cursor.group);
|
|
164
|
+
}
|
|
165
|
+
#item(id) {
|
|
166
|
+
if (id === undefined)
|
|
167
|
+
return undefined;
|
|
168
|
+
const { groups } = this.#lanes();
|
|
169
|
+
for (const group of groups) {
|
|
170
|
+
const found = group.items.find((row) => row.id === id);
|
|
171
|
+
if (found !== undefined)
|
|
172
|
+
return found;
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
handleInput(data) {
|
|
177
|
+
const { groups, visible } = this.#lanes();
|
|
178
|
+
const lane = this.#cursorLane(groups);
|
|
179
|
+
// The "Move to…" menu owns the keys while open.
|
|
180
|
+
if (this.#state.menu?.kind === "moveTo") {
|
|
181
|
+
const targets = this.#moveTargets();
|
|
182
|
+
if (data === "\x1b") {
|
|
183
|
+
this.#state.menu = null;
|
|
184
|
+
this.#menuItem = undefined;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (data === "\x1b[B") {
|
|
188
|
+
this.#state.menuCursor = Math.min(targets.length - 1, this.#state.menuCursor + 1);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (data === "\x1b[A") {
|
|
192
|
+
this.#state.menuCursor = Math.max(0, this.#state.menuCursor - 1);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (data !== "\r")
|
|
196
|
+
return;
|
|
197
|
+
const target = targets[this.#state.menuCursor];
|
|
198
|
+
const itemId = this.#menuItem;
|
|
199
|
+
this.#state.menu = null;
|
|
200
|
+
this.#menuItem = undefined;
|
|
201
|
+
if (target === undefined || itemId === undefined)
|
|
202
|
+
return;
|
|
203
|
+
const from = placementOf(groups, visible, itemId);
|
|
204
|
+
if (from === undefined || this.#block.onMove === undefined)
|
|
205
|
+
return;
|
|
206
|
+
// The compact move target is the END of the chosen lane (U1's
|
|
207
|
+
// MoveToMenu rule, spec §12.3).
|
|
208
|
+
const to = { group: target.id, index: (visible.get(target.id) ?? []).length };
|
|
209
|
+
this.#ctx.dispatch({ type: "move", name: this.#block.onMove }, movePayload({ itemId, from, to }));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// A pick-up in flight owns the arrows (spec §12.4).
|
|
213
|
+
const held = this.#moves.held;
|
|
214
|
+
if (held !== undefined) {
|
|
215
|
+
if (data === "\x1b") {
|
|
216
|
+
this.#moves.cancel();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (data === "\x1b[D") {
|
|
220
|
+
this.#moves.held = shiftAcross(held, groups, visible, -1);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (data === "\x1b[C") {
|
|
224
|
+
this.#moves.held = shiftAcross(held, groups, visible, 1);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (data === "\x1b[A") {
|
|
228
|
+
this.#moves.held = shiftWithin(held, visible, -1);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (data === "\x1b[B") {
|
|
232
|
+
this.#moves.held = shiftWithin(held, visible, 1);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (data === "\r") {
|
|
236
|
+
this.#moves.held = undefined;
|
|
237
|
+
dispatchMove(this.#block, this.#ctx, held);
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// Compact lane switcher: Tab / Shift+Tab cycle lanes.
|
|
242
|
+
if (data === "\t") {
|
|
243
|
+
if (groups.length > 1) {
|
|
244
|
+
this.#lane = (this.#lane + 1) % groups.length;
|
|
245
|
+
this.#cursor = { group: groups[this.#lane]?.id ?? "", row: 0 };
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (data === "\x1b[Z") {
|
|
250
|
+
if (groups.length > 1) {
|
|
251
|
+
this.#lane = this.#lane - 1 < 0 ? groups.length - 1 : this.#lane - 1;
|
|
252
|
+
this.#cursor = { group: groups[this.#lane]?.id ?? "", row: 0 };
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (lane === undefined)
|
|
257
|
+
return;
|
|
258
|
+
const rows = this.#laneRows(lane, visible.get(lane.id) ?? []);
|
|
259
|
+
if (this.#cursor.row >= rows.length)
|
|
260
|
+
this.#cursor.row = Math.max(0, rows.length - 1);
|
|
261
|
+
const current = rows[this.#cursor.row];
|
|
262
|
+
if (data === "m" && current?.kind === "item" && current.itemId !== undefined) {
|
|
263
|
+
if (this.#block.onMove === undefined)
|
|
264
|
+
return;
|
|
265
|
+
const from = placementOf(groups, visible, current.itemId);
|
|
266
|
+
if (from === undefined)
|
|
267
|
+
return;
|
|
268
|
+
this.#moves.pickUp(current.itemId, from);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (data === ".") {
|
|
272
|
+
if (current?.kind === "item" &&
|
|
273
|
+
this.#block.onMove !== undefined &&
|
|
274
|
+
this.#moveTargets().length > 0) {
|
|
275
|
+
this.#state.menu = { kind: "moveTo" };
|
|
276
|
+
this.#state.menuCursor = 0;
|
|
277
|
+
this.#menuItem = current.itemId;
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (data === "\x1b[B") {
|
|
282
|
+
this.#cursor = { group: lane.id, row: Math.min(rows.length - 1, this.#cursor.row + 1) };
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (data === "\x1b[A") {
|
|
286
|
+
this.#cursor = { group: lane.id, row: Math.max(0, this.#cursor.row - 1) };
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (data === "x" && current?.kind === "item") {
|
|
290
|
+
const item = this.#item(current.itemId);
|
|
291
|
+
if (item !== undefined)
|
|
292
|
+
dispatchCheck(this.#block, this.#ctx, this.#state, item);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (data === " " && this.#block.select !== undefined && current?.kind === "item") {
|
|
296
|
+
const id = current.itemId ?? "";
|
|
297
|
+
if (this.#state.selected.has(id))
|
|
298
|
+
this.#state.selected.delete(id);
|
|
299
|
+
else
|
|
300
|
+
this.#state.selected.add(id);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (data !== "\r")
|
|
304
|
+
return;
|
|
305
|
+
if (current?.kind === "quiet") {
|
|
306
|
+
this.#state.toggleQuiet(current.group);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (current?.kind === "item") {
|
|
310
|
+
const item = this.#item(current.itemId);
|
|
311
|
+
if (item !== undefined)
|
|
312
|
+
dispatchActivate(this.#block, this.#ctx, item);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
invalidate() {
|
|
316
|
+
// State (cursor, selection, folds, the pick-up) survives re-renders.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Re-exported so tests can assert the compact switcher boundary. */
|
|
320
|
+
export { isQuiet };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `list` display (UI-blocks spec §5.5): rows with group headings in
|
|
3
|
+
* `groups` order, 2-cell-per-level nesting on `children`, `numbered`,
|
|
4
|
+
* checkboxes, quiet items folded behind "N more", and the multi-select
|
|
5
|
+
* bulk action menu (`.`).
|
|
6
|
+
*
|
|
7
|
+
* Keys (spec §12.4): Up/Down move the cursor; `x` toggles the checkable
|
|
8
|
+
* field and dispatches `onCheck` (`edit`, `{itemId, value}`); `Space`
|
|
9
|
+
* selects; `.` opens the bulk menu; Enter activates a row (`onActivate`
|
|
10
|
+
* with `{itemId}`), unfolds the "N more" fold, or picks a menu entry.
|
|
11
|
+
* Selection, folds and menus are view-only — they never dispatch.
|
|
12
|
+
*
|
|
13
|
+
* The bulk menu is the seam Task 5's form renderer plugs into: an
|
|
14
|
+
* action with a `form` opens a minimal seam form here (labeled line
|
|
15
|
+
* per top-level field name, values keyed by name) and its submit rides
|
|
16
|
+
* the bulk payload as `{ids, values}` — mirroring U1 Task 3's note.
|
|
17
|
+
*/
|
|
18
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
19
|
+
import type { CollectionBlock } from "@sigrun/shared/blocks";
|
|
20
|
+
import { type BlockCtx } from "../context.js";
|
|
21
|
+
import { widthClassOf } from "../width.js";
|
|
22
|
+
/** `list`: groups, nesting, numbering, checkboxes, quiet fold, bulk menu. */
|
|
23
|
+
export declare class ListComponent implements Component {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(block: CollectionBlock, ctx: BlockCtx);
|
|
26
|
+
render(width: number): string[];
|
|
27
|
+
handleInput(data: string): void;
|
|
28
|
+
invalidate(): void;
|
|
29
|
+
}
|
|
30
|
+
export { widthClassOf };
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `list` display (UI-blocks spec §5.5): rows with group headings in
|
|
3
|
+
* `groups` order, 2-cell-per-level nesting on `children`, `numbered`,
|
|
4
|
+
* checkboxes, quiet items folded behind "N more", and the multi-select
|
|
5
|
+
* bulk action menu (`.`).
|
|
6
|
+
*
|
|
7
|
+
* Keys (spec §12.4): Up/Down move the cursor; `x` toggles the checkable
|
|
8
|
+
* field and dispatches `onCheck` (`edit`, `{itemId, value}`); `Space`
|
|
9
|
+
* selects; `.` opens the bulk menu; Enter activates a row (`onActivate`
|
|
10
|
+
* with `{itemId}`), unfolds the "N more" fold, or picks a menu entry.
|
|
11
|
+
* Selection, folds and menus are view-only — they never dispatch.
|
|
12
|
+
*
|
|
13
|
+
* The bulk menu is the seam Task 5's form renderer plugs into: an
|
|
14
|
+
* action with a `form` opens a minimal seam form here (labeled line
|
|
15
|
+
* per top-level field name, values keyed by name) and its submit rides
|
|
16
|
+
* the bulk payload as `{ids, values}` — mirroring U1 Task 3's note.
|
|
17
|
+
*/
|
|
18
|
+
import { bindText } from "../context.js";
|
|
19
|
+
import { truncate, widthClassOf } from "../width.js";
|
|
20
|
+
import { cardLines } from "./card.js";
|
|
21
|
+
import { CollectionState, childItems, collectionItems, dispatchActivate, dispatchBulk, dispatchCheck, groupItems, isQuiet, sortItems, } from "./shared.js";
|
|
22
|
+
/** Top-level `form` block fields of a bulk action's form tree. */
|
|
23
|
+
function seamFields(form, ctx) {
|
|
24
|
+
const root = form.root;
|
|
25
|
+
if (root.type !== "form")
|
|
26
|
+
return [];
|
|
27
|
+
const fields = [];
|
|
28
|
+
for (const child of root.children) {
|
|
29
|
+
if (child.type === "section")
|
|
30
|
+
continue;
|
|
31
|
+
fields.push({ name: child.name, label: bindText(child.label, ctx) || child.name });
|
|
32
|
+
}
|
|
33
|
+
return fields;
|
|
34
|
+
}
|
|
35
|
+
/** `list`: groups, nesting, numbering, checkboxes, quiet fold, bulk menu. */
|
|
36
|
+
export class ListComponent {
|
|
37
|
+
#block;
|
|
38
|
+
#ctx;
|
|
39
|
+
#state = new CollectionState();
|
|
40
|
+
#tick = 0;
|
|
41
|
+
constructor(block, ctx) {
|
|
42
|
+
this.#block = block;
|
|
43
|
+
this.#ctx = ctx;
|
|
44
|
+
}
|
|
45
|
+
/** The items in render order (block's initial `sort`). */
|
|
46
|
+
#items() {
|
|
47
|
+
return sortItems(collectionItems(this.#block, this.#ctx), this.#block.sort);
|
|
48
|
+
}
|
|
49
|
+
/** Flatten the render into addressable rows. */
|
|
50
|
+
#rows() {
|
|
51
|
+
const rows = [];
|
|
52
|
+
const push = (row, depth) => {
|
|
53
|
+
rows.push({ kind: "item", row, depth });
|
|
54
|
+
for (const child of childItems(this.#block, row.item))
|
|
55
|
+
push(child, depth + 1);
|
|
56
|
+
};
|
|
57
|
+
for (const group of this.#groups()) {
|
|
58
|
+
if (group.id !== "" && group.title !== "") {
|
|
59
|
+
rows.push({ kind: "group", groupId: group.id });
|
|
60
|
+
}
|
|
61
|
+
if (group.collapsed && !this.#state.unfoldedGroups.has(group.id))
|
|
62
|
+
continue;
|
|
63
|
+
const quiet = group.items.filter((row) => isQuiet(this.#block, this.#ctx, row.item));
|
|
64
|
+
const loud = group.items.filter((row) => !isQuiet(this.#block, this.#ctx, row.item));
|
|
65
|
+
for (const row of loud)
|
|
66
|
+
push(row, 0);
|
|
67
|
+
if (quiet.length > 0) {
|
|
68
|
+
if (this.#state.quietShownFor(group.id)) {
|
|
69
|
+
for (const row of quiet)
|
|
70
|
+
push(row, 0);
|
|
71
|
+
rows.push({ kind: "quiet", groupId: group.id, quietRows: [] }); // the "Show less" row
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
rows.push({ kind: "quiet", groupId: group.id, quietRows: quiet });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return rows;
|
|
79
|
+
}
|
|
80
|
+
#groups() {
|
|
81
|
+
return groupItems(this.#block, this.#items(), this.#ctx);
|
|
82
|
+
}
|
|
83
|
+
render(width) {
|
|
84
|
+
this.#tick += 1;
|
|
85
|
+
const rows = this.#rows();
|
|
86
|
+
if (this.#state.cursor >= rows.length)
|
|
87
|
+
this.#state.cursor = Math.max(0, rows.length - 1);
|
|
88
|
+
const lines = [];
|
|
89
|
+
const unicode = this.#ctx.unicode;
|
|
90
|
+
if (this.#state.menu?.kind === "bulk") {
|
|
91
|
+
lines.push(truncate(`Bulk actions (${this.#state.selected.size} selected)`, width));
|
|
92
|
+
const actions = this.#block.bulkActions ?? [];
|
|
93
|
+
actions.forEach((action, index) => {
|
|
94
|
+
const cursor = index === this.#state.menuCursor ? "> " : " ";
|
|
95
|
+
lines.push(truncate(`${cursor}${bindText(action.label, this.#ctx) || action.id}`, width));
|
|
96
|
+
});
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
|
99
|
+
if (this.#state.menu?.kind === "form") {
|
|
100
|
+
const menu = this.#state.menu;
|
|
101
|
+
const value = this.#state.formBuffer === "" ? "…" : this.#state.formBuffer;
|
|
102
|
+
for (const field of seamFields(menu.form, this.#ctx)) {
|
|
103
|
+
lines.push(truncate(`${field.label}: ${value}`, width));
|
|
104
|
+
}
|
|
105
|
+
lines.push(truncate("Enter to submit · Esc to cancel", width));
|
|
106
|
+
return lines;
|
|
107
|
+
}
|
|
108
|
+
rows.forEach((row, index) => {
|
|
109
|
+
const cursor = index === this.#state.cursor ? "> " : " ";
|
|
110
|
+
if (row.kind === "group") {
|
|
111
|
+
lines.push(truncate(cursor + this.#ctx.theme.bold(`## ${this.#groupTitle(row.groupId ?? "")}`), width));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (row.kind === "quiet") {
|
|
115
|
+
const count = row.quietRows?.length ?? 0;
|
|
116
|
+
const label = this.#state.quietShownFor(row.groupId ?? "") ? "Show less" : `${count} more`;
|
|
117
|
+
lines.push(truncate(cursor + this.#ctx.theme.dim(label), width));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const item = row.row;
|
|
121
|
+
if (item === undefined)
|
|
122
|
+
return;
|
|
123
|
+
let prefix = "";
|
|
124
|
+
const indentStr = " ".repeat(row.depth ?? 0);
|
|
125
|
+
if (this.#block.numbered === true && (row.depth ?? 0) === 0) {
|
|
126
|
+
prefix += `${this.#numberOf(rows, index)}. `;
|
|
127
|
+
}
|
|
128
|
+
if (this.#block.checkable !== undefined) {
|
|
129
|
+
prefix += this.#state.checkedValue(this.#block, item) ? "[x] " : "[ ] ";
|
|
130
|
+
}
|
|
131
|
+
if (this.#block.select !== undefined) {
|
|
132
|
+
const box = this.#state.selected.has(item.id)
|
|
133
|
+
? unicode
|
|
134
|
+
? "(•) "
|
|
135
|
+
: "(*) "
|
|
136
|
+
: "( ) ";
|
|
137
|
+
prefix += box;
|
|
138
|
+
}
|
|
139
|
+
const card = cardLines(this.#block.item, item.item, this.#ctx, width - cursor.length - indentStr.length - prefix.length, 0, this.#tick);
|
|
140
|
+
// The first card line carries the prefixes (title line by slot
|
|
141
|
+
// order — an eyebrow hoists the prefixes onto the eyebrow line,
|
|
142
|
+
// keeping one fixed line layout).
|
|
143
|
+
const [first, ...rest] = card;
|
|
144
|
+
lines.push(truncate(`${cursor}${indentStr}${prefix}${first ?? ""}`, width));
|
|
145
|
+
for (const line of rest) {
|
|
146
|
+
lines.push(truncate(`${cursor}${indentStr}${" ".repeat(prefix.length)}${line}`, width));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
151
|
+
#groupTitle(id) {
|
|
152
|
+
return this.#groups().find((group) => group.id === id)?.title ?? id;
|
|
153
|
+
}
|
|
154
|
+
/** The running number of a depth-0 item row (numbered lists). */
|
|
155
|
+
#numberOf(rows, at) {
|
|
156
|
+
let number = 0;
|
|
157
|
+
for (let i = 0; i <= at; i++) {
|
|
158
|
+
const row = rows[i];
|
|
159
|
+
if (row?.kind === "item" && row.depth === 0)
|
|
160
|
+
number += 1;
|
|
161
|
+
}
|
|
162
|
+
return number;
|
|
163
|
+
}
|
|
164
|
+
handleInput(data) {
|
|
165
|
+
if (this.#state.menu !== null) {
|
|
166
|
+
this.#menuInput(data);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const rows = this.#rows();
|
|
170
|
+
if (data === "\x1b[B") {
|
|
171
|
+
this.#state.cursor = Math.min(rows.length - 1, this.#state.cursor + 1);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (data === "\x1b[A") {
|
|
175
|
+
this.#state.cursor = Math.max(0, this.#state.cursor - 1);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const row = rows[this.#state.cursor];
|
|
179
|
+
if (row === undefined)
|
|
180
|
+
return;
|
|
181
|
+
if (data === "x" && row.kind === "item" && row.row !== undefined) {
|
|
182
|
+
dispatchCheck(this.#block, this.#ctx, this.#state, row.row);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (data === " " && this.#block.select !== undefined && row.kind === "item" && row.row !== undefined) {
|
|
186
|
+
const id = row.row.id;
|
|
187
|
+
if (this.#state.selected.has(id))
|
|
188
|
+
this.#state.selected.delete(id);
|
|
189
|
+
else
|
|
190
|
+
this.#state.selected.add(id);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (data === "." && this.#block.select === "multi" && this.#state.selected.size > 0) {
|
|
194
|
+
if ((this.#block.bulkActions ?? []).length > 0) {
|
|
195
|
+
this.#state.menu = { kind: "bulk" };
|
|
196
|
+
this.#state.menuCursor = 0;
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (data !== "\r")
|
|
201
|
+
return;
|
|
202
|
+
if (row.kind === "quiet") {
|
|
203
|
+
this.#state.toggleQuiet(row.groupId ?? "");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (row.kind === "group" && row.groupId !== undefined) {
|
|
207
|
+
if (this.#state.unfoldedGroups.has(row.groupId)) {
|
|
208
|
+
this.#state.unfoldedGroups.delete(row.groupId);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
this.#state.unfoldedGroups.add(row.groupId);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (row.row !== undefined)
|
|
216
|
+
dispatchActivate(this.#block, this.#ctx, row.row);
|
|
217
|
+
}
|
|
218
|
+
#menuInput(data) {
|
|
219
|
+
const menu = this.#state.menu;
|
|
220
|
+
if (menu === null)
|
|
221
|
+
return;
|
|
222
|
+
if (data === "\x1b") {
|
|
223
|
+
this.#state.menu = null;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (menu.kind === "form") {
|
|
227
|
+
if (data === "\r") {
|
|
228
|
+
const values = {};
|
|
229
|
+
for (const field of seamFields(menu.form, this.#ctx)) {
|
|
230
|
+
values[field.name] = this.#state.formBuffer;
|
|
231
|
+
}
|
|
232
|
+
this.#state.formBuffer = "";
|
|
233
|
+
dispatchBulk(this.#ctx, this.#state, menu.onSubmit, values);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (data === "\x7f") {
|
|
237
|
+
this.#state.formBuffer = this.#state.formBuffer.slice(0, -1);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (!data.startsWith("\x1b") && data.length >= 1) {
|
|
241
|
+
this.#state.formBuffer += data;
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const actions = this.#block.bulkActions ?? [];
|
|
246
|
+
if (data === "\x1b[B") {
|
|
247
|
+
this.#state.menuCursor = Math.min(actions.length - 1, this.#state.menuCursor + 1);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (data === "\x1b[A") {
|
|
251
|
+
this.#state.menuCursor = Math.max(0, this.#state.menuCursor - 1);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (data !== "\r")
|
|
255
|
+
return;
|
|
256
|
+
const action = actions[this.#state.menuCursor];
|
|
257
|
+
if (action === undefined)
|
|
258
|
+
return;
|
|
259
|
+
if (action.form !== undefined) {
|
|
260
|
+
// The Task-5 seam: the form's full rendering lands with the
|
|
261
|
+
// form block; this menu opens the seam form and its values
|
|
262
|
+
// ride the payload (mirroring U1 Task 3's note in rows.tsx).
|
|
263
|
+
this.#state.menu = { kind: "form", form: action.form, onSubmit: action.onSubmit };
|
|
264
|
+
this.#state.formBuffer = "";
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
dispatchBulk(this.#ctx, this.#state, action.onSubmit);
|
|
268
|
+
}
|
|
269
|
+
invalidate() {
|
|
270
|
+
// State survives re-renders (view-only by design).
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
export { widthClassOf };
|