glimpse-sdk 0.1.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/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/src/app.d.ts +53 -0
- package/dist/src/app.js +187 -0
- package/dist/src/events.d.ts +36 -0
- package/dist/src/events.js +37 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/protocol.d.ts +43 -0
- package/dist/src/protocol.js +58 -0
- package/dist/src/widgets.d.ts +351 -0
- package/dist/src/widgets.js +626 -0
- package/package.json +53 -0
- package/src/app.ts +241 -0
- package/src/events.ts +85 -0
- package/src/index.ts +57 -0
- package/src/protocol.ts +73 -0
- package/src/widgets.ts +820 -0
package/src/app.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type CallbackEvent,
|
|
5
|
+
type ChangeEvent,
|
|
6
|
+
type ClickEvent,
|
|
7
|
+
type InitEvent,
|
|
8
|
+
type InputEvent,
|
|
9
|
+
type PopoverEvent,
|
|
10
|
+
type ScrollEvent,
|
|
11
|
+
type ToggleEvent,
|
|
12
|
+
parseCallbackEvent,
|
|
13
|
+
parseInitEvent,
|
|
14
|
+
} from "./events.js";
|
|
15
|
+
import { StatusItem } from "./protocol.js";
|
|
16
|
+
import { type TreeNode } from "./widgets.js";
|
|
17
|
+
|
|
18
|
+
type Handler<EventT> = (event: EventT) => void | Promise<void>;
|
|
19
|
+
|
|
20
|
+
interface OutgoingMessage {
|
|
21
|
+
command: string;
|
|
22
|
+
data: unknown;
|
|
23
|
+
line: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class RenderResult {
|
|
27
|
+
constructor(
|
|
28
|
+
public readonly options: {
|
|
29
|
+
status?: StatusItem[];
|
|
30
|
+
tree?: TreeNode | null;
|
|
31
|
+
} = {},
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
get status(): StatusItem[] {
|
|
35
|
+
return this.options.status ?? [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get tree(): TreeNode | null {
|
|
39
|
+
return this.options.tree ?? null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export abstract class Applet<State extends object> {
|
|
44
|
+
state: State;
|
|
45
|
+
|
|
46
|
+
private readonly handlerMap = new Map<string, Handler<CallbackEvent>>();
|
|
47
|
+
private readonly outgoing: OutgoingMessage[] = [];
|
|
48
|
+
private flushPromise: Promise<void> | null = null;
|
|
49
|
+
private renderQueued = false;
|
|
50
|
+
private lastStatus: unknown[] | null = null;
|
|
51
|
+
private lastTree: Record<string, unknown> | null = null;
|
|
52
|
+
private popoverOpen = false;
|
|
53
|
+
|
|
54
|
+
protected constructor() {
|
|
55
|
+
this.state = this.initialState();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
protected abstract initialState(): State;
|
|
59
|
+
|
|
60
|
+
protected async onStart(): Promise<void> {}
|
|
61
|
+
|
|
62
|
+
protected async onInit(_event: InitEvent): Promise<void> {}
|
|
63
|
+
|
|
64
|
+
protected async onCallback(_event: CallbackEvent): Promise<void> {}
|
|
65
|
+
|
|
66
|
+
protected async render(): Promise<RenderResult> {
|
|
67
|
+
return new RenderResult();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async setState(patch: Partial<State>): Promise<void> {
|
|
71
|
+
this.state = { ...this.state, ...patch };
|
|
72
|
+
await this.scheduleRender();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
onClick(id: string, handler: Handler<ClickEvent>): void {
|
|
76
|
+
this.register("click", id, handler as Handler<CallbackEvent>);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onScroll(id: string, handler: Handler<ScrollEvent>): void {
|
|
80
|
+
this.register("scroll", id, handler as Handler<CallbackEvent>);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
onInput(id: string, handler: Handler<InputEvent>): void {
|
|
84
|
+
this.register("input", id, handler as Handler<CallbackEvent>);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
onChange(id: string, handler: Handler<ChangeEvent>): void {
|
|
88
|
+
this.register("change", id, handler as Handler<CallbackEvent>);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onToggle(id: string, handler: Handler<ToggleEvent>): void {
|
|
92
|
+
this.register("toggle", id, handler as Handler<CallbackEvent>);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
isPopoverOpen(): boolean {
|
|
96
|
+
return this.popoverOpen;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async run(): Promise<void> {
|
|
100
|
+
process.stdout.on("error", (err: NodeJS.ErrnoException) => {
|
|
101
|
+
if (err.code === "EPIPE") {
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await this.onStart();
|
|
107
|
+
await this.scheduleRender();
|
|
108
|
+
|
|
109
|
+
const rl = createInterface({
|
|
110
|
+
input: process.stdin,
|
|
111
|
+
crlfDelay: Infinity,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
for await (const line of rl) {
|
|
115
|
+
if (!line) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
let raw: { command: string; data: unknown } | null;
|
|
119
|
+
try {
|
|
120
|
+
raw = parseLine(line);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
process.stderr.write(`glimpse-sdk: ignoring malformed input: ${err}\n`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (raw === null) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const data = raw.data as Record<string, unknown>;
|
|
129
|
+
try {
|
|
130
|
+
await this.handleIncoming(raw.command, data);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
process.stderr.write(`glimpse-sdk: error handling input: ${err}\n`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
protected async drainOutgoingForTest(): Promise<OutgoingMessage[]> {
|
|
138
|
+
await this.scheduleRender();
|
|
139
|
+
const drained = [...this.outgoing];
|
|
140
|
+
this.outgoing.length = 0;
|
|
141
|
+
return drained;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private register(event: string, id: string, handler: Handler<CallbackEvent>): void {
|
|
145
|
+
this.handlerMap.set(`${event}:${id}`, handler);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async dispatchCallback(event: CallbackEvent): Promise<void> {
|
|
149
|
+
const handler = this.handlerMap.get(`${event.event}:${event.id}`);
|
|
150
|
+
if (handler !== undefined) {
|
|
151
|
+
await handler(event);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
await this.onCallback(event);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async handleIncoming(type: string, data: Record<string, unknown>): Promise<void> {
|
|
158
|
+
if (type === "init") {
|
|
159
|
+
await this.onInit(parseInitEvent(data));
|
|
160
|
+
await this.scheduleRender();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (type === "event") {
|
|
164
|
+
const event = parseCallbackEvent(data);
|
|
165
|
+
if (isPopoverEvent(event)) {
|
|
166
|
+
this.popoverOpen = event.open;
|
|
167
|
+
}
|
|
168
|
+
await this.dispatchCallback(event);
|
|
169
|
+
await this.scheduleRender();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async scheduleRender(): Promise<void> {
|
|
174
|
+
this.renderQueued = true;
|
|
175
|
+
if (this.flushPromise === null) {
|
|
176
|
+
this.flushPromise = Promise.resolve().then(async () => {
|
|
177
|
+
try {
|
|
178
|
+
while (this.renderQueued) {
|
|
179
|
+
this.renderQueued = false;
|
|
180
|
+
await this.flushRender();
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
this.flushPromise = null;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
await this.flushPromise;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async flushRender(): Promise<void> {
|
|
191
|
+
const rendered = await this.render();
|
|
192
|
+
const status = rendered.status.map((item) => item.toProtocol());
|
|
193
|
+
const tree = { root: rendered.tree?.toProtocol() ?? null };
|
|
194
|
+
|
|
195
|
+
if (!deepEqual(status, this.lastStatus)) {
|
|
196
|
+
this.lastStatus = status;
|
|
197
|
+
this.emit("status", { items: status });
|
|
198
|
+
}
|
|
199
|
+
const publishPopover = this.popoverOpen || this.lastTree === null || tree.root === null;
|
|
200
|
+
if (publishPopover && !deepEqual(tree, this.lastTree)) {
|
|
201
|
+
this.lastTree = tree;
|
|
202
|
+
this.emit("popover", tree);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private emit(command: string, data: unknown): void {
|
|
207
|
+
const line = `${command} ${JSON.stringify(data)}`;
|
|
208
|
+
this.outgoing.push({ command, data, line });
|
|
209
|
+
try {
|
|
210
|
+
process.stdout.write(`${line}\n`);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
213
|
+
if (code !== "EPIPE") {
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parseLine(line: string): { command: string; data: unknown } | null {
|
|
221
|
+
const trimmed = line.trim();
|
|
222
|
+
if (trimmed === "") {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
const split = trimmed.search(/\s/);
|
|
226
|
+
if (split < 0) {
|
|
227
|
+
throw new Error("missing command payload");
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
command: trimmed.slice(0, split),
|
|
231
|
+
data: JSON.parse(trimmed.slice(split).trimStart()),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function deepEqual(left: unknown, right: unknown): boolean {
|
|
236
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isPopoverEvent(event: CallbackEvent): event is PopoverEvent {
|
|
240
|
+
return event.event === "open" || event.event === "close";
|
|
241
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export interface InitEvent {
|
|
2
|
+
instance: string;
|
|
3
|
+
options: Record<string, unknown>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface CallbackEventBase {
|
|
7
|
+
id: string;
|
|
8
|
+
event: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ClickEvent extends CallbackEventBase {
|
|
12
|
+
event: "click";
|
|
13
|
+
button?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ScrollEvent extends CallbackEventBase {
|
|
17
|
+
event: "scroll";
|
|
18
|
+
delta_y?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface InputEvent extends CallbackEventBase {
|
|
22
|
+
event: "input";
|
|
23
|
+
text: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ChangeEvent extends CallbackEventBase {
|
|
27
|
+
event: "change";
|
|
28
|
+
value: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ToggleEvent extends CallbackEventBase {
|
|
32
|
+
event: "toggle";
|
|
33
|
+
value: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PopoverEvent extends CallbackEventBase {
|
|
37
|
+
event: "open" | "close";
|
|
38
|
+
open: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type CallbackEvent =
|
|
42
|
+
| ClickEvent
|
|
43
|
+
| ScrollEvent
|
|
44
|
+
| InputEvent
|
|
45
|
+
| ChangeEvent
|
|
46
|
+
| ToggleEvent
|
|
47
|
+
| PopoverEvent;
|
|
48
|
+
|
|
49
|
+
export function parseInitEvent(payload: Record<string, unknown>): InitEvent {
|
|
50
|
+
return {
|
|
51
|
+
instance: String(payload.instance ?? ""),
|
|
52
|
+
options: (payload.options ?? {}) as Record<string, unknown>,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseCallbackEvent(payload: Record<string, unknown>): CallbackEvent {
|
|
57
|
+
const event = String(payload.type ?? payload.event ?? "");
|
|
58
|
+
const id = String(payload.id ?? "");
|
|
59
|
+
if (event === "click") {
|
|
60
|
+
return { id, event, button: payload.button === undefined ? undefined : String(payload.button) };
|
|
61
|
+
}
|
|
62
|
+
if (event === "scroll") {
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
event,
|
|
66
|
+
delta_y: typeof payload.delta_y === "number" ? payload.delta_y : undefined,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (event === "input") {
|
|
70
|
+
return { id, event, text: String(payload.text ?? "") };
|
|
71
|
+
}
|
|
72
|
+
if (event === "toggle") {
|
|
73
|
+
let value = false;
|
|
74
|
+
if (typeof payload.active === "boolean") {
|
|
75
|
+
value = payload.active;
|
|
76
|
+
} else if (typeof payload.value === "boolean") {
|
|
77
|
+
value = payload.value;
|
|
78
|
+
}
|
|
79
|
+
return { id, event, value };
|
|
80
|
+
}
|
|
81
|
+
if (event === "open" || event === "close") {
|
|
82
|
+
return { id, event, open: event === "open" };
|
|
83
|
+
}
|
|
84
|
+
return { id, event: "change", value: payload.value };
|
|
85
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export { Applet, RenderResult } from "./app.js";
|
|
2
|
+
export {
|
|
3
|
+
type CallbackEvent,
|
|
4
|
+
type ChangeEvent,
|
|
5
|
+
type ClickEvent,
|
|
6
|
+
type InitEvent,
|
|
7
|
+
type InputEvent,
|
|
8
|
+
type PopoverEvent,
|
|
9
|
+
type ScrollEvent,
|
|
10
|
+
type ToggleEvent,
|
|
11
|
+
parseCallbackEvent,
|
|
12
|
+
parseInitEvent,
|
|
13
|
+
} from "./events.js";
|
|
14
|
+
export { Icon, MenuItem, StatusItem } from "./protocol.js";
|
|
15
|
+
export {
|
|
16
|
+
ActionMenu,
|
|
17
|
+
ActionMenuItem,
|
|
18
|
+
ActionRow,
|
|
19
|
+
type Align,
|
|
20
|
+
Badge,
|
|
21
|
+
Box,
|
|
22
|
+
Button,
|
|
23
|
+
Card,
|
|
24
|
+
Checkbox,
|
|
25
|
+
Collapsible,
|
|
26
|
+
CollapsibleItem,
|
|
27
|
+
Column,
|
|
28
|
+
Copyable,
|
|
29
|
+
DetailGrid,
|
|
30
|
+
DetailGridItem,
|
|
31
|
+
Dropdown,
|
|
32
|
+
DropdownItem,
|
|
33
|
+
EmptyState,
|
|
34
|
+
Grid,
|
|
35
|
+
GridChild,
|
|
36
|
+
Header,
|
|
37
|
+
Hero,
|
|
38
|
+
IconWidget,
|
|
39
|
+
Image,
|
|
40
|
+
Item,
|
|
41
|
+
Label,
|
|
42
|
+
Meter,
|
|
43
|
+
type Orientation,
|
|
44
|
+
Progress,
|
|
45
|
+
Row,
|
|
46
|
+
Scale,
|
|
47
|
+
Scroll,
|
|
48
|
+
Section,
|
|
49
|
+
Separator,
|
|
50
|
+
Spinner,
|
|
51
|
+
StatusDot,
|
|
52
|
+
Switch,
|
|
53
|
+
Toast,
|
|
54
|
+
ToastAction,
|
|
55
|
+
type TreeNode,
|
|
56
|
+
type Variant,
|
|
57
|
+
} from "./widgets.js";
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type IconKind = "name" | "path";
|
|
2
|
+
|
|
3
|
+
export class Icon {
|
|
4
|
+
constructor(
|
|
5
|
+
public readonly kind: IconKind,
|
|
6
|
+
public readonly value: string,
|
|
7
|
+
) {}
|
|
8
|
+
|
|
9
|
+
static name(value: string): Icon {
|
|
10
|
+
return new Icon("name", value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static path(value: string): Icon {
|
|
14
|
+
return new Icon("path", value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
toProtocol(): { name: string } | { path: string } {
|
|
18
|
+
return this.kind === "name" ? { name: this.value } : { path: this.value };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class StatusItem {
|
|
23
|
+
constructor(
|
|
24
|
+
public readonly options: {
|
|
25
|
+
id?: string;
|
|
26
|
+
icon?: Icon;
|
|
27
|
+
label?: string;
|
|
28
|
+
tooltip?: string;
|
|
29
|
+
} = {},
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
toProtocol(): Record<string, unknown> {
|
|
33
|
+
const payload: Record<string, unknown> = {};
|
|
34
|
+
if (this.options.id !== undefined) {
|
|
35
|
+
payload.id = this.options.id;
|
|
36
|
+
}
|
|
37
|
+
if (this.options.icon !== undefined) {
|
|
38
|
+
payload.icon = this.options.icon.toProtocol();
|
|
39
|
+
}
|
|
40
|
+
if (this.options.label !== undefined) {
|
|
41
|
+
payload.label = this.options.label;
|
|
42
|
+
}
|
|
43
|
+
if (this.options.tooltip !== undefined) {
|
|
44
|
+
payload.tooltip = this.options.tooltip;
|
|
45
|
+
}
|
|
46
|
+
return payload;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class MenuItem {
|
|
51
|
+
constructor(
|
|
52
|
+
public readonly options: {
|
|
53
|
+
id: string;
|
|
54
|
+
label: string;
|
|
55
|
+
visible?: boolean;
|
|
56
|
+
enabled?: boolean;
|
|
57
|
+
},
|
|
58
|
+
) {}
|
|
59
|
+
|
|
60
|
+
toProtocol(): Record<string, unknown> {
|
|
61
|
+
const payload: Record<string, unknown> = {
|
|
62
|
+
id: this.options.id,
|
|
63
|
+
label: this.options.label,
|
|
64
|
+
};
|
|
65
|
+
if (this.options.visible !== undefined) {
|
|
66
|
+
payload.visible = this.options.visible;
|
|
67
|
+
}
|
|
68
|
+
if (this.options.enabled !== undefined) {
|
|
69
|
+
payload.enabled = this.options.enabled;
|
|
70
|
+
}
|
|
71
|
+
return payload;
|
|
72
|
+
}
|
|
73
|
+
}
|