@alchemy.run/sigil 0.0.0-alpha.4 → 0.0.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -9
- package/THIRD_PARTY_NOTICES.md +39 -1
- package/dist/Text-BobFKi74.d.ts +452 -0
- package/dist/ansi.d.ts +122 -131
- package/dist/ansi.js +86 -6
- package/dist/capabilities.d.ts +5 -0
- package/dist/capabilities.js +3 -0
- package/dist/cell-_ZVhbfl0.js +44 -0
- package/dist/color-CkbalRqK.js +2 -0
- package/dist/color-policy-BMzMwV7Q.d.ts +22 -0
- package/dist/color-policy-SVj1pYTA.js +560 -0
- package/dist/color-profile-DHhQHY55.js +36 -0
- package/dist/color-profile-u0Nhe9Nv.d.ts +97 -0
- package/dist/color.d.ts +21 -0
- package/dist/color.js +3 -0
- package/dist/cursor-position-D2LAkRG0.d.ts +7 -0
- package/dist/detect-Bh4yGP6w.d.ts +186 -0
- package/dist/detect-BuTXtY6e.js +373 -0
- package/dist/env-YVw64yZS.js +9 -0
- package/dist/escapes-CB_6CWOE.d.ts +72 -0
- package/dist/geometry-BxXOzJgo.d.ts +11 -0
- package/dist/index-DZ88EXJv.d.ts +21 -0
- package/dist/index.d.ts +143 -498
- package/dist/index.js +1026 -2244
- package/dist/osc-CCH7xDoS.js +71 -0
- package/dist/osc-Cn0fw77g.d.ts +23 -0
- package/dist/paint-C19minOS.d.ts +81 -0
- package/dist/query-vaIeGOkH.d.ts +152 -0
- package/dist/router.d.ts +392 -0
- package/dist/router.js +709 -0
- package/dist/sample-Cqw1bjUL.js +445 -0
- package/dist/screen-BOSLQ8fF.d.ts +49 -0
- package/dist/screen-CiPytswf.js +342 -0
- package/dist/screen.d.ts +5 -0
- package/dist/screen.js +5 -0
- package/dist/semantic-text-style-DIMzC7xt.js +91 -0
- package/dist/serialize-BTkAZgw1.js +79 -0
- package/dist/session-aZr9O8h3.js +665 -0
- package/dist/sgr-BhwaWAJB.js +246 -0
- package/dist/store-CgrG9K4y.d.ts +72 -0
- package/dist/string-width-CijQwpIk.js +69 -0
- package/dist/strip-BvU4toXG.js +6 -0
- package/dist/terminal.d.ts +118 -0
- package/dist/terminal.js +2 -0
- package/dist/tokenize-AjqbvtiT.js +1242 -0
- package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
- package/dist/truncate-D31fhU6i.js +562 -0
- package/dist/use-focus-BzqAJi0n.js +1337 -0
- package/package.json +41 -9
- package/src/ansi/chalk.ts +5 -3
- package/src/ansi/escapes.ts +14 -0
- package/src/ansi/graphemes.ts +8 -0
- package/src/ansi/hyperlink.ts +44 -0
- package/src/ansi/index.ts +3 -1
- package/src/ansi/osc.ts +77 -0
- package/src/ansi/tokenize.ts +3 -4
- package/src/capabilities/color-policy.ts +34 -0
- package/src/capabilities/detect.ts +594 -0
- package/src/capabilities/index.ts +37 -0
- package/src/capabilities/query.ts +657 -0
- package/src/capabilities/store.ts +379 -0
- package/src/color/index.ts +3 -0
- package/src/color/paint.ts +169 -0
- package/src/color/palette.ts +48 -0
- package/src/color/sample.ts +323 -0
- package/src/color.ts +1 -0
- package/src/components/AnsiText.tsx +42 -0
- package/src/components/App.tsx +98 -10
- package/src/components/BackgroundContext.ts +2 -3
- package/src/components/Box.tsx +0 -8
- package/src/components/CursorContext.ts +1 -1
- package/src/components/Hyperlink.tsx +56 -0
- package/src/components/TerminalOscContext.ts +25 -0
- package/src/components/Text.tsx +22 -45
- package/src/components/Transform.tsx +1 -1
- package/src/dom.ts +11 -2
- package/src/global.d.ts +3 -0
- package/src/hooks/use-capabilities.ts +73 -0
- package/src/hooks/use-cursor.ts +2 -2
- package/src/hooks/use-terminal-osc.ts +59 -0
- package/src/index.ts +45 -1
- package/src/ink.tsx +213 -153
- package/src/{render-node-to-output.ts → paint-tree.ts} +75 -48
- package/src/reconciler.ts +24 -3
- package/src/render-background.ts +36 -15
- package/src/render-border.ts +94 -61
- package/src/render-frame.ts +83 -0
- package/src/render-to-string.ts +21 -6
- package/src/render.ts +15 -6
- package/src/router/components.tsx +343 -0
- package/src/router/context.ts +41 -0
- package/src/router/history.ts +194 -0
- package/src/router/hooks.tsx +391 -0
- package/src/router/index.ts +34 -0
- package/src/router/matcher.ts +571 -0
- package/src/screen/ansi.ts +184 -0
- package/src/screen/canvas.ts +160 -0
- package/src/screen/cell.ts +138 -0
- package/src/screen/color-profile.ts +47 -0
- package/src/screen/geometry.ts +9 -0
- package/src/screen/index.ts +6 -0
- package/src/screen/screen.ts +305 -0
- package/src/screen/serialize.ts +129 -0
- package/src/screen.ts +1 -0
- package/src/semantic-text-style.ts +118 -0
- package/src/squash-text-nodes.ts +2 -5
- package/src/structured-text.ts +325 -0
- package/src/styles.ts +19 -14
- package/src/terminal/index.ts +2 -0
- package/src/terminal/inline-presenter.ts +120 -0
- package/src/terminal/input.ts +86 -0
- package/src/terminal/render-scheduler.ts +37 -0
- package/src/terminal/screen-presenter.ts +188 -0
- package/src/terminal/session.ts +407 -0
- package/src/terminal.ts +1 -0
- package/src/testing/browser.ts +588 -0
- package/src/testing/emulators.ts +205 -0
- package/src/testing/explorer-app/index.html +12 -0
- package/src/testing/explorer-app/main.ts +381 -0
- package/src/testing/explorer-app/style.css +194 -0
- package/src/testing/explorer-app/tsconfig.json +15 -0
- package/src/testing/explorer-app/vite-env.d.ts +1 -0
- package/src/testing/index.ts +26 -0
- package/src/testing/keys.ts +56 -0
- package/src/testing/live.ts +85 -0
- package/src/testing/matchers.ts +70 -0
- package/src/testing/public.ts +94 -0
- package/src/testing/terminal.ts +349 -0
- package/src/testing/vitest.ts +157 -0
- package/src/transform-adapter.ts +14 -0
- package/src/wrap-text.ts +4 -0
- package/dist/sgr-CMfEpjSk.d.ts +0 -91
- package/dist/truncate-Cr6xVFMa.js +0 -2330
- package/src/ansi/supports-color.ts +0 -207
- package/src/colorize.ts +0 -60
- package/src/log-update.ts +0 -370
- package/src/output.ts +0 -308
- package/src/renderer.ts +0 -73
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// A framework-free capabilities store: a snapshot that stays current, an
|
|
2
|
+
// explicit way to (re-)ask the terminal, and a subscription for changes.
|
|
3
|
+
// This is the public API — the `useCapabilities` React hook is a thin
|
|
4
|
+
// wrapper over a store instance.
|
|
5
|
+
//
|
|
6
|
+
// Subscriptions carry everything the terminal can push at us: resizes
|
|
7
|
+
// (stdout `resize` + in-band mode 2048 with pixel geometry), color scheme
|
|
8
|
+
// changes (mode 2031 → `CSI ?997;…n` reports), and window focus (mode 1004
|
|
9
|
+
// → `CSI I`/`CSI O`). The store enables those modes — the supported subset —
|
|
10
|
+
// while it has subscribers and a report feed, and disables them on the last
|
|
11
|
+
// unsubscribe and at process exit.
|
|
12
|
+
import { CSI, ESC } from "#/ansi/escapes.ts";
|
|
13
|
+
import { detectCapabilities, type Capabilities } from "#/capabilities/detect.ts";
|
|
14
|
+
import {
|
|
15
|
+
applyTerminalQuery,
|
|
16
|
+
ensureTerminalQuery,
|
|
17
|
+
getTerminalQuery,
|
|
18
|
+
patchTerminalQuery,
|
|
19
|
+
refreshTerminalQuery,
|
|
20
|
+
type TerminalQueryResult,
|
|
21
|
+
} from "#/capabilities/query.ts";
|
|
22
|
+
import { signalExit } from "#/signal-exit.ts";
|
|
23
|
+
import { type OutputStream } from "#/stream.ts";
|
|
24
|
+
|
|
25
|
+
type QueryRunner = (options?: { refresh?: boolean }) => Promise<TerminalQueryResult | undefined>;
|
|
26
|
+
|
|
27
|
+
type TerminalIntegration = {
|
|
28
|
+
/**
|
|
29
|
+
Runs queries through the host's input pipeline instead of competing with
|
|
30
|
+
it (Ink: raw mode + detached readable listener).
|
|
31
|
+
*/
|
|
32
|
+
runQuery: QueryRunner;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
Called when push reporting turns on/off. The host must keep reading stdin
|
|
36
|
+
(and feeding `ingest`) while active — otherwise the enabled reports would
|
|
37
|
+
never be consumed. Ink holds raw mode for the duration.
|
|
38
|
+
*/
|
|
39
|
+
setReportFeed?: (active: boolean) => void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Ink registers itself here; standalone stores query directly. One
|
|
43
|
+
// integration per stdout stream; its presence also marks that a report feed
|
|
44
|
+
// exists (the host calls `ingest` for every parsed escape sequence).
|
|
45
|
+
const integrations = new WeakMap<object, TerminalIntegration>();
|
|
46
|
+
|
|
47
|
+
// Lets an integration (un)registration re-evaluate whether push reports
|
|
48
|
+
// should be enabled.
|
|
49
|
+
const reportingUpdaters = new WeakMap<object, () => void>();
|
|
50
|
+
|
|
51
|
+
export const registerTerminalIntegration = (
|
|
52
|
+
stdout: object,
|
|
53
|
+
integration: TerminalIntegration,
|
|
54
|
+
): (() => void) => {
|
|
55
|
+
integrations.set(stdout, integration);
|
|
56
|
+
reportingUpdaters.get(stdout)?.();
|
|
57
|
+
return () => {
|
|
58
|
+
if (integrations.get(stdout) === integration) {
|
|
59
|
+
integrations.delete(stdout);
|
|
60
|
+
reportingUpdaters.get(stdout)?.();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Unsolicited reports the terminal pushes once the matching mode is enabled.
|
|
66
|
+
const focusInReport = `${CSI}I`;
|
|
67
|
+
const focusOutReport = `${CSI}O`;
|
|
68
|
+
const colorSchemeReport = new RegExp(`^${ESC}\\[\\?997;(\\d+)n$`);
|
|
69
|
+
const inBandResizeReport = new RegExp(`^${ESC}\\[48;(\\d+);(\\d+);(\\d+);(\\d+)t$`);
|
|
70
|
+
|
|
71
|
+
// Mode → whether the query result says the terminal supports it.
|
|
72
|
+
const reportModes: Array<[mode: number, supported: (result: TerminalQueryResult) => boolean]> = [
|
|
73
|
+
[1004, (result) => result.focusEvents],
|
|
74
|
+
[2031, (result) => result.colorSchemeUpdates],
|
|
75
|
+
[2048, (result) => result.inBandResize],
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
type StoreStdin = NodeJS.ReadableStream & {
|
|
79
|
+
isTTY?: boolean;
|
|
80
|
+
isRaw?: boolean;
|
|
81
|
+
setRawMode?: (mode: boolean) => void;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type StoreStdout = OutputStream;
|
|
85
|
+
|
|
86
|
+
export type CapabilitiesStore = {
|
|
87
|
+
/**
|
|
88
|
+
The current snapshot: environment-derived detection merged with every
|
|
89
|
+
query answer received so far. Size is always fresh; the object identity
|
|
90
|
+
only changes when something actually changed, so it's safe to compare.
|
|
91
|
+
*/
|
|
92
|
+
readonly current: Capabilities;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
Asks the terminal. The first call runs the full query; subsequent calls
|
|
96
|
+
re-ask only the dynamic questions (theme colors, pixel geometry) and
|
|
97
|
+
merge over the cached answers. No-op outside a TTY. Resolves with the
|
|
98
|
+
updated snapshot.
|
|
99
|
+
*/
|
|
100
|
+
query: () => Promise<Capabilities>;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
Subscribes to snapshot changes: query answers arriving, terminal resizes
|
|
104
|
+
(including in-band pixel geometry), color scheme switches, and window
|
|
105
|
+
focus changes. While subscribers exist (and a report feed is available),
|
|
106
|
+
the store enables the supported report modes on the terminal and disables
|
|
107
|
+
them again on the last unsubscribe. Returns an unsubscribe function.
|
|
108
|
+
*/
|
|
109
|
+
subscribe: (
|
|
110
|
+
listener: (capabilities: Capabilities) => void,
|
|
111
|
+
options?: { resizes?: boolean },
|
|
112
|
+
) => () => void;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
Feeds one parsed escape sequence from an input pipeline into the store.
|
|
116
|
+
Returns `true` when the sequence was an unsolicited terminal report
|
|
117
|
+
(color scheme, focus, in-band resize) and was consumed. Ink calls this
|
|
118
|
+
for every escape sequence it reads; standalone apps that read stdin
|
|
119
|
+
themselves can forward unrecognized sequences here.
|
|
120
|
+
*/
|
|
121
|
+
ingest: (sequence: string) => boolean;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const stores = new WeakMap<object, CapabilitiesStore>();
|
|
125
|
+
|
|
126
|
+
const createStore = (stdin: StoreStdin, stdout: StoreStdout): CapabilitiesStore => {
|
|
127
|
+
const listeners = new Set<(capabilities: Capabilities) => void>();
|
|
128
|
+
let resizeSubscribers = 0;
|
|
129
|
+
|
|
130
|
+
let focused: boolean | undefined;
|
|
131
|
+
let snapshot: Capabilities | undefined;
|
|
132
|
+
let snapshotQuery: TerminalQueryResult | undefined;
|
|
133
|
+
let snapshotColumns: number | undefined;
|
|
134
|
+
let snapshotRows: number | undefined;
|
|
135
|
+
let snapshotFocused: boolean | undefined;
|
|
136
|
+
|
|
137
|
+
const current = (): Capabilities => {
|
|
138
|
+
const query = getTerminalQuery(stdout);
|
|
139
|
+
const { columns, rows } = stdout;
|
|
140
|
+
if (
|
|
141
|
+
!snapshot ||
|
|
142
|
+
query !== snapshotQuery ||
|
|
143
|
+
columns !== snapshotColumns ||
|
|
144
|
+
rows !== snapshotRows ||
|
|
145
|
+
focused !== snapshotFocused
|
|
146
|
+
) {
|
|
147
|
+
const detected = detectCapabilities({ stdout });
|
|
148
|
+
const applied = query ? applyTerminalQuery(detected, query) : detected;
|
|
149
|
+
snapshot = focused === undefined ? applied : { ...applied, focused };
|
|
150
|
+
snapshotQuery = query;
|
|
151
|
+
snapshotColumns = columns;
|
|
152
|
+
snapshotRows = rows;
|
|
153
|
+
snapshotFocused = focused;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return snapshot;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
let lastNotified: Capabilities | undefined;
|
|
160
|
+
const notify = (): void => {
|
|
161
|
+
const capabilities = current();
|
|
162
|
+
if (capabilities === lastNotified) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
lastNotified = capabilities;
|
|
167
|
+
for (const listener of listeners) {
|
|
168
|
+
listener(capabilities);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// ── Push reports ──────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
let reportingEnabled = false;
|
|
175
|
+
let removeExitHandler: (() => void) | undefined;
|
|
176
|
+
|
|
177
|
+
const enabledModes = (): number[] => {
|
|
178
|
+
const result = getTerminalQuery(stdout);
|
|
179
|
+
if (!result) {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return reportModes.filter(([, supported]) => supported(result)).map(([mode]) => mode);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const writeModes = (suffix: "h" | "l"): void => {
|
|
187
|
+
const sequence = enabledModes()
|
|
188
|
+
.map((mode) => `${CSI}?${mode}${suffix}`)
|
|
189
|
+
.join("");
|
|
190
|
+
if (sequence.length > 0) {
|
|
191
|
+
try {
|
|
192
|
+
stdout.write(sequence);
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Report modes persist in the terminal past our process, so they are only
|
|
198
|
+
// on while someone is listening AND a feed exists to consume the reports
|
|
199
|
+
// (the registered runner — Ink's input pipeline — or the app's own
|
|
200
|
+
// `ingest` calls), and they are always reset at process exit.
|
|
201
|
+
const updateReporting = (): void => {
|
|
202
|
+
const integration = integrations.get(stdout);
|
|
203
|
+
const shouldEnable = listeners.size > 0 && integration !== undefined && stdout.isTTY === true;
|
|
204
|
+
|
|
205
|
+
if (shouldEnable && !reportingEnabled && enabledModes().length > 0) {
|
|
206
|
+
reportingEnabled = true;
|
|
207
|
+
// Start the feed before enabling the modes: mode 2048 reports
|
|
208
|
+
// immediately on enable and that report must be consumed.
|
|
209
|
+
integration?.setReportFeed?.(true);
|
|
210
|
+
writeModes("h");
|
|
211
|
+
removeExitHandler = signalExit(() => {
|
|
212
|
+
writeModes("l");
|
|
213
|
+
});
|
|
214
|
+
} else if (!shouldEnable && reportingEnabled) {
|
|
215
|
+
reportingEnabled = false;
|
|
216
|
+
writeModes("l");
|
|
217
|
+
integration?.setReportFeed?.(false);
|
|
218
|
+
removeExitHandler?.();
|
|
219
|
+
removeExitHandler = undefined;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
reportingUpdaters.set(stdout, updateReporting);
|
|
224
|
+
|
|
225
|
+
const ingest = (sequence: string): boolean => {
|
|
226
|
+
if (sequence === focusInReport || sequence === focusOutReport) {
|
|
227
|
+
focused = sequence === focusInReport;
|
|
228
|
+
notify();
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const colorScheme = sequence.match(colorSchemeReport);
|
|
233
|
+
if (colorScheme) {
|
|
234
|
+
const reported =
|
|
235
|
+
colorScheme[1] === "1" ? "dark" : colorScheme[1] === "2" ? "light" : undefined;
|
|
236
|
+
if (reported) {
|
|
237
|
+
// The report reflects the OS preference; the terminal's own theme
|
|
238
|
+
// follows its background color, which the refresh below re-reads.
|
|
239
|
+
// Only when no background is known does the report stand in for it.
|
|
240
|
+
const cached = getTerminalQuery(stdout);
|
|
241
|
+
patchTerminalQuery(stdout, {
|
|
242
|
+
systemAppearance: reported,
|
|
243
|
+
...(cached?.background === undefined ? { appearance: reported } : {}),
|
|
244
|
+
});
|
|
245
|
+
notify();
|
|
246
|
+
if (integrations.has(stdout)) {
|
|
247
|
+
void query().catch(() => {});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const resize = sequence.match(inBandResizeReport);
|
|
255
|
+
if (resize) {
|
|
256
|
+
const [, rows, columns, pixelHeight, pixelWidth] = resize.map(Number);
|
|
257
|
+
if (columns && rows) {
|
|
258
|
+
patchTerminalQuery(stdout, {
|
|
259
|
+
textAreaPixels:
|
|
260
|
+
pixelWidth && pixelHeight ? { width: pixelWidth, height: pixelHeight } : undefined,
|
|
261
|
+
cellPixels:
|
|
262
|
+
pixelWidth && pixelHeight
|
|
263
|
+
? { width: Math.round(pixelWidth / columns), height: Math.round(pixelHeight / rows) }
|
|
264
|
+
: undefined,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
notify();
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return false;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const runStandaloneQuery = async (): Promise<void> => {
|
|
276
|
+
if (!stdout.isTTY || !stdin.isTTY) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Standalone (no Ink): manage raw mode ourselves so responses arrive
|
|
281
|
+
// unbuffered, restoring the previous state afterwards.
|
|
282
|
+
const wasRaw = stdin.isRaw ?? false;
|
|
283
|
+
if (!wasRaw) {
|
|
284
|
+
stdin.setRawMode?.(true);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const refresh = getTerminalQuery(stdout) !== undefined;
|
|
289
|
+
await (refresh ? refreshTerminalQuery(stdin, stdout) : ensureTerminalQuery(stdin, stdout));
|
|
290
|
+
} finally {
|
|
291
|
+
if (!wasRaw) {
|
|
292
|
+
stdin.setRawMode?.(false);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const query = async (): Promise<Capabilities> => {
|
|
298
|
+
const integration = integrations.get(stdout);
|
|
299
|
+
if (integration) {
|
|
300
|
+
await integration.runQuery({ refresh: getTerminalQuery(stdout) !== undefined });
|
|
301
|
+
} else {
|
|
302
|
+
await runStandaloneQuery();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
notify();
|
|
306
|
+
// The answers may have revealed support for push reports.
|
|
307
|
+
updateReporting();
|
|
308
|
+
return current();
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const onResize = (): void => {
|
|
312
|
+
notify();
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const subscribe = (
|
|
316
|
+
listener: (capabilities: Capabilities) => void,
|
|
317
|
+
options: { resizes?: boolean } = {},
|
|
318
|
+
): (() => void) => {
|
|
319
|
+
const resizes = options.resizes ?? true;
|
|
320
|
+
if (resizes && resizeSubscribers++ === 0) {
|
|
321
|
+
stdout.on("resize", onResize);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
listeners.add(listener);
|
|
325
|
+
updateReporting();
|
|
326
|
+
let subscribed = true;
|
|
327
|
+
return () => {
|
|
328
|
+
if (!subscribed) return;
|
|
329
|
+
subscribed = false;
|
|
330
|
+
listeners.delete(listener);
|
|
331
|
+
if (resizes && --resizeSubscribers === 0) {
|
|
332
|
+
stdout.removeListener("resize", onResize);
|
|
333
|
+
}
|
|
334
|
+
updateReporting();
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
get current() {
|
|
340
|
+
return current();
|
|
341
|
+
},
|
|
342
|
+
query,
|
|
343
|
+
subscribe,
|
|
344
|
+
ingest,
|
|
345
|
+
};
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
The capabilities store for a stream pair. Stores are cached per stdout, so
|
|
350
|
+
standalone code and Ink components observing the same terminal share one
|
|
351
|
+
snapshot and one set of query answers.
|
|
352
|
+
*/
|
|
353
|
+
export const getCapabilities = (
|
|
354
|
+
stdin: StoreStdin = process.stdin,
|
|
355
|
+
stdout: StoreStdout = process.stdout,
|
|
356
|
+
): CapabilitiesStore => {
|
|
357
|
+
let store = stores.get(stdout);
|
|
358
|
+
if (!store) {
|
|
359
|
+
store = createStore(stdin, stdout);
|
|
360
|
+
stores.set(stdout, store);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return store;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
The process's own terminal — the store for `process.stdin`/`process.stdout`.
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
import { capabilities } from "@alchemy.run/sigil/capabilities";
|
|
371
|
+
|
|
372
|
+
capabilities.current.supports.hyperlinks;
|
|
373
|
+
const fresh = await capabilities.query();
|
|
374
|
+
const unsubscribe = capabilities.subscribe((caps) => {
|
|
375
|
+
console.log(caps.size, caps.theme.appearance);
|
|
376
|
+
});
|
|
377
|
+
```
|
|
378
|
+
*/
|
|
379
|
+
export const capabilities: CapabilitiesStore = getCapabilities();
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { TerminalAppearance } from "#/capabilities/detect.ts";
|
|
2
|
+
import type { Color, RgbColor } from "#/screen/cell.ts";
|
|
3
|
+
import type { ColorProfile } from "#/screen/color-profile.ts";
|
|
4
|
+
|
|
5
|
+
export type ColorInput = Color | string;
|
|
6
|
+
export type InterpolationSpace = "oklab" | "lab" | "rgb" | "hsv";
|
|
7
|
+
|
|
8
|
+
export type GradientStop = {
|
|
9
|
+
readonly offset?: number;
|
|
10
|
+
readonly color: ColorInput;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type LinearGradient = {
|
|
14
|
+
readonly type: "linear-gradient";
|
|
15
|
+
readonly stops: readonly GradientStop[];
|
|
16
|
+
/** Degrees clockwise: 0 points left-to-right, 90 top-to-bottom. */
|
|
17
|
+
readonly angle: number;
|
|
18
|
+
readonly space: InterpolationSpace;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** A one-dimensional gradient wrapped clockwise around a rectangle's edge. */
|
|
22
|
+
export type PerimeterGradient = {
|
|
23
|
+
readonly type: "perimeter-gradient";
|
|
24
|
+
readonly stops: readonly GradientStop[];
|
|
25
|
+
/** Rotation in terminal cells, starting at the top-left corner. */
|
|
26
|
+
readonly offset: number;
|
|
27
|
+
readonly space: InterpolationSpace;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type AdaptiveColor = {
|
|
31
|
+
readonly type: "adaptive-color";
|
|
32
|
+
readonly light: ColorInput;
|
|
33
|
+
readonly dark: ColorInput;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ProfileColor = {
|
|
37
|
+
readonly type: "profile-color";
|
|
38
|
+
readonly colors: Partial<Record<ColorProfile, ColorInput>>;
|
|
39
|
+
readonly fallback: ColorInput;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type Paint = ColorInput | LinearGradient | PerimeterGradient | AdaptiveColor | ProfileColor;
|
|
43
|
+
|
|
44
|
+
export type PaintContext = {
|
|
45
|
+
readonly appearance?: TerminalAppearance;
|
|
46
|
+
readonly profile?: ColorProfile;
|
|
47
|
+
readonly palette?: readonly { r: number; g: number; b: number }[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const rgb = (red: number, green: number, blue: number, alpha = 255): RgbColor => ({
|
|
51
|
+
model: "rgb",
|
|
52
|
+
red: channel(red),
|
|
53
|
+
green: channel(green),
|
|
54
|
+
blue: channel(blue),
|
|
55
|
+
alpha: channel(alpha),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const ansi = (index: number): Color => ({
|
|
59
|
+
model: "indexed",
|
|
60
|
+
index: Math.max(0, Math.min(15, Math.round(index))),
|
|
61
|
+
encoding: "ansi16",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const ansi256 = (index: number): Color => ({
|
|
65
|
+
model: "indexed",
|
|
66
|
+
index: Math.max(0, Math.min(255, Math.round(index))),
|
|
67
|
+
encoding: "ansi256",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const namedIndexes = {
|
|
71
|
+
black: 0,
|
|
72
|
+
red: 1,
|
|
73
|
+
green: 2,
|
|
74
|
+
yellow: 3,
|
|
75
|
+
blue: 4,
|
|
76
|
+
magenta: 5,
|
|
77
|
+
cyan: 6,
|
|
78
|
+
white: 7,
|
|
79
|
+
blackBright: 8,
|
|
80
|
+
gray: 8,
|
|
81
|
+
grey: 8,
|
|
82
|
+
redBright: 9,
|
|
83
|
+
greenBright: 10,
|
|
84
|
+
yellowBright: 11,
|
|
85
|
+
blueBright: 12,
|
|
86
|
+
magentaBright: 13,
|
|
87
|
+
cyanBright: 14,
|
|
88
|
+
whiteBright: 15,
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
export type NamedColor = keyof typeof namedIndexes;
|
|
92
|
+
export const named = (color: NamedColor): Color => ansi(namedIndexes[color]);
|
|
93
|
+
|
|
94
|
+
export const adaptive = (light: ColorInput, dark: ColorInput): AdaptiveColor => ({
|
|
95
|
+
type: "adaptive-color",
|
|
96
|
+
light,
|
|
97
|
+
dark,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const perProfile = (
|
|
101
|
+
colors: Partial<Record<ColorProfile, ColorInput>>,
|
|
102
|
+
fallback: ColorInput,
|
|
103
|
+
): ProfileColor => ({ type: "profile-color", colors, fallback });
|
|
104
|
+
|
|
105
|
+
export const linearGradient = (
|
|
106
|
+
stops: readonly (GradientStop | ColorInput)[],
|
|
107
|
+
options: { angle?: number; space?: InterpolationSpace } = {},
|
|
108
|
+
): LinearGradient => {
|
|
109
|
+
if (stops.length < 2) {
|
|
110
|
+
throw new Error("A linear gradient requires at least two color stops");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
type: "linear-gradient",
|
|
115
|
+
stops: normalizeStops(stops.map((stop) => (isGradientStop(stop) ? stop : { color: stop }))),
|
|
116
|
+
angle: options.angle ?? 0,
|
|
117
|
+
space: options.space ?? "oklab",
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const perimeterGradient = (
|
|
122
|
+
stops: readonly ColorInput[],
|
|
123
|
+
options: { offset?: number; space?: InterpolationSpace } = {},
|
|
124
|
+
): PerimeterGradient => {
|
|
125
|
+
if (stops.length < 2) {
|
|
126
|
+
throw new Error("A perimeter gradient requires at least two color stops");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
type: "perimeter-gradient",
|
|
131
|
+
stops: normalizeStops(stops.map((color) => ({ color }))),
|
|
132
|
+
offset: Math.round(options.offset ?? 0),
|
|
133
|
+
space: options.space ?? "lab",
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
function normalizeStops(stops: readonly GradientStop[]): GradientStop[] {
|
|
138
|
+
const output = stops.map((stop) => ({ ...stop }));
|
|
139
|
+
output[0] = { ...output[0]!, offset: output[0]!.offset ?? 0 };
|
|
140
|
+
output[output.length - 1] = {
|
|
141
|
+
...output.at(-1)!,
|
|
142
|
+
offset: output.at(-1)!.offset ?? 1,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
let anchor = 0;
|
|
146
|
+
while (anchor < output.length - 1) {
|
|
147
|
+
let next = anchor + 1;
|
|
148
|
+
while (next < output.length && output[next]!.offset === undefined) next++;
|
|
149
|
+
const start = output[anchor]!.offset!;
|
|
150
|
+
const end = output[next]!.offset!;
|
|
151
|
+
for (let index = anchor + 1; index < next; index++) {
|
|
152
|
+
output[index] = {
|
|
153
|
+
...output[index]!,
|
|
154
|
+
offset: start + ((end - start) * (index - anchor)) / (next - anchor),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
anchor = next;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return output.map((stop) => ({ ...stop, offset: Math.max(0, Math.min(1, stop.offset!)) }));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isGradientStop(value: GradientStop | ColorInput): value is GradientStop {
|
|
164
|
+
return typeof value === "object" && value !== null && "color" in value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function channel(value: number): number {
|
|
168
|
+
return Math.max(0, Math.min(255, Math.round(value)));
|
|
169
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Color, RgbColor } from "#/screen/cell.ts";
|
|
2
|
+
|
|
3
|
+
/** Canonical xterm-compatible palette used until a terminal reports its own. */
|
|
4
|
+
const canonicalChannels = [
|
|
5
|
+
[0, 0, 0],
|
|
6
|
+
[205, 0, 0],
|
|
7
|
+
[0, 205, 0],
|
|
8
|
+
[205, 205, 0],
|
|
9
|
+
[0, 0, 238],
|
|
10
|
+
[205, 0, 205],
|
|
11
|
+
[0, 205, 205],
|
|
12
|
+
[229, 229, 229],
|
|
13
|
+
[127, 127, 127],
|
|
14
|
+
[255, 0, 0],
|
|
15
|
+
[0, 255, 0],
|
|
16
|
+
[255, 255, 0],
|
|
17
|
+
[92, 92, 255],
|
|
18
|
+
[255, 0, 255],
|
|
19
|
+
[0, 255, 255],
|
|
20
|
+
[255, 255, 255],
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export const canonicalAnsiPalette: readonly RgbColor[] = canonicalChannels.map(
|
|
24
|
+
([red, green, blue]) => ({ model: "rgb", red, green, blue, alpha: 255 }),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export function colorToRgb(
|
|
28
|
+
color: Color,
|
|
29
|
+
palette?: readonly { r: number; g: number; b: number }[],
|
|
30
|
+
): RgbColor {
|
|
31
|
+
if (color.model === "rgb") return color;
|
|
32
|
+
const custom = color.index < 16 ? palette?.[color.index] : undefined;
|
|
33
|
+
if (custom) return { model: "rgb", red: custom.r, green: custom.g, blue: custom.b, alpha: 255 };
|
|
34
|
+
if (color.index < 16) return canonicalAnsiPalette[color.index]!;
|
|
35
|
+
if (color.index >= 232) {
|
|
36
|
+
const value = 8 + (color.index - 232) * 10;
|
|
37
|
+
return { model: "rgb", red: value, green: value, blue: value, alpha: 255 };
|
|
38
|
+
}
|
|
39
|
+
const index = color.index - 16;
|
|
40
|
+
const levels = [0, 95, 135, 175, 215, 255];
|
|
41
|
+
return {
|
|
42
|
+
model: "rgb",
|
|
43
|
+
red: levels[Math.floor(index / 36)]!,
|
|
44
|
+
green: levels[Math.floor((index % 36) / 6)]!,
|
|
45
|
+
blue: levels[index % 6]!,
|
|
46
|
+
alpha: 255,
|
|
47
|
+
};
|
|
48
|
+
}
|