@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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TUI's chat-block transport (UI-blocks U5 Task 4, spec §8.2): the
|
|
3
|
+
* shared `SurfaceTransport` contract over the chat-block routes
|
|
4
|
+
* (`/v1/conversations/:id/blocks/:entryId`, `…/data?since=`, `POST
|
|
5
|
+
* …/actions`) — the port of the webui's `chatTransport.ts`, addressed
|
|
6
|
+
* by (conversationId, entryId) instead of (slug, id, params).
|
|
7
|
+
*
|
|
8
|
+
* Unlike the TUI's ext transport there is no `X-Sigrun-Stream` watcher
|
|
9
|
+
* registration: chat change events are conversation-scoped (the events
|
|
10
|
+
* route delivers them to every stream whose principal sees the
|
|
11
|
+
* conversation), so `setStreamId` does not exist here. The launcher's
|
|
12
|
+
* events loop hands the conversation-scoped frames to
|
|
13
|
+
* {@link ChatSurfaceHub.dispatch}.
|
|
14
|
+
*/
|
|
15
|
+
import { SigrunApiError } from "../api.js";
|
|
16
|
+
/** The base path of one chat block (`/data` and `/actions` hang off it). */
|
|
17
|
+
function blockPath(options) {
|
|
18
|
+
return `/v1/conversations/${options.conversationId}/blocks/${encodeURIComponent(options.entryId)}`;
|
|
19
|
+
}
|
|
20
|
+
/** Create the TUI's chat-block transport. */
|
|
21
|
+
export function createChatTransport(options) {
|
|
22
|
+
const doFetch = options.fetch ??
|
|
23
|
+
((input, init) => globalThis.fetch(input, init));
|
|
24
|
+
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
25
|
+
const listeners = new Set();
|
|
26
|
+
const headers = () => ({
|
|
27
|
+
accept: "application/json",
|
|
28
|
+
...(options.user === undefined || options.user.trim() === ""
|
|
29
|
+
? {}
|
|
30
|
+
: { "x-sigrun-user": options.user.trim() }),
|
|
31
|
+
...(options.token === undefined || options.token.trim() === ""
|
|
32
|
+
? {}
|
|
33
|
+
: { authorization: `Bearer ${options.token.trim()}` }),
|
|
34
|
+
});
|
|
35
|
+
const get = async (path) => {
|
|
36
|
+
const response = await doFetch(`${baseUrl}${path}`, {
|
|
37
|
+
method: "GET",
|
|
38
|
+
headers: headers(),
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
throw new SigrunApiError("GET", path, response.status, await response.json().catch(() => undefined));
|
|
42
|
+
}
|
|
43
|
+
return (await response.json());
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
load() {
|
|
47
|
+
return get(blockPath(options));
|
|
48
|
+
},
|
|
49
|
+
fetchData(since) {
|
|
50
|
+
const base = `${blockPath(options)}/data`;
|
|
51
|
+
return get(since === undefined ? base : `${base}?since=${String(since)}`);
|
|
52
|
+
},
|
|
53
|
+
async act(body) {
|
|
54
|
+
const response = await doFetch(`${baseUrl}${blockPath(options)}/actions`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { ...headers(), "content-type": "application/json" },
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
});
|
|
59
|
+
return (await response.json());
|
|
60
|
+
},
|
|
61
|
+
upload() {
|
|
62
|
+
// Chat trees forbid upload fields (spec §8.2); reject loudly.
|
|
63
|
+
return Promise.reject(new Error("chat blocks carry no upload fields"));
|
|
64
|
+
},
|
|
65
|
+
image() {
|
|
66
|
+
return Promise.reject(new Error("chat block image route ships with U7"));
|
|
67
|
+
},
|
|
68
|
+
onChanged(listener) {
|
|
69
|
+
listeners.add(listener);
|
|
70
|
+
return () => {
|
|
71
|
+
listeners.delete(listener);
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The TUI's chat-block change router: `attach` creates a transport whose
|
|
78
|
+
* `onChanged` subscribes into this hub under (conversationId, entryId);
|
|
79
|
+
* `dispatch` fans a conversation-scoped `surface.changed` frame out to
|
|
80
|
+
* the matching subscriptions only.
|
|
81
|
+
*/
|
|
82
|
+
export class ChatSurfaceHub {
|
|
83
|
+
#subscriptions = new Set();
|
|
84
|
+
/** Create a transport whose `onChanged` subscribes into this hub. */
|
|
85
|
+
attach(options) {
|
|
86
|
+
const base = createChatTransport(options);
|
|
87
|
+
const hub = this;
|
|
88
|
+
return {
|
|
89
|
+
load: () => base.load(),
|
|
90
|
+
fetchData: (since) => base.fetchData(since),
|
|
91
|
+
act: (body) => base.act(body),
|
|
92
|
+
upload: (body) => base.upload(body),
|
|
93
|
+
image: (src, opts) => base.image(src, opts),
|
|
94
|
+
onChanged(listener) {
|
|
95
|
+
const subscription = {
|
|
96
|
+
conversationId: options.conversationId,
|
|
97
|
+
entryId: options.entryId,
|
|
98
|
+
listener,
|
|
99
|
+
};
|
|
100
|
+
hub.#subscriptions.add(subscription);
|
|
101
|
+
return () => {
|
|
102
|
+
hub.#subscriptions.delete(subscription);
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** Fire every subscription matching the event's (conversationId, entryId). */
|
|
108
|
+
dispatch(event) {
|
|
109
|
+
for (const subscription of this.#subscriptions) {
|
|
110
|
+
if (subscription.conversationId === event.conversationId &&
|
|
111
|
+
subscription.entryId === event.entryId) {
|
|
112
|
+
subscription.listener({ rev: event.rev, treeRev: event.treeRev ?? 0 });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `calendar` display (UI-blocks U2 Task 4, spec §5.5 + §12.3 +
|
|
3
|
+
* §12.4): a month grid at regular/wide (day-rows — the same v1 shape
|
|
4
|
+
* as U1's grid, not a weekday-aligned matrix); compact shows the
|
|
5
|
+
* agenda (§12.3), as does `time.range: "agenda"` at any width.
|
|
6
|
+
*
|
|
7
|
+
* Keyboard (§12.4): `[`/`]` shift the visible month, `t` returns to
|
|
8
|
+
* today — all view-only, nothing dispatches. `m` on an event picks it
|
|
9
|
+
* up; arrows move it by DAY; Enter drops, dispatching `onMove` with
|
|
10
|
+
* `{itemId, to: {start, end}}` — the duration preserved by shifting
|
|
11
|
+
* start AND end by the day delta (the same payload as U1's pointer
|
|
12
|
+
* drop). Esc cancels.
|
|
13
|
+
*
|
|
14
|
+
* The v1 TUI calendar has no pointer create/resize gestures (`onCreate`
|
|
15
|
+
* and `onResize` stay U1-pointer-only affordances; noted in the task
|
|
16
|
+
* report).
|
|
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
|
+
/** The calendar display. */
|
|
22
|
+
export declare class CalendarComponent implements Component {
|
|
23
|
+
#private;
|
|
24
|
+
constructor(block: CollectionBlock, ctx: BlockCtx);
|
|
25
|
+
render(width: number): string[];
|
|
26
|
+
handleInput(data: string): void;
|
|
27
|
+
invalidate(): void;
|
|
28
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `calendar` display (UI-blocks U2 Task 4, spec §5.5 + §12.3 +
|
|
3
|
+
* §12.4): a month grid at regular/wide (day-rows — the same v1 shape
|
|
4
|
+
* as U1's grid, not a weekday-aligned matrix); compact shows the
|
|
5
|
+
* agenda (§12.3), as does `time.range: "agenda"` at any width.
|
|
6
|
+
*
|
|
7
|
+
* Keyboard (§12.4): `[`/`]` shift the visible month, `t` returns to
|
|
8
|
+
* today — all view-only, nothing dispatches. `m` on an event picks it
|
|
9
|
+
* up; arrows move it by DAY; Enter drops, dispatching `onMove` with
|
|
10
|
+
* `{itemId, to: {start, end}}` — the duration preserved by shifting
|
|
11
|
+
* start AND end by the day delta (the same payload as U1's pointer
|
|
12
|
+
* drop). Esc cancels.
|
|
13
|
+
*
|
|
14
|
+
* The v1 TUI calendar has no pointer create/resize gestures (`onCreate`
|
|
15
|
+
* and `onResize` stay U1-pointer-only affordances; noted in the task
|
|
16
|
+
* report).
|
|
17
|
+
*/
|
|
18
|
+
import { truncate, widthClassOf } from "../width.js";
|
|
19
|
+
import { cardLines } from "./card.js";
|
|
20
|
+
import { CollectionState, collectionItems, dispatchActivate, sortItems, } from "./shared.js";
|
|
21
|
+
/** The day key (YYYY-MM-DD) an ISO timestamp falls on. */
|
|
22
|
+
function dayKey(iso) {
|
|
23
|
+
return iso.slice(0, 10);
|
|
24
|
+
}
|
|
25
|
+
/** Shift a day key by `days`. */
|
|
26
|
+
function shiftDay(key, days) {
|
|
27
|
+
const date = new Date(`${key}T00:00:00Z`);
|
|
28
|
+
date.setUTCDate(date.getUTCDate() + days);
|
|
29
|
+
return date.toISOString().slice(0, 10);
|
|
30
|
+
}
|
|
31
|
+
/** Shift an ISO instant by whole days, keeping its clock time. */
|
|
32
|
+
function shiftInstant(iso, days) {
|
|
33
|
+
const date = new Date(iso);
|
|
34
|
+
date.setUTCDate(date.getUTCDate() + days);
|
|
35
|
+
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
36
|
+
}
|
|
37
|
+
/** Today's month key (view-range default; fake timers drive tests). */
|
|
38
|
+
function currentMonth() {
|
|
39
|
+
return new Date().toISOString().slice(0, 7);
|
|
40
|
+
}
|
|
41
|
+
/** Shift a month key by `months`. */
|
|
42
|
+
function shiftMonth(key, months) {
|
|
43
|
+
const [yearRaw, monthRaw] = key.split("-");
|
|
44
|
+
const year = Number(yearRaw);
|
|
45
|
+
const month = Number(monthRaw) - 1 + months;
|
|
46
|
+
return new Date(Date.UTC(year, month, 1)).toISOString().slice(0, 7);
|
|
47
|
+
}
|
|
48
|
+
/** The days a month grid renders (1st through the month's end). */
|
|
49
|
+
function daysInMonth(month) {
|
|
50
|
+
const [yearRaw, monthRaw] = month.split("-");
|
|
51
|
+
const year = Number(yearRaw);
|
|
52
|
+
const m = Number(monthRaw);
|
|
53
|
+
const days = [];
|
|
54
|
+
const date = new Date(Date.UTC(year, m - 1, 1));
|
|
55
|
+
while (date.getUTCMonth() === m - 1) {
|
|
56
|
+
days.push(date.toISOString().slice(0, 10));
|
|
57
|
+
date.setUTCDate(date.getUTCDate() + 1);
|
|
58
|
+
}
|
|
59
|
+
return days;
|
|
60
|
+
}
|
|
61
|
+
/** An item's start/end ISO strings under the block's `time` config. */
|
|
62
|
+
function itemRange(block, item) {
|
|
63
|
+
const time = block.time;
|
|
64
|
+
if (time === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
if (typeof item !== "object" || item === null || Array.isArray(item))
|
|
67
|
+
return undefined;
|
|
68
|
+
const record = item;
|
|
69
|
+
const start = record[time.start];
|
|
70
|
+
if (typeof start !== "string")
|
|
71
|
+
return undefined;
|
|
72
|
+
const end = time.end === undefined ? undefined : record[time.end];
|
|
73
|
+
return { start, end: typeof end === "string" ? end : undefined };
|
|
74
|
+
}
|
|
75
|
+
/** The calendar display. */
|
|
76
|
+
export class CalendarComponent {
|
|
77
|
+
#block;
|
|
78
|
+
#ctx;
|
|
79
|
+
#state = new CollectionState();
|
|
80
|
+
#tick = 0;
|
|
81
|
+
/** The visible month (view-only; `[`/`]`/`t` shift it). */
|
|
82
|
+
#month = currentMonth();
|
|
83
|
+
/** The agenda cursor (a flat item index over the agenda order). */
|
|
84
|
+
#cursor = 0;
|
|
85
|
+
/** The picked-up event (calendar moves use a day placement, not the
|
|
86
|
+
* lanes' group/index placement — the session wrapper tracks it). */
|
|
87
|
+
#held;
|
|
88
|
+
constructor(block, ctx) {
|
|
89
|
+
this.#block = block;
|
|
90
|
+
this.#ctx = ctx;
|
|
91
|
+
}
|
|
92
|
+
/** Every item with a parseable time range, in start order. */
|
|
93
|
+
#events() {
|
|
94
|
+
const rows = [];
|
|
95
|
+
for (const row of sortItems(collectionItems(this.#block, this.#ctx), this.#block.sort)) {
|
|
96
|
+
const range = itemRange(this.#block, row.item);
|
|
97
|
+
if (range === undefined)
|
|
98
|
+
continue;
|
|
99
|
+
rows.push({ row, start: range.start, end: range.end });
|
|
100
|
+
}
|
|
101
|
+
rows.sort((a, b) => a.start.localeCompare(b.start));
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
104
|
+
render(width) {
|
|
105
|
+
this.#tick += 1;
|
|
106
|
+
const compact = widthClassOf(width) === "compact" || this.#block.time?.range === "agenda";
|
|
107
|
+
const events = this.#events();
|
|
108
|
+
if (compact)
|
|
109
|
+
return this.#agenda(width, events);
|
|
110
|
+
return this.#monthGrid(width, events);
|
|
111
|
+
}
|
|
112
|
+
/** The agenda: items sorted by start, one row each. */
|
|
113
|
+
#agenda(width, events) {
|
|
114
|
+
const lines = [this.#ctx.theme.dim(truncate(this.#month, width))];
|
|
115
|
+
events.forEach((event, index) => {
|
|
116
|
+
const cursor = index === this.#cursor ? "> " : " ";
|
|
117
|
+
const held = this.#held?.itemId === event.row.id;
|
|
118
|
+
const mark = held ? (this.#ctx.unicode ? "✋ " : "* ") : "";
|
|
119
|
+
const card = cardLines(this.#block.item, event.row.item, this.#ctx, width - cursor.length - mark.length - 12, 0, this.#tick);
|
|
120
|
+
const when = `${event.start.slice(0, 10)} `;
|
|
121
|
+
const [first, ...rest] = card;
|
|
122
|
+
lines.push(truncate(`${cursor}${mark}${when}${first ?? ""}`, width));
|
|
123
|
+
for (const line of rest) {
|
|
124
|
+
lines.push(truncate(`${cursor}${" ".repeat(mark.length + when.length)}${line}`, width));
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
return lines;
|
|
128
|
+
}
|
|
129
|
+
/** The month grid: one row per day, events under their day. */
|
|
130
|
+
#monthGrid(width, events) {
|
|
131
|
+
const byDay = new Map();
|
|
132
|
+
for (const event of events) {
|
|
133
|
+
const day = dayKey(event.start);
|
|
134
|
+
const list = byDay.get(day) ?? [];
|
|
135
|
+
list.push(event);
|
|
136
|
+
byDay.set(day, list);
|
|
137
|
+
}
|
|
138
|
+
const lines = [this.#ctx.theme.bold(truncate(this.#month, width))];
|
|
139
|
+
for (const day of daysInMonth(this.#month)) {
|
|
140
|
+
const todays = byDay.get(day) ?? [];
|
|
141
|
+
const heldHere = this.#held !== undefined && this.#held.at === day;
|
|
142
|
+
const marker = heldHere ? (this.#ctx.unicode ? " ⌄" : " v") : "";
|
|
143
|
+
lines.push(truncate(`${day.slice(8)}${marker}`, width));
|
|
144
|
+
for (const event of todays) {
|
|
145
|
+
const held = this.#held?.itemId === event.row.id;
|
|
146
|
+
const mark = held ? (this.#ctx.unicode ? "✋ " : "* ") : "";
|
|
147
|
+
const card = cardLines(this.#block.item, event.row.item, this.#ctx, width - 2 - mark.length, 0, this.#tick);
|
|
148
|
+
const [first, ...rest] = card;
|
|
149
|
+
lines.push(truncate(` ${mark}${first ?? ""}`, width));
|
|
150
|
+
for (const line of rest) {
|
|
151
|
+
lines.push(truncate(` ${" ".repeat(mark.length)}${line}`, width));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
handleInput(data) {
|
|
158
|
+
// Range keys are always view-only (§12.4).
|
|
159
|
+
if (data === "[") {
|
|
160
|
+
this.#month = shiftMonth(this.#month, -1);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (data === "]") {
|
|
164
|
+
this.#month = shiftMonth(this.#month, 1);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (data === "t") {
|
|
168
|
+
this.#month = currentMonth();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const events = this.#events();
|
|
172
|
+
// A pick-up in flight: arrows move the event by DAY.
|
|
173
|
+
const held = this.#held;
|
|
174
|
+
if (held !== undefined) {
|
|
175
|
+
if (data === "\x1b") {
|
|
176
|
+
this.#held = undefined;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
let delta = 0;
|
|
180
|
+
if (data === "\x1b[C" || data === "\x1b[B")
|
|
181
|
+
delta = 1;
|
|
182
|
+
if (data === "\x1b[D" || data === "\x1b[A")
|
|
183
|
+
delta = -1;
|
|
184
|
+
if (delta !== 0) {
|
|
185
|
+
this.#held = { ...held, at: shiftDay(held.at, delta) };
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (data === "\r") {
|
|
189
|
+
this.#held = undefined;
|
|
190
|
+
const days = (Date.parse(`${held.at}T00:00:00Z`) -
|
|
191
|
+
Date.parse(`${dayKey(held.start)}T00:00:00Z`)) /
|
|
192
|
+
86_400_000;
|
|
193
|
+
if (days === 0)
|
|
194
|
+
return;
|
|
195
|
+
const onMove = this.#block.onMove;
|
|
196
|
+
if (onMove === undefined)
|
|
197
|
+
return;
|
|
198
|
+
const start = shiftInstant(held.start, days);
|
|
199
|
+
const end = held.end === undefined ? undefined : shiftInstant(held.end, days);
|
|
200
|
+
const to = end === undefined ? { start } : { start, end };
|
|
201
|
+
this.#ctx.dispatch({ type: "move", name: onMove }, { itemId: held.itemId, to });
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (this.#cursor >= events.length)
|
|
206
|
+
this.#cursor = Math.max(0, events.length - 1);
|
|
207
|
+
const current = events[this.#cursor];
|
|
208
|
+
if (data === "\x1b[B") {
|
|
209
|
+
this.#cursor = Math.min(events.length - 1, this.#cursor + 1);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (data === "\x1b[A") {
|
|
213
|
+
this.#cursor = Math.max(0, this.#cursor - 1);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (data === "m" && current !== undefined && this.#block.onMove !== undefined) {
|
|
217
|
+
this.#held = {
|
|
218
|
+
itemId: current.row.id,
|
|
219
|
+
start: current.start,
|
|
220
|
+
end: current.end,
|
|
221
|
+
at: dayKey(current.start),
|
|
222
|
+
};
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (data === "\r" && current !== undefined) {
|
|
226
|
+
dispatchActivate(this.#block, this.#ctx, current.row);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
invalidate() {
|
|
230
|
+
// State (range, cursor, the pick-up) survives re-renders.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The card template (UI-blocks spec §5.5.1) as terminal lines: a FIXED
|
|
3
|
+
* line layout, every slot optional except `title`, each slot resolved
|
|
4
|
+
* against the item (`./` pointers).
|
|
5
|
+
*
|
|
6
|
+
* Layout, top to bottom:
|
|
7
|
+
* 1. eyebrow line (dimmed small text)
|
|
8
|
+
* 2. title line: icon glyph, tone marker, title (title carries the
|
|
9
|
+
* tone's colour through TuiTheme)
|
|
10
|
+
* 3. subtitle (one line)
|
|
11
|
+
* 4. body, wrapped and clamped to 4 lines (spec: lists and lanes clamp;
|
|
12
|
+
* the TUI clamps in every display — a 100-cell grid card cannot
|
|
13
|
+
* afford a full body either, and `detail` panes are Task 4's)
|
|
14
|
+
* 5. badges + meta on ONE line (badges first, then meta facts)
|
|
15
|
+
* 6. metrics, one per line (`label: value`, `formatValue` formatted)
|
|
16
|
+
* 7. status: spinner frame (when `spinner` binds true) + tone marker +
|
|
17
|
+
* label — the frame ticks per render (`spinnerFrame`)
|
|
18
|
+
* 8. deps as `after: id, id, …`
|
|
19
|
+
*
|
|
20
|
+
* The card `tone` marker also leads the title; with no tone binding the
|
|
21
|
+
* marker is the neutral one so lines align.
|
|
22
|
+
*/
|
|
23
|
+
import type { Card, JsonValue } from "@sigrun/shared/blocks";
|
|
24
|
+
import { type BlockCtx } from "../context.js";
|
|
25
|
+
/** The body clamp (spec §5.5.1: 4 lines in lists and lanes). */
|
|
26
|
+
export declare const CARD_BODY_LINES = 4;
|
|
27
|
+
/** The spinner frame for render `tick` (a render-indexed clock: the TUI
|
|
28
|
+
* has no card-local timers, so a spinning status advances as renders
|
|
29
|
+
* arrive — the same convention ambient rows use). */
|
|
30
|
+
export declare function spinnerFrame(tick: number, ascii: boolean): string;
|
|
31
|
+
/**
|
|
32
|
+
* The card's lines for `item` at `width`, indented `indent` cells.
|
|
33
|
+
* `tick` indexes the spinner frame (the owning component's render
|
|
34
|
+
* count). Lines are display-width truncated, never wider than the pane.
|
|
35
|
+
*/
|
|
36
|
+
export declare function cardLines(card: Card, item: JsonValue, ctx: BlockCtx, width: number, indent: number, tick: number): string[];
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The card template (UI-blocks spec §5.5.1) as terminal lines: a FIXED
|
|
3
|
+
* line layout, every slot optional except `title`, each slot resolved
|
|
4
|
+
* against the item (`./` pointers).
|
|
5
|
+
*
|
|
6
|
+
* Layout, top to bottom:
|
|
7
|
+
* 1. eyebrow line (dimmed small text)
|
|
8
|
+
* 2. title line: icon glyph, tone marker, title (title carries the
|
|
9
|
+
* tone's colour through TuiTheme)
|
|
10
|
+
* 3. subtitle (one line)
|
|
11
|
+
* 4. body, wrapped and clamped to 4 lines (spec: lists and lanes clamp;
|
|
12
|
+
* the TUI clamps in every display — a 100-cell grid card cannot
|
|
13
|
+
* afford a full body either, and `detail` panes are Task 4's)
|
|
14
|
+
* 5. badges + meta on ONE line (badges first, then meta facts)
|
|
15
|
+
* 6. metrics, one per line (`label: value`, `formatValue` formatted)
|
|
16
|
+
* 7. status: spinner frame (when `spinner` binds true) + tone marker +
|
|
17
|
+
* label — the frame ticks per render (`spinnerFrame`)
|
|
18
|
+
* 8. deps as `after: id, id, …`
|
|
19
|
+
*
|
|
20
|
+
* The card `tone` marker also leads the title; with no tone binding the
|
|
21
|
+
* marker is the neutral one so lines align.
|
|
22
|
+
*/
|
|
23
|
+
import { formatValue } from "@sigrun/shared/blocks";
|
|
24
|
+
import { bindRaw, bindText, bindTone } from "../context.js";
|
|
25
|
+
import { iconGlyph } from "../icons.js";
|
|
26
|
+
import { toneFg, toneMarker } from "../tones.js";
|
|
27
|
+
import { truncate, wrapPlain } from "../width.js";
|
|
28
|
+
/** The body clamp (spec §5.5.1: 4 lines in lists and lanes). */
|
|
29
|
+
export const CARD_BODY_LINES = 4;
|
|
30
|
+
/** The braille spinner frames (ASCII: the classic four-bar cycle). */
|
|
31
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
32
|
+
const SPINNER_FRAMES_ASCII = ["*", "+", "x", "+"];
|
|
33
|
+
/** The spinner frame for render `tick` (a render-indexed clock: the TUI
|
|
34
|
+
* has no card-local timers, so a spinning status advances as renders
|
|
35
|
+
* arrive — the same convention ambient rows use). */
|
|
36
|
+
export function spinnerFrame(tick, ascii) {
|
|
37
|
+
const frames = ascii ? SPINNER_FRAMES_ASCII : SPINNER_FRAMES;
|
|
38
|
+
return frames[tick % frames.length] ?? frames[0] ?? "*";
|
|
39
|
+
}
|
|
40
|
+
/** One meta entry resolved to its display text ("" = omit). */
|
|
41
|
+
function metaText(entry, ctx, item) {
|
|
42
|
+
if ("person" in entry) {
|
|
43
|
+
// The extension supplies the display name; `user` is the handle
|
|
44
|
+
// and is not shown (spec §5.5.1).
|
|
45
|
+
return bindText(entry.person.name, ctx);
|
|
46
|
+
}
|
|
47
|
+
if ("time" in entry) {
|
|
48
|
+
const at = bindRaw(entry.time, ctx);
|
|
49
|
+
return at === undefined ? "" : formatValue(at, entry.format, ctx.locale, ctx.timeZone);
|
|
50
|
+
}
|
|
51
|
+
return bindText(entry.text, ctx);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The card's lines for `item` at `width`, indented `indent` cells.
|
|
55
|
+
* `tick` indexes the spinner frame (the owning component's render
|
|
56
|
+
* count). Lines are display-width truncated, never wider than the pane.
|
|
57
|
+
*/
|
|
58
|
+
export function cardLines(card, item, ctx, width, indent, tick) {
|
|
59
|
+
const pad = " ".repeat(indent);
|
|
60
|
+
const inner = Math.max(1, width - indent);
|
|
61
|
+
const itemCtx = { ...ctx, item };
|
|
62
|
+
const lines = [];
|
|
63
|
+
const clamp = (line) => truncate(line, inner);
|
|
64
|
+
// 1. eyebrow
|
|
65
|
+
const eyebrow = bindText(card.eyebrow, itemCtx);
|
|
66
|
+
if (eyebrow !== "")
|
|
67
|
+
lines.push(pad + clamp(ctx.theme.dim(eyebrow)));
|
|
68
|
+
// 2. title: icon, tone marker, toned title text.
|
|
69
|
+
const tone = bindTone(card.tone, itemCtx);
|
|
70
|
+
const title = bindText(card.title, itemCtx);
|
|
71
|
+
const icon = card.icon === undefined ? "" : `${iconGlyph(bindText(card.icon, itemCtx), !ctx.unicode)} `;
|
|
72
|
+
const marker = `${toneMarker(tone, !ctx.unicode)} `;
|
|
73
|
+
lines.push(pad + clamp(`${icon}${marker}${toneFg(ctx.theme, tone)(title)}`));
|
|
74
|
+
// 3. subtitle
|
|
75
|
+
const subtitle = bindText(card.subtitle, itemCtx);
|
|
76
|
+
if (subtitle !== "")
|
|
77
|
+
lines.push(pad + clamp(subtitle));
|
|
78
|
+
// 4. body, clamped to CARD_BODY_LINES.
|
|
79
|
+
const body = bindText(card.body, itemCtx);
|
|
80
|
+
if (body !== "") {
|
|
81
|
+
for (const line of wrapPlain(body, inner).slice(0, CARD_BODY_LINES)) {
|
|
82
|
+
lines.push(pad + line);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// 5. badges + meta, one line.
|
|
86
|
+
const parts = [];
|
|
87
|
+
for (const badge of card.badges ?? []) {
|
|
88
|
+
const text = bindText(badge.text, itemCtx);
|
|
89
|
+
if (text === "")
|
|
90
|
+
continue;
|
|
91
|
+
const badgeTone = bindTone(badge.tone, itemCtx);
|
|
92
|
+
parts.push(`[${toneFg(ctx.theme, badgeTone)(text)}]`);
|
|
93
|
+
}
|
|
94
|
+
const metaParts = (card.meta ?? [])
|
|
95
|
+
.map((entry) => metaText(entry, itemCtx, item))
|
|
96
|
+
.filter((text) => text !== "");
|
|
97
|
+
if (metaParts.length > 0)
|
|
98
|
+
parts.push(metaParts.join(" · "));
|
|
99
|
+
if (parts.length > 0)
|
|
100
|
+
lines.push(pad + clamp(parts.join(" ")));
|
|
101
|
+
// 6. metrics, one per line.
|
|
102
|
+
for (const metric of card.metrics ?? []) {
|
|
103
|
+
const label = bindText(metric.label, itemCtx);
|
|
104
|
+
const value = bindRaw(metric.value, itemCtx);
|
|
105
|
+
if (label === "" || value === undefined)
|
|
106
|
+
continue;
|
|
107
|
+
lines.push(pad + clamp(`${label}: ${formatValue(value, metric.format, ctx.locale, ctx.timeZone)}`));
|
|
108
|
+
}
|
|
109
|
+
// 7. status (spinner frame + tone marker + label).
|
|
110
|
+
if (card.status !== undefined) {
|
|
111
|
+
const label = bindText(card.status.label, itemCtx);
|
|
112
|
+
if (label !== "") {
|
|
113
|
+
const statusTone = bindTone(card.status.tone, itemCtx);
|
|
114
|
+
const spin = bindRaw(card.status.spinner, itemCtx) === true;
|
|
115
|
+
const frame = spin ? `${spinnerFrame(Math.max(0, tick - 1), !ctx.unicode)} ` : "";
|
|
116
|
+
const statusMarker = `${toneMarker(statusTone, !ctx.unicode)} `;
|
|
117
|
+
lines.push(pad + clamp(`${frame}${statusMarker}${toneFg(ctx.theme, statusTone)(label)}`));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// 8. deps ("after: …").
|
|
121
|
+
const depsRaw = bindRaw(card.deps, itemCtx);
|
|
122
|
+
if (Array.isArray(depsRaw)) {
|
|
123
|
+
const deps = depsRaw.filter((dep) => typeof dep === "string");
|
|
124
|
+
if (deps.length > 0)
|
|
125
|
+
lines.push(pad + clamp(`after: ${deps.join(", ")}`));
|
|
126
|
+
}
|
|
127
|
+
return lines;
|
|
128
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `collection` block (UI-blocks spec §5.5): the display dispatch.
|
|
3
|
+
* Task 3 owns `list`/`table`/`grid`; Task 4 owns `lanes`/`calendar`.
|
|
4
|
+
*/
|
|
5
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { CollectionBlock } from "@sigrun/shared/blocks";
|
|
7
|
+
import type { BlockCtx } from "../context.js";
|
|
8
|
+
/** The display dispatch. */
|
|
9
|
+
export declare function renderCollection(block: CollectionBlock, ctx: BlockCtx): Component | undefined;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `collection` block (UI-blocks spec §5.5): the display dispatch.
|
|
3
|
+
* Task 3 owns `list`/`table`/`grid`; Task 4 owns `lanes`/`calendar`.
|
|
4
|
+
*/
|
|
5
|
+
import { CalendarComponent } from "./calendar.js";
|
|
6
|
+
import { GridComponent } from "./grid.js";
|
|
7
|
+
import { LanesComponent } from "./lanes.js";
|
|
8
|
+
import { ListComponent } from "./list.js";
|
|
9
|
+
import { TableComponent } from "./table.js";
|
|
10
|
+
/** The display dispatch. */
|
|
11
|
+
export function renderCollection(block, ctx) {
|
|
12
|
+
switch (block.display) {
|
|
13
|
+
case "list":
|
|
14
|
+
return new ListComponent(block, ctx);
|
|
15
|
+
case "table":
|
|
16
|
+
return new TableComponent(block, ctx);
|
|
17
|
+
case "grid":
|
|
18
|
+
return new GridComponent(block, ctx);
|
|
19
|
+
case "lanes":
|
|
20
|
+
return new LanesComponent(block, ctx);
|
|
21
|
+
case "calendar":
|
|
22
|
+
return new CalendarComponent(block, ctx);
|
|
23
|
+
default:
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `grid` display (UI-blocks spec §5.5 + §12.3): cards in as many
|
|
3
|
+
* columns as fit — `floor(width / token)` with the token from the item
|
|
4
|
+
* template's `image` presence (`m` = 45 cells with an image, `s` = 30
|
|
5
|
+
* without; §5.5's minItemWidth). `overflow: "scroll"` stays one
|
|
6
|
+
* horizontal row (the row clips at the pane edge; there is no second
|
|
7
|
+
* row). At compact width the fallback is one column (§12.3).
|
|
8
|
+
*/
|
|
9
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { CollectionBlock } from "@sigrun/shared/blocks";
|
|
11
|
+
import { type BlockCtx } from "../context.js";
|
|
12
|
+
/** `grid`: floor(width / token) card columns. */
|
|
13
|
+
export declare class GridComponent implements Component {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(block: CollectionBlock, ctx: BlockCtx);
|
|
16
|
+
render(width: number): string[];
|
|
17
|
+
invalidate(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `grid` display (UI-blocks spec §5.5 + §12.3): cards in as many
|
|
3
|
+
* columns as fit — `floor(width / token)` with the token from the item
|
|
4
|
+
* template's `image` presence (`m` = 45 cells with an image, `s` = 30
|
|
5
|
+
* without; §5.5's minItemWidth). `overflow: "scroll"` stays one
|
|
6
|
+
* horizontal row (the row clips at the pane edge; there is no second
|
|
7
|
+
* row). At compact width the fallback is one column (§12.3).
|
|
8
|
+
*/
|
|
9
|
+
import { pad, truncate, widthClassOf } from "../width.js";
|
|
10
|
+
import { cardLines } from "./card.js";
|
|
11
|
+
import { collectionItems, sortItems } from "./shared.js";
|
|
12
|
+
/** `grid`: floor(width / token) card columns. */
|
|
13
|
+
export class GridComponent {
|
|
14
|
+
#block;
|
|
15
|
+
#ctx;
|
|
16
|
+
#tick = 0;
|
|
17
|
+
constructor(block, ctx) {
|
|
18
|
+
this.#block = block;
|
|
19
|
+
this.#ctx = ctx;
|
|
20
|
+
}
|
|
21
|
+
#rows() {
|
|
22
|
+
return sortItems(collectionItems(this.#block, this.#ctx), this.#block.sort);
|
|
23
|
+
}
|
|
24
|
+
render(width) {
|
|
25
|
+
this.#tick += 1;
|
|
26
|
+
const rows = this.#rows();
|
|
27
|
+
const itemWidth = this.#block.item.image === undefined ? 30 : 45; // s / m token (§5.2)
|
|
28
|
+
let columns = Math.max(1, Math.floor(width / itemWidth));
|
|
29
|
+
if (this.#block.overflow === "scroll") {
|
|
30
|
+
// One row, clipped at the pane edge.
|
|
31
|
+
columns = Math.max(1, columns);
|
|
32
|
+
return this.#renderRow(rows, width, itemWidth).slice(0, this.#rowHeight(rows, itemWidth, width));
|
|
33
|
+
}
|
|
34
|
+
if (widthClassOf(width) === "compact")
|
|
35
|
+
columns = 1;
|
|
36
|
+
const lines = [];
|
|
37
|
+
for (let start = 0; start < rows.length; start += columns) {
|
|
38
|
+
lines.push(...this.#renderRow(rows.slice(start, start + columns), width, itemWidth));
|
|
39
|
+
}
|
|
40
|
+
return lines;
|
|
41
|
+
}
|
|
42
|
+
/** The tallest card of a row, in lines. */
|
|
43
|
+
#rowHeight(rows, itemWidth, width) {
|
|
44
|
+
let height = 0;
|
|
45
|
+
for (const row of rows) {
|
|
46
|
+
height = Math.max(height, cardLines(this.#block.item, row.item, this.#ctx, itemWidth - 2, 0, this.#tick).length);
|
|
47
|
+
}
|
|
48
|
+
void width;
|
|
49
|
+
return height;
|
|
50
|
+
}
|
|
51
|
+
/** One grid row: the cards side by side, each padded to `itemWidth`. */
|
|
52
|
+
#renderRow(rows, width, itemWidth) {
|
|
53
|
+
const perCard = rows.map((row) => cardLines(this.#block.item, row.item, this.#ctx, itemWidth - 2, 0, this.#tick));
|
|
54
|
+
const height = perCard.reduce((max, card) => Math.max(max, card.length), 0);
|
|
55
|
+
const lines = [];
|
|
56
|
+
for (let line = 0; line < height; line++) {
|
|
57
|
+
const cells = perCard.map((card) => pad(card[line] ?? "", itemWidth - 2));
|
|
58
|
+
const joined = cells.join(" ");
|
|
59
|
+
const text = joined.trimEnd() === "" ? "" : joined;
|
|
60
|
+
if (text === "") {
|
|
61
|
+
lines.push("");
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
lines.push(truncate(text.replace(/\s+$/, ""), width));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
|
69
|
+
invalidate() {
|
|
70
|
+
// The grid holds no view state beyond the spinner tick.
|
|
71
|
+
}
|
|
72
|
+
}
|