termcut 0.2.2 → 0.4.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/README.md +11 -2
- package/package.json +2 -2
- package/scripts/build-themes.ts +86 -0
- package/src/cli.ts +85 -7
- package/src/config.ts +30 -10
- package/src/export/svg.ts +16 -8
- package/src/index.ts +5 -1
- package/src/keys.ts +28 -0
- package/src/loop.ts +45 -0
- package/src/publish.ts +226 -0
- package/src/recorder.ts +21 -2
- package/src/renderer/encoder.ts +4 -0
- package/src/renderer/generated/page.js +1 -1
- package/src/renderer/page-entry.ts +2 -1
- package/src/renderer/webview.ts +21 -7
- package/src/screen.ts +5 -0
- package/src/scriptgen.ts +230 -0
- package/src/themes.generated.json +1 -0
- package/src/themes.ts +35 -6
- package/src/types.ts +22 -7
package/README.md
CHANGED
|
@@ -21,6 +21,8 @@ tcut rec -o demo.gif # opens a shell, records until you `exit
|
|
|
21
21
|
tcut rec -o demo.mp4 -- npm create vite # or just one command
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
You get `demo.gif`, the exact recording (`demo.cast`) and an editable script (`demo.video.ts`) of what you typed.
|
|
25
|
+
|
|
24
26
|
Or script it:
|
|
25
27
|
|
|
26
28
|
```ts
|
|
@@ -38,10 +40,17 @@ export default defineVideo({ output: "demo.gif" }, async (t) => {
|
|
|
38
40
|
tcut demo.video.ts
|
|
39
41
|
```
|
|
40
42
|
|
|
41
|
-
Re-render any recording without re-running it:
|
|
43
|
+
Re-render any recording without re-running it — ~600 themes ([Ghostty's collection](https://github.com/mbadolato/iTerm2-Color-Schemes)), `tcut themes` lists them:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
tcut render demo.cast --theme "Gruvbox Dark" -o demo.svg
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Share it:
|
|
42
50
|
|
|
43
51
|
```sh
|
|
44
|
-
tcut
|
|
52
|
+
tcut publish --setup # once: your S3-compatible bucket (RustFS, MinIO, R2, S3)
|
|
53
|
+
tcut publish demo.gif # → https://…/3f9a1c2b7d4e/demo.gif
|
|
45
54
|
```
|
|
46
55
|
|
|
47
56
|
## More
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termcut",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Aman Varshney",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
".": "./src/index.ts"
|
|
34
34
|
},
|
|
35
35
|
"bin": {
|
|
36
|
-
"tcut": "
|
|
36
|
+
"tcut": "bin/tcut.mjs"
|
|
37
37
|
},
|
|
38
38
|
"files": [
|
|
39
39
|
"bin",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Generates src/themes.generated.json from the Ghostty-format themes in mbadolato/iTerm2-Color-Schemes (MIT) —
|
|
2
|
+
// the same collection Ghostty bundles. Run: `bun scripts/build-themes.ts` (network). Output is committed.
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
|
|
7
|
+
const SOURCE = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/refs/heads/master.tar.gz";
|
|
8
|
+
const out = path.resolve(import.meta.dir, "..", "src", "themes.generated.json");
|
|
9
|
+
|
|
10
|
+
const KEYS = [
|
|
11
|
+
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
|
|
12
|
+
"brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
const slug = (name: string) =>
|
|
16
|
+
name
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
19
|
+
.replace(/^-+|-+$/g, "");
|
|
20
|
+
|
|
21
|
+
const hex = (v: string) => {
|
|
22
|
+
const h = v.trim().replace(/^#/, "");
|
|
23
|
+
return /^[0-9a-f]{6}$/i.test(h) ? `#${h.toLowerCase()}` : null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function parse(name: string, text: string): Record<string, string> | null {
|
|
27
|
+
const palette: Record<number, string> = {};
|
|
28
|
+
const props: Record<string, string> = {};
|
|
29
|
+
for (const raw of text.split("\n")) {
|
|
30
|
+
const line = raw.trim();
|
|
31
|
+
if (!line || line.startsWith("#")) continue;
|
|
32
|
+
const eq = line.indexOf("=");
|
|
33
|
+
if (eq < 0) continue;
|
|
34
|
+
const key = line.slice(0, eq).trim();
|
|
35
|
+
const value = line.slice(eq + 1).trim();
|
|
36
|
+
if (key === "palette") {
|
|
37
|
+
const m = /^(\d+)\s*=\s*(.+)$/.exec(value);
|
|
38
|
+
if (m) {
|
|
39
|
+
const c = hex(m[2]!);
|
|
40
|
+
if (c) palette[Number(m[1])] = c;
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
props[key] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const background = hex(props.background ?? "");
|
|
47
|
+
const foreground = hex(props.foreground ?? "");
|
|
48
|
+
if (!background || !foreground) return null;
|
|
49
|
+
for (let i = 0; i < 16; i++) if (!palette[i]) return null;
|
|
50
|
+
const theme: Record<string, string> = { name, background, foreground };
|
|
51
|
+
const cursor = hex(props["cursor-color"] ?? "");
|
|
52
|
+
if (cursor) theme.cursor = cursor;
|
|
53
|
+
const cursorText = hex(props["cursor-text"] ?? "");
|
|
54
|
+
if (cursorText) theme.cursorAccent = cursorText;
|
|
55
|
+
const selection = hex(props["selection-background"] ?? "");
|
|
56
|
+
if (selection) theme.selectionBackground = selection;
|
|
57
|
+
KEYS.forEach((k, i) => (theme[k] = palette[i]!));
|
|
58
|
+
return theme;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const tmp = await mkdtemp(path.join(tmpdir(), "tcut-themes-"));
|
|
62
|
+
const tgz = path.join(tmp, "schemes.tgz");
|
|
63
|
+
const res = await fetch(SOURCE);
|
|
64
|
+
if (!res.ok) throw new Error(`download failed: ${res.status}`);
|
|
65
|
+
await Bun.write(tgz, await res.arrayBuffer());
|
|
66
|
+
const untar = Bun.spawn(["tar", "-xzf", tgz, "-C", tmp], { stdout: "ignore", stderr: "pipe" });
|
|
67
|
+
if ((await untar.exited) !== 0) throw new Error(await new Response(untar.stderr).text());
|
|
68
|
+
const root = (await readdir(tmp)).find((d) => d.startsWith("iTerm2-Color-Schemes"));
|
|
69
|
+
if (!root) throw new Error("unexpected archive layout");
|
|
70
|
+
const dir = path.join(tmp, root, "ghostty");
|
|
71
|
+
|
|
72
|
+
const themes: Record<string, Record<string, string>> = {};
|
|
73
|
+
let skipped = 0;
|
|
74
|
+
for (const file of (await readdir(dir)).sort()) {
|
|
75
|
+
const text = await Bun.file(path.join(dir, file)).text();
|
|
76
|
+
const theme = parse(file, text);
|
|
77
|
+
if (!theme) {
|
|
78
|
+
skipped++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
themes[slug(file)] = theme;
|
|
82
|
+
}
|
|
83
|
+
await rm(tmp, { recursive: true, force: true });
|
|
84
|
+
|
|
85
|
+
await Bun.write(out, JSON.stringify(themes) + "\n");
|
|
86
|
+
console.log(`wrote ${path.relative(process.cwd(), out)}: ${Object.keys(themes).length} themes (${skipped} skipped), ${(Bun.file(out).size / 1024).toFixed(0)} KB`);
|
package/src/cli.ts
CHANGED
|
@@ -6,9 +6,11 @@ import { writeCast } from "./cast";
|
|
|
6
6
|
import { resolveConfig } from "./config";
|
|
7
7
|
import * as api from "./index";
|
|
8
8
|
import { recordLive } from "./live";
|
|
9
|
+
import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig } from "./publish";
|
|
9
10
|
import { renderOutputs } from "./render";
|
|
11
|
+
import { generateScript } from "./scriptgen";
|
|
10
12
|
import { runScriptTests } from "./testing";
|
|
11
|
-
import { themeNames } from "./themes";
|
|
13
|
+
import { findThemes, themeNames } from "./themes";
|
|
12
14
|
import type { CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
|
|
13
15
|
import { Video, isVideo, renderCast } from "./video";
|
|
14
16
|
|
|
@@ -32,8 +34,10 @@ Usage:
|
|
|
32
34
|
tcut record <script.ts> [options] record only (writes the .cast)
|
|
33
35
|
tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
|
|
34
36
|
tcut test <path...> run scripts in fast mode as tests (no video)
|
|
37
|
+
tcut publish <files...> [--open] upload to your S3-compatible bucket and print share links
|
|
38
|
+
tcut publish --setup configure the bucket (RustFS, MinIO, R2, S3 …) — once
|
|
35
39
|
tcut init [name] [--template t] scaffold a new script (basic | tour | test)
|
|
36
|
-
tcut themes
|
|
40
|
+
tcut themes [query] list the ~600 bundled themes (Ghostty collection)
|
|
37
41
|
|
|
38
42
|
Options (override the script's config):
|
|
39
43
|
-o, --output <path> .mp4 .webm .gif .webp .svg .html .png .jpg or dir/ for PNG frames — repeatable
|
|
@@ -44,10 +48,16 @@ Options (override the script's config):
|
|
|
44
48
|
--window-bar <type> none | colorful | colorfulRight | rings | ringsRight
|
|
45
49
|
--title <text> --no-blink
|
|
46
50
|
--core <name> ghostty | lite
|
|
47
|
-
--cols <n> --rows <n> terminal
|
|
51
|
+
--cols <n> --rows <n> terminal grid (rec: defaults to your terminal's size)
|
|
52
|
+
--width <px> --height <px> video size; the grid is derived and centred inside
|
|
53
|
+
--loop-offset <n|N%> where GIF/WebP loops start
|
|
48
54
|
--cast <path> where to read/write the .cast
|
|
49
55
|
--record-only stop after writing the cast
|
|
56
|
+
--no-script rec: don't write the editable <name>.video.ts next to the cast
|
|
50
57
|
--force ignore the cast cache and re-record
|
|
58
|
+
--open publish: open the first link in the browser
|
|
59
|
+
--name <file> publish: object name (default: the file's basename)
|
|
60
|
+
--endpoint --bucket --access-key --secret-key --public-url --region publish --setup values
|
|
51
61
|
--template <name> for init: basic | tour | test
|
|
52
62
|
-q, --quiet
|
|
53
63
|
-h, --help
|
|
@@ -75,9 +85,22 @@ const { values, positionals } = parseArgs({
|
|
|
75
85
|
core: { type: "string" },
|
|
76
86
|
cols: { type: "string" },
|
|
77
87
|
rows: { type: "string" },
|
|
88
|
+
width: { type: "string" },
|
|
89
|
+
height: { type: "string" },
|
|
90
|
+
"loop-offset": { type: "string" },
|
|
78
91
|
cast: { type: "string" },
|
|
79
92
|
"record-only": { type: "boolean" },
|
|
93
|
+
"no-script": { type: "boolean" },
|
|
80
94
|
force: { type: "boolean" },
|
|
95
|
+
setup: { type: "boolean" },
|
|
96
|
+
open: { type: "boolean" },
|
|
97
|
+
name: { type: "string" },
|
|
98
|
+
endpoint: { type: "string" },
|
|
99
|
+
bucket: { type: "string" },
|
|
100
|
+
"access-key": { type: "string" },
|
|
101
|
+
"secret-key": { type: "string" },
|
|
102
|
+
"public-url": { type: "string" },
|
|
103
|
+
region: { type: "string" },
|
|
81
104
|
template: { type: "string" },
|
|
82
105
|
quiet: { type: "boolean", short: "q" },
|
|
83
106
|
help: { type: "boolean", short: "h" },
|
|
@@ -135,6 +158,9 @@ function overridesFromFlags(): Partial<VideoConfig> {
|
|
|
135
158
|
if (values.cast) o.cast = values.cast;
|
|
136
159
|
if (values.cols !== undefined) o.cols = num("cols");
|
|
137
160
|
if (values.rows !== undefined) o.rows = num("rows");
|
|
161
|
+
if (values.width !== undefined) o.width = num("width");
|
|
162
|
+
if (values.height !== undefined) o.height = num("height");
|
|
163
|
+
if (values["loop-offset"] !== undefined) o.loopOffset = values["loop-offset"];
|
|
138
164
|
return o;
|
|
139
165
|
}
|
|
140
166
|
|
|
@@ -281,7 +307,47 @@ async function main(): Promise<void> {
|
|
|
281
307
|
|
|
282
308
|
switch (first) {
|
|
283
309
|
case "themes": {
|
|
284
|
-
|
|
310
|
+
const names = rest[0] ? findThemes(rest[0]) : themeNames;
|
|
311
|
+
if (names.length === 0) fail(`No theme matches "${rest[0]}"`);
|
|
312
|
+
for (const name of names) console.log(name);
|
|
313
|
+
if (!rest[0]) log(dim(`${names.length} themes · use any name with --theme, e.g. --theme "Gruvbox Dark"`));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
case "publish": {
|
|
317
|
+
if (values.setup) {
|
|
318
|
+
const ask = async (label: string, flag: string | undefined, fallback: string, secret = false): Promise<string> => {
|
|
319
|
+
if (flag) return flag;
|
|
320
|
+
if (!process.stdin.isTTY) return fallback;
|
|
321
|
+
const answer = prompt(`${label}${fallback ? ` [${fallback}]` : ""}:`) ?? "";
|
|
322
|
+
return answer.trim() || fallback;
|
|
323
|
+
};
|
|
324
|
+
const existing = await loadPublishConfig().catch(() => null);
|
|
325
|
+
const cfg: PublishConfig = {
|
|
326
|
+
endpoint: await ask("S3 endpoint", values.endpoint, existing?.endpoint ?? "https://s3.amanv.cloud"),
|
|
327
|
+
bucket: await ask("Bucket", values.bucket, existing?.bucket ?? "tcut"),
|
|
328
|
+
accessKeyId: await ask("Access key", values["access-key"], existing?.accessKeyId ?? ""),
|
|
329
|
+
secretAccessKey: await ask("Secret key", values["secret-key"], existing?.secretAccessKey ?? "", true),
|
|
330
|
+
region: values.region ?? existing?.region ?? "us-east-1",
|
|
331
|
+
...(values["public-url"] || existing?.publicUrl ? { publicUrl: values["public-url"] ?? existing?.publicUrl } : {}),
|
|
332
|
+
};
|
|
333
|
+
if (!cfg.accessKeyId || !cfg.secretAccessKey) fail("publish --setup needs --access-key and --secret-key (or run it in a terminal to be prompted)");
|
|
334
|
+
const result = await ensurePublicBucket(cfg, log);
|
|
335
|
+
const file = await savePublishConfig(cfg);
|
|
336
|
+
ok(`saved ${file}`, "mode 600");
|
|
337
|
+
ok(`bucket ${cfg.bucket} on ${cfg.endpoint}`, result.bucketCreated ? "created" : "exists");
|
|
338
|
+
if (result.publicReadOk) ok("public read verified", `links will look like ${publicUrlFor(cfg, "x").replace(/\/x$/, "/<hash>/demo.gif")}`);
|
|
339
|
+
else log(`${red("✘")} anonymous read failed — set a public-read policy on the bucket or pass --public-url for a CDN/proxy in front of it`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (rest.length === 0) fail("publish needs at least one file (or --setup)");
|
|
343
|
+
const cfg = await loadPublishConfig();
|
|
344
|
+
if (!cfg) fail("publish is not configured yet — run `tcut publish --setup` (or set TCUT_S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY)");
|
|
345
|
+
const published = await publishFiles(rest, cfg, { name: values.name, log });
|
|
346
|
+
for (const p of published) ok(p.url, dim(path.basename(p.file)));
|
|
347
|
+
if (values.open && published[0]) {
|
|
348
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
349
|
+
Bun.spawn([opener, published[published.length - 1]!.url], { stdout: "ignore", stderr: "ignore" });
|
|
350
|
+
}
|
|
285
351
|
return;
|
|
286
352
|
}
|
|
287
353
|
case "init": {
|
|
@@ -299,15 +365,27 @@ async function main(): Promise<void> {
|
|
|
299
365
|
case "rec": {
|
|
300
366
|
// Live mode: the user (or a pipe) drives the PTY; everything after `--` is the command to run.
|
|
301
367
|
const overrides = overridesFromFlags();
|
|
302
|
-
const
|
|
368
|
+
const rawOutputs = overrides.output ?? ["rec.mp4"];
|
|
369
|
+
const outputs = Array.isArray(rawOutputs) ? rawOutputs : [rawOutputs];
|
|
303
370
|
const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
|
|
304
371
|
const command = rest.length > 0 ? rest : undefined;
|
|
305
|
-
// Size: --cols/--rows if given, else the terminal tcut runs in.
|
|
306
|
-
const
|
|
372
|
+
// Size: --cols/--rows if given, else derived from --width/--height, else the terminal tcut runs in.
|
|
373
|
+
const sized = overrides.width !== undefined || overrides.height !== undefined;
|
|
374
|
+
const recording = await recordLive(config, {
|
|
375
|
+
command,
|
|
376
|
+
log,
|
|
377
|
+
cols: overrides.cols ?? (sized ? config.cols : undefined),
|
|
378
|
+
rows: overrides.rows ?? (sized ? config.rows : undefined),
|
|
379
|
+
});
|
|
307
380
|
await mkdir(path.dirname(path.resolve(config.cast)), { recursive: true });
|
|
308
381
|
await writeCast(config.cast, recording);
|
|
309
382
|
log("");
|
|
310
383
|
ok(`wrote ${config.cast}`, `${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s`);
|
|
384
|
+
if (!values["no-script"]) {
|
|
385
|
+
const scriptPath = config.cast.replace(/\.cast$/, "") + ".video.ts";
|
|
386
|
+
await Bun.write(scriptPath, generateScript(recording, { output: outputs, cleanShell: !command, command, castPath: config.cast }));
|
|
387
|
+
ok(`wrote ${scriptPath}`, "editable script — tweak it, then `tcut " + scriptPath + "`");
|
|
388
|
+
}
|
|
311
389
|
if (values["record-only"]) return;
|
|
312
390
|
const result = await renderOutputs(recording, config, progressReporter());
|
|
313
391
|
await reportOutputs(result.outputs, result.screenshots);
|
package/src/config.ts
CHANGED
|
@@ -14,12 +14,34 @@ export function defaultPromptPattern(prompt: string): RegExp {
|
|
|
14
14
|
return new RegExp(`${escapeRegExp(prompt.trimEnd())}\\s*$`);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Approximate cell size before anything is measured (JetBrains Mono / Menlo are ~0.6em wide). */
|
|
18
|
+
export function estimateCell(font: { size: number; lineHeight: number; letterSpacing: number }): { w: number; h: number } {
|
|
19
|
+
return { w: Math.round(font.size * 0.6 * 100) / 100 + font.letterSpacing, h: Math.ceil(font.size * font.lineHeight) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const WINDOW_BAR_HEIGHT = 36;
|
|
23
|
+
|
|
17
24
|
export function resolveConfig(config: VideoConfig): ResolvedConfig {
|
|
18
25
|
const outputs = Array.isArray(config.output) ? config.output : [config.output];
|
|
19
26
|
if (outputs.length === 0) throw new Error("config.output must name at least one output");
|
|
20
27
|
|
|
21
28
|
const prompt = config.prompt ?? "> ";
|
|
22
29
|
const theme = resolveTheme(config.theme);
|
|
30
|
+
|
|
31
|
+
const font = {
|
|
32
|
+
family: config.font?.family ?? DEFAULT_FONT_FAMILY,
|
|
33
|
+
size: config.font?.size ?? 20,
|
|
34
|
+
lineHeight: config.font?.lineHeight ?? 1.2,
|
|
35
|
+
letterSpacing: config.font?.letterSpacing ?? 0,
|
|
36
|
+
};
|
|
37
|
+
const padding = config.padding ?? 24;
|
|
38
|
+
const margin = config.margin ?? 0;
|
|
39
|
+
const bar = (config.windowBar ?? "none") === "none" ? 0 : WINDOW_BAR_HEIGHT;
|
|
40
|
+
const cell = estimateCell(font);
|
|
41
|
+
let cols = config.cols;
|
|
42
|
+
let rows = config.rows;
|
|
43
|
+
if (config.width !== undefined && cols === undefined) cols = Math.max(10, Math.floor((config.width - 2 * margin - 2 * padding) / cell.w));
|
|
44
|
+
if (config.height !== undefined && rows === undefined) rows = Math.max(3, Math.floor((config.height - 2 * margin - 2 * padding - bar) / cell.h));
|
|
23
45
|
const first = outputs[0]!;
|
|
24
46
|
const castDefault = first.endsWith("/")
|
|
25
47
|
? path.join(first, "session.cast")
|
|
@@ -33,8 +55,11 @@ export function resolveConfig(config: VideoConfig): ResolvedConfig {
|
|
|
33
55
|
promptPattern: (config.promptPattern ?? defaultPromptPattern(prompt)).source,
|
|
34
56
|
cwd: config.cwd ?? process.cwd(),
|
|
35
57
|
env: config.env ?? {},
|
|
36
|
-
cols:
|
|
37
|
-
rows:
|
|
58
|
+
cols: cols ?? 80,
|
|
59
|
+
rows: rows ?? 24,
|
|
60
|
+
...(config.width !== undefined && { width: config.width }),
|
|
61
|
+
...(config.height !== undefined && { height: config.height }),
|
|
62
|
+
...(config.loopOffset !== undefined && { loopOffset: config.loopOffset }),
|
|
38
63
|
fps: config.fps ?? 60,
|
|
39
64
|
typingSpeed: toMs(config.typingSpeed, 50),
|
|
40
65
|
typingJitter: Math.min(1, Math.max(0, config.typingJitter ?? 0)),
|
|
@@ -45,19 +70,14 @@ export function resolveConfig(config: VideoConfig): ResolvedConfig {
|
|
|
45
70
|
quantize: config.quantize ?? false,
|
|
46
71
|
core: config.core ?? "ghostty",
|
|
47
72
|
cache: config.cache ?? true,
|
|
48
|
-
font
|
|
49
|
-
family: config.font?.family ?? DEFAULT_FONT_FAMILY,
|
|
50
|
-
size: config.font?.size ?? 20,
|
|
51
|
-
lineHeight: config.font?.lineHeight ?? 1.2,
|
|
52
|
-
letterSpacing: config.font?.letterSpacing ?? 0,
|
|
53
|
-
},
|
|
73
|
+
font,
|
|
54
74
|
theme,
|
|
55
75
|
cursor: {
|
|
56
76
|
blink: config.cursor?.blink ?? true,
|
|
57
77
|
period: config.cursor?.period ?? 1000,
|
|
58
78
|
},
|
|
59
|
-
padding
|
|
60
|
-
margin
|
|
79
|
+
padding,
|
|
80
|
+
margin,
|
|
61
81
|
marginFill: config.marginFill ?? theme.background,
|
|
62
82
|
borderRadius: config.borderRadius ?? 0,
|
|
63
83
|
windowBar: config.windowBar ?? "none",
|
package/src/export/svg.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fitFrame } from "../loop";
|
|
1
2
|
import { barHeight } from "../renderer/page";
|
|
2
3
|
import type { Recording, ResolvedConfig } from "../types";
|
|
3
4
|
import { FLAG, replayFrames, type GridCell, type GridFrame } from "./frames";
|
|
@@ -28,8 +29,15 @@ export function svgGeometry(config: ResolvedConfig, cols: number, rows: number):
|
|
|
28
29
|
const termW = cols * cellW;
|
|
29
30
|
const termH = rows * cellH;
|
|
30
31
|
const bar = barHeight(config);
|
|
31
|
-
const
|
|
32
|
-
|
|
32
|
+
const fit = fitFrame({
|
|
33
|
+
termW: Math.ceil(termW),
|
|
34
|
+
termH,
|
|
35
|
+
padding: config.padding,
|
|
36
|
+
margin: config.margin,
|
|
37
|
+
bar,
|
|
38
|
+
width: config.width,
|
|
39
|
+
height: config.height,
|
|
40
|
+
});
|
|
33
41
|
return {
|
|
34
42
|
cellW,
|
|
35
43
|
cellH,
|
|
@@ -37,12 +45,12 @@ export function svgGeometry(config: ResolvedConfig, cols: number, rows: number):
|
|
|
37
45
|
termH,
|
|
38
46
|
frameX: config.margin,
|
|
39
47
|
frameY: config.margin,
|
|
40
|
-
frameW,
|
|
41
|
-
frameH,
|
|
42
|
-
termX: config.margin +
|
|
43
|
-
termY: config.margin +
|
|
44
|
-
width:
|
|
45
|
-
height:
|
|
48
|
+
frameW: fit.frameW,
|
|
49
|
+
frameH: fit.frameH,
|
|
50
|
+
termX: config.margin + fit.padX,
|
|
51
|
+
termY: config.margin + fit.padY + bar,
|
|
52
|
+
width: fit.width,
|
|
53
|
+
height: fit.height,
|
|
46
54
|
};
|
|
47
55
|
}
|
|
48
56
|
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,11 @@ export { replayFrames } from "./export/frames";
|
|
|
9
9
|
export type { GridFrame, GridCell, GridReplay } from "./export/frames";
|
|
10
10
|
export { runScriptTests, discoverScripts } from "./testing";
|
|
11
11
|
export type { TestResult, TestSummary } from "./testing";
|
|
12
|
-
export { themes, themeNames, resolveTheme } from "./themes";
|
|
12
|
+
export { themes, themeNames, resolveTheme, findThemes, themeSlug, builtinThemes } from "./themes";
|
|
13
|
+
export { generateScript, eventsToOps, tokenize } from "./scriptgen";
|
|
14
|
+
export type { ScriptGenOptions } from "./scriptgen";
|
|
15
|
+
export { publishFiles, loadPublishConfig, savePublishConfig, ensurePublicBucket, publicUrlFor, keyFor } from "./publish";
|
|
16
|
+
export type { PublishConfig, Published, PublishOptions } from "./publish";
|
|
13
17
|
export { readCast, writeCast, parseCast, serializeCast } from "./cast";
|
|
14
18
|
export { buildTimeline } from "./timeline";
|
|
15
19
|
export { resolveConfig } from "./config";
|
package/src/keys.ts
CHANGED
|
@@ -51,6 +51,34 @@ export function ctrlSequence(key: string): string {
|
|
|
51
51
|
throw new Error(`Cannot send Ctrl+${key}`);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
const shiftedNamed: Record<string, string> = {
|
|
55
|
+
tab: `${ESC}[Z`,
|
|
56
|
+
up: `${ESC}[1;2A`,
|
|
57
|
+
down: `${ESC}[1;2B`,
|
|
58
|
+
right: `${ESC}[1;2C`,
|
|
59
|
+
left: `${ESC}[1;2D`,
|
|
60
|
+
home: `${ESC}[1;2H`,
|
|
61
|
+
end: `${ESC}[1;2F`,
|
|
62
|
+
delete: `${ESC}[3;2~`,
|
|
63
|
+
pageUp: `${ESC}[5;2~`,
|
|
64
|
+
pageDown: `${ESC}[6;2~`,
|
|
65
|
+
enter: "\r",
|
|
66
|
+
space: " ",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Shift+<key>: back-tab, shifted navigation keys (xterm modifier 2), or an uppercased character. */
|
|
70
|
+
export function shiftSequence(key: string): string {
|
|
71
|
+
const named = shiftedNamed[key];
|
|
72
|
+
if (named) return named;
|
|
73
|
+
if (key.length === 1) return key.toUpperCase();
|
|
74
|
+
throw new Error(`Cannot send Shift+${key}. Known: ${Object.keys(shiftedNamed).join(", ")}, or a single character.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** SGR mouse wheel event (button 64 = up, 65 = down) at a 1-based cell position. */
|
|
78
|
+
export function wheelSequence(direction: "up" | "down", col: number, row: number): string {
|
|
79
|
+
return `${ESC}[<${direction === "up" ? 64 : 65};${col};${row}M`;
|
|
80
|
+
}
|
|
81
|
+
|
|
54
82
|
/** Alt/Meta+<key> → ESC-prefixed key. */
|
|
55
83
|
export function altSequence(key: string): string {
|
|
56
84
|
const isNamed = key in keySequences;
|
package/src/loop.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Resolve a `loopOffset` (frame count or "N%") to a frame index in `[0, total)`. */
|
|
2
|
+
export function loopOffsetFrames(total: number, value: number | string | undefined): number {
|
|
3
|
+
if (!value || total <= 1) return 0;
|
|
4
|
+
let frames: number;
|
|
5
|
+
if (typeof value === "string") {
|
|
6
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*(%?)\s*$/.exec(value);
|
|
7
|
+
if (!m) throw new Error(`Invalid loopOffset "${value}" (use a frame count or a percentage like "50%")`);
|
|
8
|
+
frames = m[2] ? Math.round((Number(m[1]) / 100) * total) : Math.round(Number(m[1]));
|
|
9
|
+
} else {
|
|
10
|
+
frames = Math.round(value);
|
|
11
|
+
}
|
|
12
|
+
return ((frames % total) + total) % total;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Start the sequence at `offset`, appending the frames before it at the end. */
|
|
16
|
+
export function rotateFrames<T>(frames: T[], offset: number): T[] {
|
|
17
|
+
if (offset <= 0 || offset >= frames.length) return frames;
|
|
18
|
+
return [...frames.slice(offset), ...frames.slice(0, offset)];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Place the terminal grid inside a frame of the requested size (or wrap it tightly when no size is requested). */
|
|
22
|
+
export function fitFrame(opts: {
|
|
23
|
+
termW: number;
|
|
24
|
+
termH: number;
|
|
25
|
+
padding: number;
|
|
26
|
+
margin: number;
|
|
27
|
+
bar: number;
|
|
28
|
+
width?: number;
|
|
29
|
+
height?: number;
|
|
30
|
+
}): { frameW: number; frameH: number; padX: number; padY: number; width: number; height: number } {
|
|
31
|
+
const even = (n: number) => (n % 2 === 0 ? n : n + 1);
|
|
32
|
+
let frameW = opts.termW + opts.padding * 2;
|
|
33
|
+
let frameH = opts.termH + opts.padding * 2 + opts.bar;
|
|
34
|
+
let padX = opts.padding;
|
|
35
|
+
let padY = opts.padding;
|
|
36
|
+
if (opts.width !== undefined || opts.height !== undefined) {
|
|
37
|
+
const targetW = (opts.width ?? frameW + opts.margin * 2) - opts.margin * 2;
|
|
38
|
+
const targetH = (opts.height ?? frameH + opts.margin * 2) - opts.margin * 2;
|
|
39
|
+
frameW = Math.max(opts.termW, targetW);
|
|
40
|
+
frameH = Math.max(opts.termH + opts.bar, targetH);
|
|
41
|
+
padX = Math.floor((frameW - opts.termW) / 2);
|
|
42
|
+
padY = Math.floor((frameH - opts.bar - opts.termH) / 2);
|
|
43
|
+
}
|
|
44
|
+
return { frameW, frameH, padX, padY, width: even(frameW + opts.margin * 2), height: even(frameH + opts.margin * 2) };
|
|
45
|
+
}
|