@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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard moves (UI-blocks U2 Task 4, spec §12.4): the shared
|
|
3
|
+
* placement machinery the lanes and calendar displays drive. `m` picks
|
|
4
|
+
* the item under the cursor up, arrows move the placement (left/right
|
|
5
|
+
* across groups, up/down within), Enter drops — dispatching
|
|
6
|
+
* `{itemId, from: {group, index}, to: {group, index}}`, the EXACT
|
|
7
|
+
* payload U1's pointer drop builds — and Esc cancels.
|
|
8
|
+
*
|
|
9
|
+
* The held item counts as PRESENT in the lane sizing (its removal
|
|
10
|
+
* happens on drop, not on pickup) — the same rule as U1's lanes. A
|
|
11
|
+
* dropped placement equal to the item's current one dispatches nothing.
|
|
12
|
+
*
|
|
13
|
+
* Review Focus 1: a resize while picked up is handled by the DISPLAYS,
|
|
14
|
+
* which recompute placements from the live block data on every key; the
|
|
15
|
+
* held state survives because it lives in the component, not the
|
|
16
|
+
* rendered lines.
|
|
17
|
+
*/
|
|
18
|
+
import type { CollectionBlock, JsonObject } from "@sigrun/shared/blocks";
|
|
19
|
+
import { type CollectionItem, type ItemGroup } from "./shared.js";
|
|
20
|
+
import type { BlockCtx } from "../context.js";
|
|
21
|
+
/** A placement: a group plus the index within its VISIBLE items. */
|
|
22
|
+
export interface Placement {
|
|
23
|
+
readonly group: string;
|
|
24
|
+
readonly index: number;
|
|
25
|
+
}
|
|
26
|
+
/** The keyboard-move session (the picked-up item and its placement). */
|
|
27
|
+
export interface MoveState {
|
|
28
|
+
readonly itemId: string;
|
|
29
|
+
readonly from: Placement;
|
|
30
|
+
readonly at: Placement;
|
|
31
|
+
}
|
|
32
|
+
/** The visible items of a group (sorted, loud items — quiet items are
|
|
33
|
+
* folded behind the fold row and not placement targets). */
|
|
34
|
+
export declare function visibleLaneItems(block: CollectionBlock, ctx: BlockCtx, group: ItemGroup): CollectionItem[];
|
|
35
|
+
/** Every group with its visible items, in group order. */
|
|
36
|
+
export declare function lanes(block: CollectionBlock, ctx: BlockCtx): {
|
|
37
|
+
groups: ItemGroup[];
|
|
38
|
+
visible: Map<string, CollectionItem[]>;
|
|
39
|
+
};
|
|
40
|
+
/** An item's current placement across every group. */
|
|
41
|
+
export declare function placementOf(groups: readonly ItemGroup[], visible: ReadonlyMap<string, CollectionItem[]>, itemId: string): Placement | undefined;
|
|
42
|
+
/** The move payload (spec §6.3 step 2; U1's pointer-drop shape exactly). */
|
|
43
|
+
export declare function movePayload(move: {
|
|
44
|
+
itemId: string;
|
|
45
|
+
from: Placement;
|
|
46
|
+
to: Placement;
|
|
47
|
+
}): JsonObject;
|
|
48
|
+
/** Move the placement left/right across groups (the index clamps to
|
|
49
|
+
* the target lane's size; out-of-range directions are inert). */
|
|
50
|
+
export declare function shiftAcross(move: MoveState, groups: readonly ItemGroup[], visible: ReadonlyMap<string, CollectionItem[]>, delta: 1 | -1): MoveState;
|
|
51
|
+
/** Move the placement up/down within its group (clamped). */
|
|
52
|
+
export declare function shiftWithin(move: MoveState, visible: ReadonlyMap<string, CollectionItem[]>, delta: 1 | -1): MoveState;
|
|
53
|
+
/**
|
|
54
|
+
* Dispatch the drop: nothing when the placement equals the current
|
|
55
|
+
* position; otherwise the `move` action with the U1 payload. The
|
|
56
|
+
* optimistic flip lives in SurfaceState's overlay — the component
|
|
57
|
+
* itself keeps no per-item move override.
|
|
58
|
+
*/
|
|
59
|
+
export declare function dispatchMove(block: CollectionBlock, ctx: BlockCtx, move: MoveState): void;
|
|
60
|
+
/** The per-display move session state (held in the component). */
|
|
61
|
+
export declare class MoveSession {
|
|
62
|
+
held: MoveState | undefined;
|
|
63
|
+
/** Pick up the item (undefined placement = no pickup). */
|
|
64
|
+
pickUp(itemId: string, from: Placement): void;
|
|
65
|
+
/** Cancel (Esc): drop the session without dispatching. */
|
|
66
|
+
cancel(): void;
|
|
67
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard moves (UI-blocks U2 Task 4, spec §12.4): the shared
|
|
3
|
+
* placement machinery the lanes and calendar displays drive. `m` picks
|
|
4
|
+
* the item under the cursor up, arrows move the placement (left/right
|
|
5
|
+
* across groups, up/down within), Enter drops — dispatching
|
|
6
|
+
* `{itemId, from: {group, index}, to: {group, index}}`, the EXACT
|
|
7
|
+
* payload U1's pointer drop builds — and Esc cancels.
|
|
8
|
+
*
|
|
9
|
+
* The held item counts as PRESENT in the lane sizing (its removal
|
|
10
|
+
* happens on drop, not on pickup) — the same rule as U1's lanes. A
|
|
11
|
+
* dropped placement equal to the item's current one dispatches nothing.
|
|
12
|
+
*
|
|
13
|
+
* Review Focus 1: a resize while picked up is handled by the DISPLAYS,
|
|
14
|
+
* which recompute placements from the live block data on every key; the
|
|
15
|
+
* held state survives because it lives in the component, not the
|
|
16
|
+
* rendered lines.
|
|
17
|
+
*/
|
|
18
|
+
import { collectionItems, groupItems, isQuiet, sortItems, } from "./shared.js";
|
|
19
|
+
/** The visible items of a group (sorted, loud items — quiet items are
|
|
20
|
+
* folded behind the fold row and not placement targets). */
|
|
21
|
+
export function visibleLaneItems(block, ctx, group) {
|
|
22
|
+
const sorted = sortItems(group.items, block.sort);
|
|
23
|
+
return sorted.filter((row) => !isQuiet(block, ctx, row.item));
|
|
24
|
+
}
|
|
25
|
+
/** Every group with its visible items, in group order. */
|
|
26
|
+
export function lanes(block, ctx) {
|
|
27
|
+
const groups = groupItems(block, sortItems(collectionItems(block, ctx), block.sort), ctx);
|
|
28
|
+
const visible = new Map();
|
|
29
|
+
for (const group of groups) {
|
|
30
|
+
visible.set(group.id, visibleLaneItems(block, ctx, group));
|
|
31
|
+
}
|
|
32
|
+
return { groups, visible };
|
|
33
|
+
}
|
|
34
|
+
/** An item's current placement across every group. */
|
|
35
|
+
export function placementOf(groups, visible, itemId) {
|
|
36
|
+
for (const group of groups) {
|
|
37
|
+
const items = visible.get(group.id) ?? [];
|
|
38
|
+
const index = items.findIndex((row) => row.id === itemId);
|
|
39
|
+
if (index >= 0)
|
|
40
|
+
return { group: group.id, index };
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
/** The move payload (spec §6.3 step 2; U1's pointer-drop shape exactly). */
|
|
45
|
+
export function movePayload(move) {
|
|
46
|
+
return {
|
|
47
|
+
itemId: move.itemId,
|
|
48
|
+
from: { group: move.from.group, index: move.from.index },
|
|
49
|
+
to: { group: move.to.group, index: move.to.index },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Move the placement left/right across groups (the index clamps to
|
|
53
|
+
* the target lane's size; out-of-range directions are inert). */
|
|
54
|
+
export function shiftAcross(move, groups, visible, delta) {
|
|
55
|
+
const order = groups.map((group) => group.id);
|
|
56
|
+
const laneIndex = order.indexOf(move.at.group);
|
|
57
|
+
const target = order[laneIndex + delta];
|
|
58
|
+
if (target === undefined)
|
|
59
|
+
return move;
|
|
60
|
+
const size = (visible.get(target) ?? []).length;
|
|
61
|
+
return {
|
|
62
|
+
...move,
|
|
63
|
+
at: { group: target, index: Math.min(move.at.index, size) },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Move the placement up/down within its group (clamped). */
|
|
67
|
+
export function shiftWithin(move, visible, delta) {
|
|
68
|
+
const size = (visible.get(move.at.group) ?? []).length;
|
|
69
|
+
const index = delta === 1 ? Math.min(move.at.index + 1, size) : Math.max(move.at.index - 1, 0);
|
|
70
|
+
return { ...move, at: { group: move.at.group, index } };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Dispatch the drop: nothing when the placement equals the current
|
|
74
|
+
* position; otherwise the `move` action with the U1 payload. The
|
|
75
|
+
* optimistic flip lives in SurfaceState's overlay — the component
|
|
76
|
+
* itself keeps no per-item move override.
|
|
77
|
+
*/
|
|
78
|
+
export function dispatchMove(block, ctx, move) {
|
|
79
|
+
if (block.onMove === undefined)
|
|
80
|
+
return;
|
|
81
|
+
if (move.from.group === move.at.group && move.from.index === move.at.index)
|
|
82
|
+
return;
|
|
83
|
+
ctx.dispatch({ type: "move", name: block.onMove }, movePayload({ itemId: move.itemId, from: move.from, to: move.at }));
|
|
84
|
+
}
|
|
85
|
+
/** The per-display move session state (held in the component). */
|
|
86
|
+
export class MoveSession {
|
|
87
|
+
held;
|
|
88
|
+
/** Pick up the item (undefined placement = no pickup). */
|
|
89
|
+
pickUp(itemId, from) {
|
|
90
|
+
this.held = { itemId, from, at: from };
|
|
91
|
+
}
|
|
92
|
+
/** Cancel (Esc): drop the session without dispatching. */
|
|
93
|
+
cancel() {
|
|
94
|
+
this.held = undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared collection machinery (UI-blocks spec §5.5): item resolution,
|
|
3
|
+
* grouping, sorting and the view-only interaction state (selection,
|
|
4
|
+
* folds, menu) the list/table/grid displays drive.
|
|
5
|
+
*
|
|
6
|
+
* Every interactive display owns a {@link CollectionState} — its shape
|
|
7
|
+
* mirrors the `/view/<blockId>/` branch Task 4's surface state will own
|
|
8
|
+
* (sort, page, selection, expanded, quietShown). View-only state never
|
|
9
|
+
* dispatches (§6.2): only `onCheck` (`edit`), `onActivate`, and bulk
|
|
10
|
+
* `submit` actions reach `ctx.dispatch`.
|
|
11
|
+
*/
|
|
12
|
+
import type { CollectionBlock, JsonObject, JsonValue, Tree } from "@sigrun/shared/blocks";
|
|
13
|
+
import { type BlockCtx } from "../context.js";
|
|
14
|
+
/** A resolved collection row: the item and its stable id. */
|
|
15
|
+
export interface CollectionItem {
|
|
16
|
+
id: string;
|
|
17
|
+
item: JsonObject;
|
|
18
|
+
}
|
|
19
|
+
/** A resolved group: heading text and its items, in `groups` order. */
|
|
20
|
+
export interface ItemGroup {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
collapsed: boolean;
|
|
24
|
+
items: CollectionItem[];
|
|
25
|
+
}
|
|
26
|
+
/** Resolve `items` to rows, dropping malformed entries. */
|
|
27
|
+
export declare function collectionItems(block: CollectionBlock, ctx: BlockCtx): CollectionItem[];
|
|
28
|
+
/** Nested child rows of an item (the `children` field's records). */
|
|
29
|
+
export declare function childItems(block: CollectionBlock, item: JsonObject): CollectionItem[];
|
|
30
|
+
/** Compare two items on `field`, ascending. */
|
|
31
|
+
export declare function compareItems(a: JsonObject, b: JsonObject, field: string): number;
|
|
32
|
+
/** Order items by a `{field, dir}` sort (stable; view state wins over the block's). */
|
|
33
|
+
export declare function sortItems(items: readonly CollectionItem[], sort: {
|
|
34
|
+
field: string;
|
|
35
|
+
dir: "asc" | "desc";
|
|
36
|
+
} | undefined): CollectionItem[];
|
|
37
|
+
/**
|
|
38
|
+
* Group items by `groupBy`: declared `groups` in declared order, then
|
|
39
|
+
* unseen values in first-appearance item order. With no `groupBy`,
|
|
40
|
+
* one anonymous group holds everything.
|
|
41
|
+
*/
|
|
42
|
+
export declare function groupItems(block: CollectionBlock, items: readonly CollectionItem[], ctx: BlockCtx): ItemGroup[];
|
|
43
|
+
/** Is an item quiet (folded behind "N more")? */
|
|
44
|
+
export declare function isQuiet(block: CollectionBlock, ctx: BlockCtx, item: JsonValue): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The view-only interaction state of one collection component. The
|
|
47
|
+
* checkable toggle's optimistic flip is part of it: the data update
|
|
48
|
+
* that follows a successful `onCheck` replaces the optimistic value
|
|
49
|
+
* (§6.3's convention, without moves).
|
|
50
|
+
*/
|
|
51
|
+
export declare class CollectionState {
|
|
52
|
+
cursor: number;
|
|
53
|
+
readonly selected: Set<string>;
|
|
54
|
+
readonly checkedOverrides: Map<string, boolean>;
|
|
55
|
+
readonly expanded: Set<string>;
|
|
56
|
+
readonly unfoldedGroups: Set<string>;
|
|
57
|
+
/** The groups whose quiet fold is open (per-group; Task 3's global
|
|
58
|
+
* toggle became per-group with the lanes' per-lane folds). */
|
|
59
|
+
readonly quietOpen: Set<string>;
|
|
60
|
+
/** Is the quiet fold of `groupId` open? */
|
|
61
|
+
quietShownFor(groupId: string): boolean;
|
|
62
|
+
/** Toggle the quiet fold of `groupId`. */
|
|
63
|
+
toggleQuiet(groupId: string): void;
|
|
64
|
+
/** The open overlay: the sort choice, the bulk menu, the seam form,
|
|
65
|
+
* or the compact lanes' "Move to…" target list (Task 4). */
|
|
66
|
+
menu: {
|
|
67
|
+
kind: "sort";
|
|
68
|
+
} | {
|
|
69
|
+
kind: "bulk";
|
|
70
|
+
} | {
|
|
71
|
+
kind: "form";
|
|
72
|
+
form: Tree;
|
|
73
|
+
onSubmit: string;
|
|
74
|
+
} | {
|
|
75
|
+
kind: "moveTo";
|
|
76
|
+
} | null;
|
|
77
|
+
menuCursor: number;
|
|
78
|
+
formBuffer: string;
|
|
79
|
+
/** The effective checked value of an item (data plus the optimistic flip). */
|
|
80
|
+
checkedValue(block: CollectionBlock, row: CollectionItem): boolean;
|
|
81
|
+
}
|
|
82
|
+
/** Dispatch the `onCheck` edit for an item (spec §5.5: `{itemId, value}`). */
|
|
83
|
+
export declare function dispatchCheck(block: CollectionBlock, ctx: BlockCtx, state: CollectionState, row: CollectionItem): void;
|
|
84
|
+
/** Dispatch `onActivate` for an item, payload `{itemId}`. */
|
|
85
|
+
export declare function dispatchActivate(block: CollectionBlock, ctx: BlockCtx, row: CollectionItem): void;
|
|
86
|
+
/** Dispatch a bulk action's submit for the current selection. */
|
|
87
|
+
export declare function dispatchBulk(ctx: BlockCtx, state: CollectionState, onSubmit: string, values?: JsonObject): void;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared collection machinery (UI-blocks spec §5.5): item resolution,
|
|
3
|
+
* grouping, sorting and the view-only interaction state (selection,
|
|
4
|
+
* folds, menu) the list/table/grid displays drive.
|
|
5
|
+
*
|
|
6
|
+
* Every interactive display owns a {@link CollectionState} — its shape
|
|
7
|
+
* mirrors the `/view/<blockId>/` branch Task 4's surface state will own
|
|
8
|
+
* (sort, page, selection, expanded, quietShown). View-only state never
|
|
9
|
+
* dispatches (§6.2): only `onCheck` (`edit`), `onActivate`, and bulk
|
|
10
|
+
* `submit` actions reach `ctx.dispatch`.
|
|
11
|
+
*/
|
|
12
|
+
import { bindRaw, bindText } from "../context.js";
|
|
13
|
+
/** Resolve `items` to rows, dropping malformed entries. */
|
|
14
|
+
export function collectionItems(block, ctx) {
|
|
15
|
+
const raw = bindRaw(block.items, ctx);
|
|
16
|
+
if (!Array.isArray(raw))
|
|
17
|
+
return [];
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const entry of raw) {
|
|
20
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
21
|
+
continue;
|
|
22
|
+
const record = entry;
|
|
23
|
+
const id = typeof record.id === "string" ? record.id : String(record.id ?? "");
|
|
24
|
+
if (id === "")
|
|
25
|
+
continue;
|
|
26
|
+
out.push({ id, item: record });
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/** Nested child rows of an item (the `children` field's records). */
|
|
31
|
+
export function childItems(block, item) {
|
|
32
|
+
const field = block.children;
|
|
33
|
+
if (field === undefined || field === "")
|
|
34
|
+
return [];
|
|
35
|
+
const raw = item[field];
|
|
36
|
+
if (!Array.isArray(raw))
|
|
37
|
+
return [];
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const entry of raw) {
|
|
40
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
41
|
+
continue;
|
|
42
|
+
const record = entry;
|
|
43
|
+
const id = typeof record.id === "string" ? record.id : String(record.id ?? "");
|
|
44
|
+
if (id === "")
|
|
45
|
+
continue;
|
|
46
|
+
out.push({ id, item: record });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** The sort key of an item for a field (numbers sort numerically). */
|
|
51
|
+
function sortValue(item, field) {
|
|
52
|
+
return item[field] ?? null;
|
|
53
|
+
}
|
|
54
|
+
/** Compare two items on `field`, ascending. */
|
|
55
|
+
export function compareItems(a, b, field) {
|
|
56
|
+
const left = sortValue(a, field);
|
|
57
|
+
const right = sortValue(b, field);
|
|
58
|
+
if (typeof left === "number" && typeof right === "number") {
|
|
59
|
+
return left === right ? 0 : left < right ? -1 : 1;
|
|
60
|
+
}
|
|
61
|
+
const leftKey = left === null ? "" : String(left);
|
|
62
|
+
const rightKey = right === null ? "" : String(right);
|
|
63
|
+
if (leftKey === rightKey)
|
|
64
|
+
return 0;
|
|
65
|
+
return leftKey < rightKey ? -1 : 1;
|
|
66
|
+
}
|
|
67
|
+
/** Order items by a `{field, dir}` sort (stable; view state wins over the block's). */
|
|
68
|
+
export function sortItems(items, sort) {
|
|
69
|
+
if (sort === undefined)
|
|
70
|
+
return [...items];
|
|
71
|
+
const sign = sort.dir === "desc" ? -1 : 1;
|
|
72
|
+
return [...items].sort((a, b) => compareItems(a.item, b.item, sort.field) * sign);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Group items by `groupBy`: declared `groups` in declared order, then
|
|
76
|
+
* unseen values in first-appearance item order. With no `groupBy`,
|
|
77
|
+
* one anonymous group holds everything.
|
|
78
|
+
*/
|
|
79
|
+
export function groupItems(block, items, ctx) {
|
|
80
|
+
const by = block.groupBy;
|
|
81
|
+
if (by === undefined || by === "") {
|
|
82
|
+
return [{ id: "", title: "", collapsed: false, items: [...items] }];
|
|
83
|
+
}
|
|
84
|
+
const groups = [];
|
|
85
|
+
const indexById = new Map();
|
|
86
|
+
for (const declared of block.groups ?? []) {
|
|
87
|
+
indexById.set(declared.id, groups.length);
|
|
88
|
+
groups.push({
|
|
89
|
+
id: declared.id,
|
|
90
|
+
title: bindText(declared.title, ctx),
|
|
91
|
+
collapsed: declared.collapsed === true,
|
|
92
|
+
items: [],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
for (const row of items) {
|
|
96
|
+
const value = row.item[by];
|
|
97
|
+
const id = value === undefined || value === null ? "" : String(value);
|
|
98
|
+
const index = indexById.get(id);
|
|
99
|
+
if (index !== undefined) {
|
|
100
|
+
groups[index]?.items.push(row);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
indexById.set(id, groups.length);
|
|
104
|
+
groups.push({ id, title: id, collapsed: false, items: [row] });
|
|
105
|
+
}
|
|
106
|
+
return groups;
|
|
107
|
+
}
|
|
108
|
+
/** Is an item quiet (folded behind "N more")? */
|
|
109
|
+
export function isQuiet(block, ctx, item) {
|
|
110
|
+
if (block.item.quiet === undefined)
|
|
111
|
+
return false;
|
|
112
|
+
return bindRaw(block.item.quiet, { ...ctx, item }) === true;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The view-only interaction state of one collection component. The
|
|
116
|
+
* checkable toggle's optimistic flip is part of it: the data update
|
|
117
|
+
* that follows a successful `onCheck` replaces the optimistic value
|
|
118
|
+
* (§6.3's convention, without moves).
|
|
119
|
+
*/
|
|
120
|
+
export class CollectionState {
|
|
121
|
+
cursor = 0;
|
|
122
|
+
selected = new Set();
|
|
123
|
+
checkedOverrides = new Map();
|
|
124
|
+
expanded = new Set();
|
|
125
|
+
unfoldedGroups = new Set();
|
|
126
|
+
/** The groups whose quiet fold is open (per-group; Task 3's global
|
|
127
|
+
* toggle became per-group with the lanes' per-lane folds). */
|
|
128
|
+
quietOpen = new Set();
|
|
129
|
+
/** Is the quiet fold of `groupId` open? */
|
|
130
|
+
quietShownFor(groupId) {
|
|
131
|
+
return this.quietOpen.has(groupId);
|
|
132
|
+
}
|
|
133
|
+
/** Toggle the quiet fold of `groupId`. */
|
|
134
|
+
toggleQuiet(groupId) {
|
|
135
|
+
if (this.quietOpen.has(groupId))
|
|
136
|
+
this.quietOpen.delete(groupId);
|
|
137
|
+
else
|
|
138
|
+
this.quietOpen.add(groupId);
|
|
139
|
+
}
|
|
140
|
+
/** The open overlay: the sort choice, the bulk menu, the seam form,
|
|
141
|
+
* or the compact lanes' "Move to…" target list (Task 4). */
|
|
142
|
+
menu = null;
|
|
143
|
+
menuCursor = 0;
|
|
144
|
+
formBuffer = "";
|
|
145
|
+
/** The effective checked value of an item (data plus the optimistic flip). */
|
|
146
|
+
checkedValue(block, row) {
|
|
147
|
+
const override = this.checkedOverrides.get(row.id);
|
|
148
|
+
if (override !== undefined)
|
|
149
|
+
return override;
|
|
150
|
+
if (block.checkable === undefined)
|
|
151
|
+
return false;
|
|
152
|
+
return row.item[block.checkable] === true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Dispatch the `onCheck` edit for an item (spec §5.5: `{itemId, value}`). */
|
|
156
|
+
export function dispatchCheck(block, ctx, state, row) {
|
|
157
|
+
if (block.onCheck === undefined || block.checkable === undefined)
|
|
158
|
+
return;
|
|
159
|
+
const value = !state.checkedValue(block, row);
|
|
160
|
+
state.checkedOverrides.set(row.id, value);
|
|
161
|
+
ctx.dispatch({ type: "edit", name: block.onCheck }, { itemId: row.id, value });
|
|
162
|
+
}
|
|
163
|
+
/** Dispatch `onActivate` for an item, payload `{itemId}`. */
|
|
164
|
+
export function dispatchActivate(block, ctx, row) {
|
|
165
|
+
if (block.onActivate === undefined)
|
|
166
|
+
return;
|
|
167
|
+
ctx.dispatch(block.onActivate, { itemId: row.id });
|
|
168
|
+
}
|
|
169
|
+
/** Dispatch a bulk action's submit for the current selection. */
|
|
170
|
+
export function dispatchBulk(ctx, state, onSubmit, values) {
|
|
171
|
+
const ids = [...state.selected];
|
|
172
|
+
const payload = values === undefined ? { ids } : { ids, values };
|
|
173
|
+
const action = { type: "submit", name: onSubmit };
|
|
174
|
+
ctx.dispatch(action, payload);
|
|
175
|
+
state.selected.clear();
|
|
176
|
+
state.menu = null;
|
|
177
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `table` display (UI-blocks spec §5.5 + §12.3): display-width
|
|
3
|
+
* aligned columns (CJK-safe via pi-tui's `visibleWidth`), `s` picks a
|
|
4
|
+
* sortable column and cycles asc → desc, 50 rows per page with
|
|
5
|
+
* PgUp/PgDn, and the compact fallback — rows as cards with
|
|
6
|
+
* `priority: "low"` columns behind Enter-expand.
|
|
7
|
+
*
|
|
8
|
+
* Sort and page are view-only (§6.2): they never dispatch. The footer
|
|
9
|
+
* carries the page range ("1–50 of 120"); the sort menu lists the
|
|
10
|
+
* sortable columns only.
|
|
11
|
+
*/
|
|
12
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
13
|
+
import type { CollectionBlock } from "@sigrun/shared/blocks";
|
|
14
|
+
import { type BlockCtx } from "../context.js";
|
|
15
|
+
import { compareItems } from "./shared.js";
|
|
16
|
+
/** The page size (spec §5.5: paged at 50 rows). */
|
|
17
|
+
export declare const TABLE_PAGE_SIZE = 50;
|
|
18
|
+
/** `table`: aligned, sortable, paged columns; cards at compact width. */
|
|
19
|
+
export declare class TableComponent implements Component {
|
|
20
|
+
#private;
|
|
21
|
+
constructor(block: CollectionBlock, ctx: BlockCtx);
|
|
22
|
+
render(width: number): string[];
|
|
23
|
+
handleInput(data: string): void;
|
|
24
|
+
invalidate(): void;
|
|
25
|
+
}
|
|
26
|
+
export { compareItems };
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `table` display (UI-blocks spec §5.5 + §12.3): display-width
|
|
3
|
+
* aligned columns (CJK-safe via pi-tui's `visibleWidth`), `s` picks a
|
|
4
|
+
* sortable column and cycles asc → desc, 50 rows per page with
|
|
5
|
+
* PgUp/PgDn, and the compact fallback — rows as cards with
|
|
6
|
+
* `priority: "low"` columns behind Enter-expand.
|
|
7
|
+
*
|
|
8
|
+
* Sort and page are view-only (§6.2): they never dispatch. The footer
|
|
9
|
+
* carries the page range ("1–50 of 120"); the sort menu lists the
|
|
10
|
+
* sortable columns only.
|
|
11
|
+
*/
|
|
12
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
import { formatValue } from "@sigrun/shared/blocks";
|
|
14
|
+
import { bindText } from "../context.js";
|
|
15
|
+
import { truncate, widthClassOf } from "../width.js";
|
|
16
|
+
import { meterBar } from "../meter.js";
|
|
17
|
+
import { cardLines } from "./card.js";
|
|
18
|
+
import { CollectionState, collectionItems, compareItems, sortItems, } from "./shared.js";
|
|
19
|
+
/** The page size (spec §5.5: paged at 50 rows). */
|
|
20
|
+
export const TABLE_PAGE_SIZE = 50;
|
|
21
|
+
/** The sort menu entries: the sortable columns' labels. */
|
|
22
|
+
function sortableColumns(block) {
|
|
23
|
+
return (block.columns ?? []).filter((column) => column.sortable === true);
|
|
24
|
+
}
|
|
25
|
+
/** One cell's display text. */
|
|
26
|
+
function cellText(column, row, ctx, width) {
|
|
27
|
+
const value = row.item[column.field];
|
|
28
|
+
if (column.cell === "meter") {
|
|
29
|
+
const level = typeof value === "number" ? value : 0;
|
|
30
|
+
return meterBar(level, Math.max(4, Math.min(10, Math.floor(width / 4))), !ctx.unicode);
|
|
31
|
+
}
|
|
32
|
+
if (value === undefined || value === null)
|
|
33
|
+
return "";
|
|
34
|
+
return formatValue(value, column.format, ctx.locale, ctx.timeZone);
|
|
35
|
+
}
|
|
36
|
+
/** `table`: aligned, sortable, paged columns; cards at compact width. */
|
|
37
|
+
export class TableComponent {
|
|
38
|
+
#block;
|
|
39
|
+
#ctx;
|
|
40
|
+
#state = new CollectionState();
|
|
41
|
+
#sort;
|
|
42
|
+
#page = 0;
|
|
43
|
+
#tick = 0;
|
|
44
|
+
constructor(block, ctx) {
|
|
45
|
+
this.#block = block;
|
|
46
|
+
this.#ctx = ctx;
|
|
47
|
+
this.#sort = block.sort;
|
|
48
|
+
}
|
|
49
|
+
#rows() {
|
|
50
|
+
return sortItems(collectionItems(this.#block, this.#ctx), this.#sort);
|
|
51
|
+
}
|
|
52
|
+
render(width) {
|
|
53
|
+
this.#tick += 1;
|
|
54
|
+
if (widthClassOf(width) === "compact")
|
|
55
|
+
return this.#renderCompact(width);
|
|
56
|
+
return this.#renderFull(width);
|
|
57
|
+
}
|
|
58
|
+
// ─── the full table ──────────────────────────────────────────────
|
|
59
|
+
#renderFull(width) {
|
|
60
|
+
const columns = this.#block.columns ?? [];
|
|
61
|
+
const rows = this.#rows();
|
|
62
|
+
const pages = Math.max(1, Math.ceil(rows.length / TABLE_PAGE_SIZE));
|
|
63
|
+
if (this.#page >= pages)
|
|
64
|
+
this.#page = pages - 1;
|
|
65
|
+
const pageRows = rows.slice(this.#page * TABLE_PAGE_SIZE, (this.#page + 1) * TABLE_PAGE_SIZE);
|
|
66
|
+
if (this.#state.menu?.kind === "sort") {
|
|
67
|
+
const lines = [truncate("Sort by", width)];
|
|
68
|
+
sortableColumns(this.#block).forEach((column, index) => {
|
|
69
|
+
const cursor = index === this.#state.menuCursor ? "> " : " ";
|
|
70
|
+
const arrow = this.#sort?.field === column.field ? (this.#sort.dir === "asc" ? " ▲" : " ▼") : "";
|
|
71
|
+
lines.push(truncate(`${cursor}${bindText(column.label, this.#ctx)}${arrow}`, width));
|
|
72
|
+
});
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
// Column widths: the widest display width of header or cells,
|
|
76
|
+
// clamped so the table fits the pane.
|
|
77
|
+
const labels = columns.map((column) => bindText(column.label, this.#ctx));
|
|
78
|
+
const widths = columns.map((column, index) => {
|
|
79
|
+
let widest = visibleWidth(labels[index] ?? "");
|
|
80
|
+
for (const row of pageRows) {
|
|
81
|
+
widest = Math.max(widest, visibleWidth(cellText(column, row, this.#ctx, width)));
|
|
82
|
+
}
|
|
83
|
+
return widest;
|
|
84
|
+
});
|
|
85
|
+
const gaps = Math.max(0, columns.length - 1) * 2;
|
|
86
|
+
let budget = width - 2 - gaps; // the cursor column + separators
|
|
87
|
+
for (let i = columns.length - 1; i >= 0 && widths.reduce((a, b) => a + b, 0) > budget; i--) {
|
|
88
|
+
widths[i] = Math.max(4, (widths[i] ?? 4) - (widths.reduce((a, b) => a + b, 0) - budget));
|
|
89
|
+
budget = width - 2 - gaps;
|
|
90
|
+
}
|
|
91
|
+
const join = (cells, cursor) => truncate(cursor +
|
|
92
|
+
cells
|
|
93
|
+
.map((cell, index) => {
|
|
94
|
+
const column = columns[index];
|
|
95
|
+
const cellWidth = widths[index] ?? 4;
|
|
96
|
+
const fits = visibleWidth(cell) <= cellWidth;
|
|
97
|
+
const text = fits ? cell : truncate(cell, cellWidth);
|
|
98
|
+
const rest = cellWidth - visibleWidth(text);
|
|
99
|
+
const padStr = rest > 0 ? " ".repeat(rest) : "";
|
|
100
|
+
// numeric formats right-align; text left-aligns.
|
|
101
|
+
return column?.format === "number" && text !== ""
|
|
102
|
+
? padStr + text
|
|
103
|
+
: text + padStr;
|
|
104
|
+
})
|
|
105
|
+
.join(" "), width);
|
|
106
|
+
const lines = [join(labels, " ")];
|
|
107
|
+
for (const row of pageRows) {
|
|
108
|
+
lines.push(join(columns.map((column) => cellText(column, row, this.#ctx, width)), " "));
|
|
109
|
+
}
|
|
110
|
+
const from = this.#page * TABLE_PAGE_SIZE + 1;
|
|
111
|
+
const to = Math.min(rows.length, (this.#page + 1) * TABLE_PAGE_SIZE);
|
|
112
|
+
const range = rows.length === 0 ? "0 of 0" : `${from}–${to} of ${rows.length}`;
|
|
113
|
+
lines.push(truncate(this.#ctx.theme.dim(range), width));
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
// ─── the compact fallback (rows as cards, §12.3) ────────────────
|
|
117
|
+
#renderCompact(width) {
|
|
118
|
+
const rows = this.#rows();
|
|
119
|
+
const lines = [];
|
|
120
|
+
const cursorRow = Math.min(this.#state.cursor, rows.length - 1);
|
|
121
|
+
rows.forEach((row, index) => {
|
|
122
|
+
const cursor = index === cursorRow ? "> " : " ";
|
|
123
|
+
const inner = width - 2;
|
|
124
|
+
const card = cardLines(this.#block.item, row.item, this.#ctx, inner, 0, this.#tick);
|
|
125
|
+
lines.push(truncate(cursor + (card[0] ?? ""), width));
|
|
126
|
+
for (const line of card.slice(1))
|
|
127
|
+
lines.push(truncate(cursor + line, width));
|
|
128
|
+
// High-priority columns ride the card as "label: value" lines.
|
|
129
|
+
for (const column of this.#block.columns ?? []) {
|
|
130
|
+
if (column.priority === "low")
|
|
131
|
+
continue;
|
|
132
|
+
if (column.cell === "meter")
|
|
133
|
+
continue;
|
|
134
|
+
const value = cellText(column, row, this.#ctx, width);
|
|
135
|
+
if (value === "")
|
|
136
|
+
continue;
|
|
137
|
+
lines.push(truncate(`${cursor}${bindText(column.label, this.#ctx)}: ${value}`, width));
|
|
138
|
+
}
|
|
139
|
+
// Low-priority columns sit behind Enter-expand (§12.3).
|
|
140
|
+
if (this.#state.expanded.has(row.id)) {
|
|
141
|
+
for (const column of this.#block.columns ?? []) {
|
|
142
|
+
if (column.priority !== "low")
|
|
143
|
+
continue;
|
|
144
|
+
const value = cellText(column, row, this.#ctx, width);
|
|
145
|
+
if (value === "")
|
|
146
|
+
continue;
|
|
147
|
+
lines.push(truncate(`${cursor}${bindText(column.label, this.#ctx)}: ${value}`, width));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
return lines;
|
|
152
|
+
}
|
|
153
|
+
handleInput(data) {
|
|
154
|
+
if (this.#state.menu?.kind === "sort") {
|
|
155
|
+
const columns = sortableColumns(this.#block);
|
|
156
|
+
if (data === "\x1b") {
|
|
157
|
+
this.#state.menu = null;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (data === "\x1b[B") {
|
|
161
|
+
this.#state.menuCursor = Math.min(columns.length - 1, this.#state.menuCursor + 1);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (data === "\x1b[A") {
|
|
165
|
+
this.#state.menuCursor = Math.max(0, this.#state.menuCursor - 1);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (data === "\r") {
|
|
169
|
+
const column = columns[this.#state.menuCursor];
|
|
170
|
+
if (column !== undefined) {
|
|
171
|
+
// Sorting the same column again flips the direction.
|
|
172
|
+
this.#sort =
|
|
173
|
+
this.#sort?.field === column.field && this.#sort.dir === "asc"
|
|
174
|
+
? { field: column.field, dir: "desc" }
|
|
175
|
+
: { field: column.field, dir: "asc" };
|
|
176
|
+
this.#page = 0;
|
|
177
|
+
}
|
|
178
|
+
this.#state.menu = null;
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (data === "s" && sortableColumns(this.#block).length > 0) {
|
|
183
|
+
this.#state.menu = { kind: "sort" };
|
|
184
|
+
this.#state.menuCursor = 0;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (data === "\x1b[6~") {
|
|
188
|
+
const pages = Math.ceil(this.#rows().length / TABLE_PAGE_SIZE);
|
|
189
|
+
this.#page = Math.min(Math.max(0, pages - 1), this.#page + 1);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (data === "\x1b[5~") {
|
|
193
|
+
this.#page = Math.max(0, this.#page - 1);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const rows = this.#rows();
|
|
197
|
+
if (data === "\x1b[B") {
|
|
198
|
+
this.#state.cursor = Math.min(rows.length - 1, this.#state.cursor + 1);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (data === "\x1b[A") {
|
|
202
|
+
this.#state.cursor = Math.max(0, this.#state.cursor - 1);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (data === "\r") {
|
|
206
|
+
const row = rows[this.#state.cursor];
|
|
207
|
+
if (row === undefined)
|
|
208
|
+
return;
|
|
209
|
+
// Compact: Enter toggles the low-priority expand; full table:
|
|
210
|
+
// Enter activates (onActivate rides the card template's click).
|
|
211
|
+
if (this.#state.expanded.has(row.id))
|
|
212
|
+
this.#state.expanded.delete(row.id);
|
|
213
|
+
else
|
|
214
|
+
this.#state.expanded.add(row.id);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
invalidate() {
|
|
218
|
+
// View state survives re-renders.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
export { compareItems };
|