From eecdcf6f1141f1773a46c9e9f3956e3cda9dd552 Mon Sep 17 00:00:00 2001 From: douglance <4741454+douglance@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:18:14 -0400 Subject: [PATCH] feat(app): compile JSONC diagrams --- CHANGELOG.md | 2 + README.md | 65 ++++ bun.lock | 27 +- docs/examples/architecture.diagram.jsonc | 197 ++++++++++++ docs/examples/service-flow.diagram.jsonc | 44 +++ package.json | 2 +- packages/app/README.md | 25 ++ packages/app/package.json | 2 + packages/app/src/compile.test.ts | 50 +++ packages/app/src/compile.ts | 379 +++++++++++++++++++++++ packages/app/src/main.test.ts | 46 +++ packages/app/src/main.tsx | 77 ++++- packages/opentui/src/draw-state.test.ts | 35 ++- packages/opentui/src/draw-state.ts | 19 ++ 14 files changed, 958 insertions(+), 12 deletions(-) create mode 100644 docs/examples/architecture.diagram.jsonc create mode 100644 docs/examples/service-flow.diagram.jsonc create mode 100644 packages/app/src/compile.test.ts create mode 100644 packages/app/src/compile.ts create mode 100644 packages/app/src/main.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be262f4..d820aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable user-visible changes to this project are documented in this file. ### Added +- Added `termdraw compile` for rendering JSONC diagram documents through the termDRAW drawing model without opening the interactive editor. + ### Changed ### Fixed diff --git a/README.md b/README.md index 5bf1c1e..b2421fb 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,46 @@ - `@termdraw/opentui` — embeddable OpenTUI components and renderables - `@termdraw/pi` — Pi package that opens termDRAW in a Pi overlay +## Architecture + +```text +╔══════════════════════════════════════════════════════════════════════════════════════════════╗ +║ termDRAW workspace ║ +║ ┌──────────────────────────────────────────────────────────────────────────────────────────┐ ║ +║ │ workspace root │ ║ +║ │ package.json scripts: format | lint | test | typecheck | build | smoke:pi │ ║ +║ └──────────────────────────────────────────────────────────────────────────────────────────┘ ║ +║ ║ +║ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ║ +║ ┌────────────────────────┐ ┃ packages/opentui ┃ ┌──────────────────────┐ ║ +║ │ packages/app │ ┃ ┃ │ packages/pi │ ║ +║ │ │ ┃ @termdraw/opentui ┃ │ │ ║ +║ │ @termdraw/app │ ┃ ┃ │ @termdraw/pi │ ║ +║ │ │ ┃ draw-state.ts ┃ │ │ ║ +║ │ src/cli.tsx │ ┃ objects/tools/history ┃ │ overlay.ts │ ║ +║ │ │────>┃ ┃─────>│ │ ║ +║ │ src/main.tsx │ ┃ app.ts renderable ┃ │ termdraw.island.tsx │ ║ +║ │ │ ┃ ┃ │ │ ║ +║ │ TermDrawApp │ ┃ app/input.ts keys+mouse ┃ │ onSave bridge │ ║ +║ │ │ ┃ ┃ │ │ ║ +║ │ │ ┃ app/render.ts canvas ┃ │ │ ║ +║ └────────────────────────┘ ┃ ┃ └──────────────────────┘ ║ +║ │ ┃ react.ts components ┃ │ ║ +║ │ ┃ ┃ │ ║ +║ v ┃ ┃ v ║ +║ ╔══════════════════════╗ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ╔══════════════════════╗ ║ +║ ║ terminal CLI ║ v ║ Pi editor ║ ║ +║ ║ stdout / --output ║ ╔════════════════════╗ ║ insert saved art ║ ║ +║ ║ ║ ║ saved art ║ ║ ║ ║ +║ ╚══════════════════════╝ ║ plain or fenced ║ ╚══════════════════════╝ ║ +║ ╚════════════════════╝ ║ +╚══════════════════════════════════════════════════════════════════════════════════════════════╝ +``` + +- `packages/app` wraps the full OpenTUI app as the standalone `termdraw` CLI. +- `packages/opentui` owns the reusable editor state, rendering, input handling, React bindings, and text export. +- `packages/pi` embeds the editor through an island bridge and returns saved art to Pi. + ## Install the app Requirements: @@ -57,12 +97,37 @@ termdraw --output diagram.txt # export a fenced Markdown code block termdraw --fenced > diagram.md +# render a JSONC diagram document without opening the editor +termdraw compile docs/examples/service-flow.diagram.jsonc + # show CLI help termdraw --help ``` termDRAW! outputs terminal text, not SVG or bitmap graphics. +## Compile diagrams from JSONC + +`termdraw compile` renders a compact diagram document through the same retained drawing model used by the interactive editor. It accepts a file path, `-`, or stdin. + +```jsonc +{ + "version": 1, + "size": [24, 5], + "objects": [ + { "type": "box", "rect": [0, 0, 8, 2], "style": "light", "text": "CLI" }, + { "type": "line", "from": [9, 1], "to": [14, 1], "style": "light", "marker": ">" }, + { "type": "text", "at": [16, 1], "text": "text" }, + ], +} +``` + +```text +┌───────┐ +│ CLI │─────> text +└───────┘ +``` + ## Use it in Pi ```bash diff --git a/bun.lock b/bun.lock index 7ab7732..4d2cf90 100644 --- a/bun.lock +++ b/bun.lock @@ -16,14 +16,15 @@ }, "packages/app": { "name": "@termdraw/app", - "version": "0.3.0", + "version": "0.3.3", "bin": { "termdraw": "./dist/cli.js", }, "dependencies": { "@opentui/core": "^0.1.97", "@opentui/react": "^0.1.97", - "@termdraw/opentui": "0.3.0", + "@termdraw/opentui": "0.3.3", + "jsonc-parser": "^3.3.1", "react": "^19.2.5", }, "devDependencies": { @@ -36,7 +37,7 @@ }, "packages/opentui": { "name": "@termdraw/opentui", - "version": "0.3.0", + "version": "0.3.3", "devDependencies": { "@opentui/core": "^0.1.97", "@opentui/react": "^0.1.97", @@ -55,11 +56,11 @@ }, "packages/pi": { "name": "@termdraw/pi", - "version": "0.1.0", + "version": "0.3.3", "dependencies": { "@opentui/core": "^0.1.97", "@opentui/react": "^0.1.97", - "@termdraw/opentui": "0.3.0", + "@termdraw/opentui": "0.3.3", "opentui-island": "^0.4.0", "react": "^19.2.0", }, @@ -70,9 +71,15 @@ "typescript": "^5.9.3", }, "peerDependencies": { + "@mariozechner/pi-ai": "*", "@mariozechner/pi-coding-agent": "*", "@mariozechner/pi-tui": "*", }, + "optionalPeers": [ + "@mariozechner/pi-ai", + "@mariozechner/pi-coding-agent", + "@mariozechner/pi-tui", + ], }, }, "packages": { @@ -654,6 +661,8 @@ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], @@ -894,9 +903,9 @@ "@mariozechner/pi-tui/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - "@termdraw/app/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@termdraw/app/@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], - "@termdraw/opentui/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@termdraw/opentui/@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/yauzl/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], @@ -942,9 +951,9 @@ "@jimp/core/file-type/token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], - "@termdraw/app/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "@termdraw/app/@types/bun/bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], - "@termdraw/opentui/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "@termdraw/opentui/@types/bun/bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "@types/yauzl/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/docs/examples/architecture.diagram.jsonc b/docs/examples/architecture.diagram.jsonc new file mode 100644 index 0000000..fe29529 --- /dev/null +++ b/docs/examples/architecture.diagram.jsonc @@ -0,0 +1,197 @@ +{ + "version": 1, + "size": [96, 31], + "objects": [ + { + "type": "box", + "id": "frame", + "rect": [0, 0, 95, 30], + "style": "double", + "text": "termDRAW workspace", + }, + { + "type": "box", + "id": "root", + "rect": [2, 2, 93, 5], + "style": "light", + "text": "workspace root", + }, + { + "type": "text", + "at": [5, 4], + "text": "package.json scripts: format | lint | test | typecheck | build | smoke:pi", + }, + { + "type": "box", + "id": "app", + "rect": [2, 8, 27, 20], + "style": "light", + "text": "packages/app", + }, + { + "type": "text", + "at": [5, 11], + "text": "@termdraw/app", + }, + { + "type": "text", + "at": [5, 13], + "text": "src/cli.tsx", + }, + { + "type": "text", + "at": [5, 15], + "text": "src/main.tsx", + }, + { + "type": "text", + "at": [5, 17], + "text": "TermDrawApp", + }, + { + "type": "box", + "id": "core", + "rect": [33, 7, 63, 24], + "style": "heavy", + "text": "packages/opentui", + }, + { + "type": "text", + "at": [36, 10], + "text": "@termdraw/opentui", + }, + { + "type": "text", + "at": [36, 12], + "text": "draw-state.ts", + }, + { + "type": "text", + "at": [39, 13], + "text": "objects/tools/history", + }, + { + "type": "text", + "at": [36, 15], + "text": "app.ts renderable", + }, + { + "type": "text", + "at": [36, 17], + "text": "app/input.ts keys+mouse", + }, + { + "type": "text", + "at": [36, 19], + "text": "app/render.ts canvas", + }, + { + "type": "text", + "at": [36, 21], + "text": "react.ts components", + }, + { + "type": "box", + "id": "pi", + "rect": [70, 8, 93, 20], + "style": "light", + "text": "packages/pi", + }, + { + "type": "text", + "at": [73, 11], + "text": "@termdraw/pi", + }, + { + "type": "text", + "at": [73, 13], + "text": "overlay.ts", + }, + { + "type": "text", + "at": [73, 15], + "text": "termdraw.island.tsx", + }, + { + "type": "text", + "at": [73, 17], + "text": "onSave bridge", + }, + { + "type": "box", + "id": "cli-output", + "rect": [4, 24, 27, 28], + "style": "double", + "text": "terminal CLI", + }, + { + "type": "text", + "at": [7, 26], + "text": "stdout / --output", + }, + { + "type": "box", + "id": "saved-art", + "rect": [38, 26, 59, 29], + "style": "double", + "text": "saved art", + }, + { + "type": "text", + "at": [41, 28], + "text": "plain or fenced", + }, + { + "type": "box", + "id": "pi-output", + "rect": [70, 24, 93, 28], + "style": "double", + "text": "Pi editor", + }, + { + "type": "text", + "at": [73, 26], + "text": "insert saved art", + }, + { + "type": "line", + "id": "app-core", + "from": [28, 14], + "to": [32, 14], + "style": "light", + "marker": ">", + }, + { + "type": "line", + "id": "core-pi", + "from": [64, 14], + "to": [69, 14], + "style": "light", + "marker": ">", + }, + { + "type": "line", + "id": "app-output", + "from": [15, 21], + "to": [15, 23], + "style": "light", + "marker": "v", + }, + { + "type": "line", + "id": "core-output", + "from": [48, 25], + "to": [48, 25], + "style": "light", + "marker": "v", + }, + { + "type": "line", + "id": "pi-output-edge", + "from": [82, 21], + "to": [82, 23], + "style": "light", + "marker": "v", + }, + ], +} diff --git a/docs/examples/service-flow.diagram.jsonc b/docs/examples/service-flow.diagram.jsonc new file mode 100644 index 0000000..04e8bc4 --- /dev/null +++ b/docs/examples/service-flow.diagram.jsonc @@ -0,0 +1,44 @@ +{ + "version": 1, + "size": [42, 11], + "objects": [ + { + "type": "box", + "id": "cli", + "rect": [0, 0, 13, 4], + "style": "light", + "text": "CLI", + }, + { + "type": "box", + "id": "core", + "rect": [20, 0, 41, 4], + "style": "heavy", + "text": "DrawState", + }, + { + "type": "line", + "from": [14, 2], + "to": [19, 2], + "style": "light", + "marker": ">", + }, + { + "type": "text", + "at": [2, 7], + "text": "JSONC in", + }, + { + "type": "line", + "from": [10, 7], + "to": [20, 7], + "style": "light", + "marker": ">", + }, + { + "type": "text", + "at": [22, 7], + "text": "terminal text out", + }, + ], +} diff --git a/package.json b/package.json index 4f1dabc..9c31a8b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "cd packages/app && bun --watch run src/cli.tsx", "clean": "rm -rf dist packages/app/dist packages/opentui/dist", "build": "cd packages/opentui && bun run build && cd ../app && bun run build", - "test": "cd packages/opentui && bun test", + "test": "cd packages/opentui && bun test && cd ../app && bun test", "typecheck": "cd packages/opentui && bun run typecheck && cd ../app && bun run typecheck && cd ../pi && bun run typecheck", "pack:check": "cd packages/opentui && bun run pack:check && cd ../app && bun run pack:check && cd ../pi && bun run pack:check", "smoke:pi": "cd packages/pi && bun run smoke:pi", diff --git a/packages/app/README.md b/packages/app/README.md index 3cc6681..188d8ce 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -37,12 +37,37 @@ termdraw --output diagram.txt # export a fenced Markdown code block termdraw --fenced > diagram.md +# render a JSONC diagram document without opening the editor +termdraw compile diagram.jsonc + # show CLI help termdraw --help ``` termDRAW! outputs terminal text, not SVG or bitmap graphics. +## Compile diagrams from JSONC + +`termdraw compile` renders a compact diagram document through the same retained drawing model used by the interactive editor. It accepts a file path, `-`, or stdin. + +```jsonc +{ + "version": 1, + "size": [24, 5], + "objects": [ + { "type": "box", "rect": [0, 0, 8, 2], "style": "light", "text": "CLI" }, + { "type": "line", "from": [9, 1], "to": [14, 1], "style": "light", "marker": ">" }, + { "type": "text", "at": [16, 1], "text": "text" }, + ], +} +``` + +```text +┌───────┐ +│ CLI │─────> text +└───────┘ +``` + ## OpenTUI package If you want the embeddable OpenTUI components instead of the packaged app: diff --git a/packages/app/package.json b/packages/app/package.json index 198b7a8..6810cd6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -53,6 +53,7 @@ "pack:check": "bun run build && npm pack --dry-run --ignore-scripts", "prepack": "bun run build", "prepublishOnly": "bun run lint && bun run typecheck", + "test": "bun test", "typecheck": "tsc --noEmit", "format": "oxfmt .", "format:check": "oxfmt --check .", @@ -63,6 +64,7 @@ "@opentui/core": "^0.1.97", "@opentui/react": "^0.1.97", "@termdraw/opentui": "0.3.3", + "jsonc-parser": "^3.3.1", "react": "^19.2.5" }, "devDependencies": { diff --git a/packages/app/src/compile.test.ts b/packages/app/src/compile.test.ts new file mode 100644 index 0000000..138f7c8 --- /dev/null +++ b/packages/app/src/compile.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { compileDiagramSource, renderDiagramSource } from "./compile.js"; + +describe("diagram compiler", () => { + test("renders boxes, lines, paint, text, markers, and fenced output", () => { + const source = `{ + // JSONC comments and trailing commas are accepted. + "version": 1, + "size": [24, 10], + "objects": [ + { "type": "box", "id": "outer", "rect": [0, 0, 10, 4], "style": "light", "text": "App" }, + { "type": "line", "from": [11, 2], "to": [17, 2], "style": "light", "marker": ">" }, + { "type": "text", "at": [18, 1], "text": "Core", "border": "single" }, + { "type": "paint", "brush": "*", "points": [[2, 6], [3, 6], [4, 6]] }, + ], + }`; + + const art = renderDiagramSource(source); + + expect(art).toContain("┌─────────┐"); + expect(art).toContain("│ App"); + expect(art).toContain("──────>"); + expect(art).toContain("┌────┐"); + expect(art).toContain("***"); + expect(compileDiagramSource(source, { fenced: true })).toStartWith("```text\n"); + }); + + test("rejects malformed documents with actionable paths", () => { + expect(() => + renderDiagramSource(`{ + "version": 1, + "size": [4, 4], + "objects": [ + { "type": "box", "rect": [0, 0, 8, 1], "style": "light" } + ] + }`), + ).toThrow("$.objects[0].rect[2..3]: point 8,1 is outside 4x4"); + + expect(() => + renderDiagramSource(`{ + "version": 1, + "size": [4, 4], + "objects": [ + { "type": "box", "id": "same", "rect": [0, 0, 1, 1] }, + { "type": "text", "id": "same", "at": [0, 2], "text": "x" } + ] + }`), + ).toThrow('$.objects[1].id: duplicate id "same"'); + }); +}); diff --git a/packages/app/src/compile.ts b/packages/app/src/compile.ts new file mode 100644 index 0000000..b25e131 --- /dev/null +++ b/packages/app/src/compile.ts @@ -0,0 +1,379 @@ +import { parse, printParseErrorCode, type ParseError } from "jsonc-parser"; +import { + BOX_STYLES, + DrawState, + INK_COLORS, + LINE_STYLES, + TEXT_BORDER_MODES, + formatSavedOutput, + type DrawObject, + type InkColor, + type TextBorderMode, +} from "../../opentui/src/index.js"; + +type JsonRecord = Record; +type PointTuple = [number, number]; +type RectTuple = [number, number, number, number]; + +export type DiagramCompileOptions = { + fenced?: boolean; +}; + +type DiagramDefaults = { + color: InkColor; +}; + +type NormalizedDiagram = { + width: number; + height: number; + defaults: DiagramDefaults; + objects: JsonRecord[]; +}; + +const DEFAULT_COLOR: InkColor = "white"; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function getLineColumn(source: string, offset: number): { line: number; column: number } { + const prefix = source.slice(0, offset); + const lines = prefix.split("\n"); + return { + line: lines.length, + column: (lines.at(-1)?.length ?? 0) + 1, + }; +} + +function formatParseError(source: string, error: ParseError): string { + const location = getLineColumn(source, error.offset); + return `${printParseErrorCode(error.error)} at ${location.line}:${location.column}`; +} + +function fail(path: string, message: string): never { + throw new Error(`${path}: ${message}`); +} + +function readString(record: JsonRecord, key: string, path: string): string { + const value = record[key]; + if (typeof value !== "string") fail(`${path}.${key}`, "expected a string"); + return value; +} + +function readOptionalString(record: JsonRecord, key: string, path: string): string | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== "string") fail(`${path}.${key}`, "expected a string"); + return value; +} + +function readTuple(value: unknown, length: number, path: string): number[] { + if (!Array.isArray(value) || value.length !== length) { + fail(path, `expected an array with ${length} numbers`); + } + + return value.map((entry, index) => { + if (!Number.isInteger(entry)) fail(`${path}[${index}]`, "expected an integer"); + return entry as number; + }); +} + +function readPoint(record: JsonRecord, key: string, path: string): PointTuple { + return readTuple(record[key], 2, `${path}.${key}`) as PointTuple; +} + +function readRect(record: JsonRecord, key: string, path: string): RectTuple { + return readTuple(record[key], 4, `${path}.${key}`) as RectTuple; +} + +function readKnownValue( + value: unknown, + allowed: readonly T[], + path: string, + fallback: T, +): T { + if (value === undefined) return fallback; + if (typeof value !== "string" || !allowed.includes(value as T)) { + fail(path, `expected one of: ${allowed.join(", ")}`); + } + return value as T; +} + +function assertPointInBounds(point: PointTuple, width: number, height: number, path: string): void { + const [x, y] = point; + if (x < 0 || x >= width || y < 0 || y >= height) { + fail(path, `point ${x},${y} is outside ${width}x${height}`); + } +} + +function assertRectInBounds(rect: RectTuple, width: number, height: number, path: string): void { + const [left, top, right, bottom] = rect; + if (left > right || top > bottom) { + fail(path, "expected left <= right and top <= bottom"); + } + assertPointInBounds([left, top], width, height, `${path}[0..1]`); + assertPointInBounds([right, bottom], width, height, `${path}[2..3]`); +} + +function textWidth(text: string): number { + return Array.from(text).length; +} + +function assertTextInBounds( + at: PointTuple, + text: string, + border: TextBorderMode, + width: number, + height: number, + path: string, +): void { + const [x, y] = at; + const contentWidth = Math.max(1, textWidth(text)); + const right = border === "none" ? x + contentWidth - 1 : x + contentWidth + 1; + const bottom = border === "none" ? y : y + 2; + assertRectInBounds([x, y, right, bottom], width, height, path); +} + +function parseDiagramSource(source: string): NormalizedDiagram { + const errors: ParseError[] = []; + const parsed = parse(source, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + + if (errors.length > 0) { + throw new Error( + `Invalid JSONC: ${errors.map((error) => formatParseError(source, error)).join("; ")}`, + ); + } + + if (!isRecord(parsed)) fail("$", "expected an object"); + if (parsed.version !== 1) fail("$.version", "expected version 1"); + + const size = readTuple(parsed.size, 2, "$.size"); + const width = size[0]!; + const height = size[1]!; + if (width <= 0 || height <= 0) fail("$.size", "expected positive dimensions"); + + const defaultsValue = parsed.defaults; + const defaultsRecord = defaultsValue === undefined ? {} : defaultsValue; + if (!isRecord(defaultsRecord)) fail("$.defaults", "expected an object"); + const defaults: DiagramDefaults = { + color: readKnownValue(defaultsRecord.color, INK_COLORS, "$.defaults.color", DEFAULT_COLOR), + }; + + if (!Array.isArray(parsed.objects)) fail("$.objects", "expected an array"); + const objects = parsed.objects.map((object, index) => { + if (!isRecord(object)) fail(`$.objects[${index}]`, "expected an object"); + return object; + }); + + return { + width, + height, + defaults, + objects, + }; +} + +function normalizeCell(input: string): string { + return Array.from(input)[0] ?? " "; +} + +function createObjectId(record: JsonRecord, index: number, usedIds: Set): string { + const rawId = record.id; + if (rawId !== undefined && typeof rawId !== "string") { + fail(`$.objects[${index}].id`, "expected a string"); + } + + let id = rawId ?? `object-${index + 1}`; + if (usedIds.has(id)) fail(`$.objects[${index}].id`, `duplicate id "${id}"`); + + usedIds.add(id); + return id; +} + +function createGeneratedObjectId(baseId: string, suffix: string, usedIds: Set): string { + let id = `${baseId}:${suffix}`; + let counter = 2; + while (usedIds.has(id)) { + id = `${baseId}:${suffix}-${counter}`; + counter += 1; + } + usedIds.add(id); + return id; +} + +function getObjectColor(record: JsonRecord, path: string, defaults: DiagramDefaults): InkColor { + return readKnownValue(record.color, INK_COLORS, `${path}.color`, defaults.color); +} + +function compileObjects(diagram: NormalizedDiagram): DrawObject[] { + const objects: DrawObject[] = []; + const usedIds = new Set(); + let z = 1; + + for (const [index, record] of diagram.objects.entries()) { + const path = `$.objects[${index}]`; + const type = readString(record, "type", path); + const id = createObjectId(record, index, usedIds); + const color = getObjectColor(record, path, diagram.defaults); + + if (type === "box") { + const rect = readRect(record, "rect", path); + assertRectInBounds(rect, diagram.width, diagram.height, `${path}.rect`); + const style = readKnownValue(record.style, BOX_STYLES, `${path}.style`, "auto"); + const [left, top, right, bottom] = rect; + + objects.push({ + id, + z, + parentId: null, + color, + type: "box", + left, + top, + right, + bottom, + style, + }); + z += 1; + + const label = readOptionalString(record, "text", path); + if (label !== undefined) { + const at: PointTuple = [left + 2, top + 1]; + assertTextInBounds(at, label, "none", diagram.width, diagram.height, `${path}.text`); + objects.push({ + id: createGeneratedObjectId(id, "text", usedIds), + z, + parentId: null, + color, + type: "text", + x: at[0], + y: at[1], + content: label, + border: "none", + }); + z += 1; + } + continue; + } + + if (type === "line") { + const from = readPoint(record, "from", path); + const to = readPoint(record, "to", path); + assertPointInBounds(from, diagram.width, diagram.height, `${path}.from`); + assertPointInBounds(to, diagram.width, diagram.height, `${path}.to`); + const style = readKnownValue(record.style, LINE_STYLES, `${path}.style`, "smooth"); + + objects.push({ + id, + z, + parentId: null, + color, + type: "line", + x1: from[0], + y1: from[1], + x2: to[0], + y2: to[1], + style, + }); + z += 1; + + const marker = readOptionalString(record, "marker", path); + if (marker !== undefined) { + const markerAt = record.markerAt; + const at = + markerAt === undefined || markerAt === "to" + ? to + : markerAt === "from" + ? from + : (readTuple(markerAt, 2, `${path}.markerAt`) as PointTuple); + assertTextInBounds(at, marker, "none", diagram.width, diagram.height, `${path}.markerAt`); + objects.push({ + id: createGeneratedObjectId(id, "marker", usedIds), + z, + parentId: null, + color, + type: "text", + x: at[0], + y: at[1], + content: normalizeCell(marker), + border: "none", + }); + z += 1; + } + continue; + } + + if (type === "paint") { + const pointsValue = record.points; + if (!Array.isArray(pointsValue) || pointsValue.length === 0) { + fail(`${path}.points`, "expected a non-empty point array"); + } + const points = pointsValue.map((point, pointIndex) => { + const normalizedPoint = readTuple(point, 2, `${path}.points[${pointIndex}]`) as PointTuple; + assertPointInBounds( + normalizedPoint, + diagram.width, + diagram.height, + `${path}.points[${pointIndex}]`, + ); + return { x: normalizedPoint[0], y: normalizedPoint[1] }; + }); + + objects.push({ + id, + z, + parentId: null, + color, + type: "paint", + points, + brush: normalizeCell(readOptionalString(record, "brush", path) ?? "#"), + }); + z += 1; + continue; + } + + if (type === "text") { + const at = readPoint(record, "at", path); + const content = readString(record, "text", path); + const border = readKnownValue(record.border, TEXT_BORDER_MODES, `${path}.border`, "none"); + assertTextInBounds(at, content, border, diagram.width, diagram.height, `${path}.at`); + + objects.push({ + id, + z, + parentId: null, + color, + type: "text", + x: at[0], + y: at[1], + content, + border, + }); + z += 1; + continue; + } + + fail(`${path}.type`, "expected box, line, paint, or text"); + } + + return objects; +} + +export function renderDiagramSource(source: string): string { + const diagram = parseDiagramSource(source); + const state = new DrawState(diagram.width, diagram.height, { + left: 0, + top: 0, + right: 0, + bottom: 0, + }); + state.loadObjects(compileObjects(diagram)); + return state.exportArt(); +} + +export function compileDiagramSource(source: string, options: DiagramCompileOptions = {}): string { + return formatSavedOutput(renderDiagramSource(source), options.fenced === true); +} diff --git a/packages/app/src/main.test.ts b/packages/app/src/main.test.ts new file mode 100644 index 0000000..118a61d --- /dev/null +++ b/packages/app/src/main.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { buildCompileHelpText, parseArgs } from "./main.js"; + +describe("app CLI args", () => { + test("keeps interactive mode as the default", () => { + expect(parseArgs([])).toEqual({ + command: "draw", + fenced: false, + help: false, + }); + + expect(parseArgs(["--fenced", "--output", "diagram.md"])).toEqual({ + command: "draw", + fenced: true, + help: false, + outputPath: "diagram.md", + }); + }); + + test("parses compile mode with file, stdin, output, and fenced options", () => { + expect(parseArgs(["compile", "diagram.jsonc", "--fenced", "--output", "out.md"])).toEqual({ + command: "compile", + inputPath: "diagram.jsonc", + fenced: true, + help: false, + outputPath: "out.md", + }); + + expect(parseArgs(["compile", "-"])).toEqual({ + command: "compile", + inputPath: "-", + fenced: false, + help: false, + }); + }); + + test("rejects extra compile input paths", () => { + expect(() => parseArgs(["compile", "one.jsonc", "two.jsonc"])).toThrow( + "Unexpected input path: two.jsonc", + ); + }); + + test("documents compile usage", () => { + expect(buildCompileHelpText()).toContain("termdraw compile [input|-]"); + }); +}); diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx index 35f2cbe..5d5e93b 100644 --- a/packages/app/src/main.tsx +++ b/packages/app/src/main.tsx @@ -1,8 +1,11 @@ import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { buildHelpText, formatSavedOutput, TermDrawApp } from "../../opentui/src/index.js"; +import { compileDiagramSource } from "./compile.js"; export interface CliOptions { + command: "draw" | "compile"; + inputPath?: string; outputPath?: string; fenced: boolean; help: boolean; @@ -10,10 +13,16 @@ export interface CliOptions { export function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { + command: "draw", fenced: false, help: false, }; + if (argv[0] === "compile") { + options.command = "compile"; + argv = argv.slice(1); + } + for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]!; @@ -42,6 +51,19 @@ export function parseArgs(argv: string[]): CliOptions { continue; } + if ( + options.command === "compile" && + (arg === "-" || !arg.startsWith("-")) && + options.inputPath === undefined + ) { + options.inputPath = arg; + continue; + } + + if (options.command === "compile" && (arg === "-" || !arg.startsWith("-"))) { + throw new Error(`Unexpected input path: ${arg}`); + } + throw new Error(`Unknown argument: ${arg}`); } @@ -52,11 +74,64 @@ function withTrailingNewline(text: string): string { return text.endsWith("\n") ? text : `${text}\n`; } +export function buildTermDrawCliHelp(): string { + return ( + buildHelpText("termdraw") + + `\nCommands:\n` + + ` compile [input|-] render a JSONC diagram document to terminal text\n` + ); +} + +export function buildCompileHelpText(): string { + return ( + `termdraw compile [input|-] [--output file] [--fenced|--plain]\n\n` + + `Renders a JSONC diagram document through termDRAW's retained drawing model.\n\n` + + `Arguments:\n` + + ` input JSONC diagram file. Use - or omit to read stdin.\n\n` + + `Options:\n` + + ` -o, --output write the rendered diagram to a file\n` + + ` --fenced output as a fenced markdown code block\n` + + ` --plain output plain text (default)\n` + + ` -h, --help show this help\n` + ); +} + +async function readCompileInput(inputPath: string | undefined): Promise { + if (inputPath === undefined || inputPath === "-") { + return Bun.stdin.text(); + } + + return Bun.file(inputPath).text(); +} + +async function runCompileCli(options: CliOptions): Promise { + if (options.help) { + process.stdout.write(buildCompileHelpText()); + return; + } + + const source = await readCompileInput(options.inputPath); + const output = withTrailingNewline(compileDiagramSource(source, { fenced: options.fenced })); + + if (options.outputPath) { + await Bun.write(options.outputPath, output); + process.stderr.write(`Rendered diagram to ${options.outputPath}\n`); + return; + } + + process.stdout.write(output); +} + export async function runTermDrawAppCli(argv = Bun.argv.slice(2)): Promise { const options = parseArgs(argv); + if (options.command === "compile") { + await runCompileCli(options); + return; + } + if (options.help) { - process.stdout.write(buildHelpText("termdraw")); + process.stdout.write(buildTermDrawCliHelp()); return; } diff --git a/packages/opentui/src/draw-state.test.ts b/packages/opentui/src/draw-state.test.ts index 4f653d7..dd2b8c0 100644 --- a/packages/opentui/src/draw-state.test.ts +++ b/packages/opentui/src/draw-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { MouseButton } from "@opentui/core"; -import { DrawState, TEXT_BORDER_MODES } from "./draw-state"; +import { DrawState, TEXT_BORDER_MODES, type DrawObject } from "./draw-state"; /** Converts canvas-local coordinates into the pointer coordinates expected by `DrawState`. */ function canvasPoint(state: DrawState, x: number, y: number) { @@ -11,6 +11,39 @@ function canvasPoint(state: DrawState, x: number, y: number) { } describe("DrawState", () => { + test("loads a retained object document for non-interactive rendering", () => { + const state = new DrawState(20, 8); + const objects: DrawObject[] = [ + { + id: "box", + z: 1, + parentId: null, + color: "white", + type: "box", + left: 0, + top: 0, + right: 5, + bottom: 2, + style: "light", + }, + { + id: "label", + z: 2, + parentId: null, + color: "white", + type: "text", + x: 2, + y: 1, + content: "A", + border: "none", + }, + ]; + + state.loadObjects(objects); + + expect(state.exportArt()).toBe("┌────┐\n│ A │\n└────┘"); + }); + test("draws a straight line object with pointer events", () => { const state = new DrawState(20, 10); const start = canvasPoint(state, 0, 0); diff --git a/packages/opentui/src/draw-state.ts b/packages/opentui/src/draw-state.ts index 0df23cb..57e6736 100644 --- a/packages/opentui/src/draw-state.ts +++ b/packages/opentui/src/draw-state.ts @@ -865,6 +865,25 @@ export class DrawState { this.setStatus(`Nothing to delete at ${this.cursorX + 1},${this.cursorY + 1}.`); } + /** Replaces the current document with a prebuilt retained object list. */ + public loadObjects(objects: DrawObject[]): void { + this.setObjects(cloneObjects(objects)); + this.setSelectedObjects([]); + this.activeTextObjectId = null; + this.textEntryArmed = false; + this.pendingSelection = null; + this.pendingLine = null; + this.pendingBox = null; + this.pendingPaint = null; + this.dragState = null; + this.eraseState = null; + this.undoStack = []; + this.redoStack = []; + this.nextObjectNumber = objects.length + 1; + this.nextZIndex = Math.max(0, ...objects.map((object) => object.z)) + 1; + this.setStatus("Loaded document objects."); + } + /** Deletes the current selection and returns whether anything was removed. */ public deleteSelectedObject(): boolean { const selected = this.getSelectedObjects();