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 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 render demo.cast --theme dracula -o demo.svg
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.2.2",
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": "./bin/tcut.mjs"
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 list built-in 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 size (rec: defaults to your terminal's size)
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
- for (const name of themeNames) console.log(name);
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 outputs = overrides.output ?? ["rec.mp4"];
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 recording = await recordLive(config, { command, log, cols: overrides.cols, rows: overrides.rows });
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: config.cols ?? 80,
37
- rows: config.rows ?? 24,
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: config.padding ?? 24,
60
- margin: config.margin ?? 0,
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 frameW = termW + config.padding * 2;
32
- const frameH = termH + config.padding * 2 + bar;
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 + config.padding,
43
- termY: config.margin + config.padding + bar,
44
- width: Math.ceil(frameW + config.margin * 2),
45
- height: Math.ceil(frameH + config.margin * 2),
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
+ }