From 9988d23b7970896f34768ea7415d374b2eca5f7b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 11 Apr 2026 10:01:45 -0400 Subject: [PATCH 1/3] feat: split publishable packages into app and opentui --- .github/workflows/publish.yml | 47 ++++- CHANGELOG.md | 18 +- README.md | 152 +++++---------- bun.lock | 81 +++++--- index.ts | 4 +- package.json | 14 +- packages/{tui => app}/LICENSE | 0 packages/app/README.md | 73 ++++++++ packages/app/package.json | 77 ++++++++ packages/app/src/cli.tsx | 9 + packages/app/src/index.ts | 1 + .../{tui/src/cli.tsx => app/src/main.tsx} | 21 +-- packages/app/tsconfig.build.json | 12 ++ packages/app/tsconfig.json | 4 + packages/{tui => opentui}/CHANGELOG.md | 17 +- packages/opentui/LICENSE | 21 +++ packages/opentui/README.md | 90 +++++++++ packages/opentui/index.ts | 1 + packages/{tui => opentui}/package.json | 17 +- packages/{tui => opentui}/src/app.ts | 0 .../{tui => opentui}/src/draw-state.test.ts | 0 packages/{tui => opentui}/src/draw-state.ts | 0 packages/{tui => opentui}/src/index.ts | 0 packages/{tui => opentui}/src/react.test.tsx | 0 packages/{tui => opentui}/src/react.ts | 0 packages/{tui => opentui}/tsconfig.build.json | 0 packages/{tui => opentui}/tsconfig.json | 0 packages/pi/README.md | 52 +++--- packages/pi/islands/termdraw.island.tsx | 2 +- packages/pi/package.json | 17 +- packages/pi/tsconfig.json | 2 +- packages/tui/README.md | 173 ------------------ packages/tui/index.ts | 5 - 33 files changed, 519 insertions(+), 391 deletions(-) rename packages/{tui => app}/LICENSE (100%) create mode 100644 packages/app/README.md create mode 100644 packages/app/package.json create mode 100644 packages/app/src/cli.tsx create mode 100644 packages/app/src/index.ts rename packages/{tui/src/cli.tsx => app/src/main.tsx} (80%) create mode 100644 packages/app/tsconfig.build.json create mode 100644 packages/app/tsconfig.json rename packages/{tui => opentui}/CHANGELOG.md (75%) create mode 100644 packages/opentui/LICENSE create mode 100644 packages/opentui/README.md create mode 100644 packages/opentui/index.ts rename packages/{tui => opentui}/package.json (75%) rename packages/{tui => opentui}/src/app.ts (100%) rename packages/{tui => opentui}/src/draw-state.test.ts (100%) rename packages/{tui => opentui}/src/draw-state.ts (100%) rename packages/{tui => opentui}/src/index.ts (100%) rename packages/{tui => opentui}/src/react.test.tsx (100%) rename packages/{tui => opentui}/src/react.ts (100%) rename packages/{tui => opentui}/tsconfig.build.json (100%) rename packages/{tui => opentui}/tsconfig.json (100%) delete mode 100644 packages/tui/README.md delete mode 100644 packages/tui/index.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 437895c..803a5cc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish to npm +name: Publish packages on: workflow_dispatch: @@ -7,6 +7,16 @@ on: description: Git ref to publish required: true default: main + package: + description: Package to publish + required: true + default: all + type: choice + options: + - all + - opentui + - app + - pi jobs: publish: @@ -14,6 +24,8 @@ jobs: permissions: contents: read id-token: write + env: + SKIP_INSTALL_SIMPLE_GIT_HOOKS: "1" steps: - name: Check out repository @@ -24,7 +36,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.10 - name: Set up Node.js uses: actions/setup-node@v4 @@ -35,10 +47,35 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Verify package + - name: Verify workspace run: bun run check - - name: Publish package - run: npm publish --access public --provenance + - name: Verify publishable package contents + run: bun run pack:check + + - name: Publish selected packages env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + publish_package() { + local package_dir="$1" + echo "Publishing ${package_dir}" + (cd "${package_dir}" && npm publish --access public --provenance) + } + + case "${{ inputs.package }}" in + opentui) + publish_package packages/opentui + ;; + app) + publish_package packages/app + ;; + pi) + publish_package packages/pi + ;; + all) + publish_package packages/opentui + publish_package packages/app + publish_package packages/pi + ;; + esac diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f9031d..b9f41c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v0.3.0 + +Splits the published surface into dedicated app, OpenTUI, and Pi packages. + +### Highlights + +- publishes the standalone terminal app as `@termdraw/app` +- publishes the embeddable OpenTUI package as `@termdraw/opentui` +- publishes the Pi integration as `@termdraw/pi` +- keeps the `termdraw` executable as the main app entrypoint + ## v0.2.0 Adds a dedicated select tool for selection-first editing workflows. @@ -26,10 +37,3 @@ Initial public release of termDRAW!. - embeddable OpenTUI React components: - `TermDrawApp` for the full chrome - `TermDrawEditor` for the bare editor surface - -### Packaging - -- publish-ready npm package configuration -- CLI entrypoint exposed as `termdraw` -- package name configured as `@benvinegar/termdraw` -- manual GitHub Actions workflow for npm publishing diff --git a/README.md b/README.md index 1a36db2..75a1818 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,57 @@ # termDRAW! -termDRAW! is an object-based terminal illustrator for diagrams, UI mocks, and terminal-native graphics. +termDRAW! is a terminal drawing editor for developers who want editable diagrams, UI mocks, and text graphics without leaving the terminal. -## Why termDRAW! +## Packages -- Make terminal-native diagrams without leaving your terminal. -- Keep editing as you think: drawn elements stay selectable, movable, and resizable. -- Group related content inside boxes so diagrams stay organized while you iterate. -- Export plain text or fenced Markdown for READMEs, docs, tickets, and agent prompts. +- `@termdraw/app` — the standalone terminal app with the `termdraw` command +- `@termdraw/opentui` — embeddable OpenTUI components and renderables +- `@termdraw/pi` — Pi package that opens termDRAW in a Pi overlay -## Install +## Install the app Requirements: -- [Bun](https://bun.sh) +- [Bun](https://bun.sh) 1.3+ - A terminal with mouse support -From npm: - -```bash -bun add @benvinegar/termdraw -``` - -Or from source: - ```bash -git clone https://github.com/benvinegar/termdraw.git -cd termdraw -bun install +npm install --global @termdraw/app ``` ## Quick start -Start the app from a local checkout: - -```bash -bun run start -``` - -Or run the published CLI: - -```bash -bunx @benvinegar/termdraw -``` - -Draw something, then press `Enter` or `Ctrl+S` to save. By default, termDRAW! writes the result to stdout after the app exits. - -Write directly to a file: - ```bash -bun run start -- --output diagram.txt +termdraw ``` -Export as a fenced Markdown code block: +Draw something, then press `Enter` or `Ctrl+S` to write the result to stdout. -```bash -bun run start -- --fenced > diagram.md -``` - -Show CLI help: +## App usage ```bash -bun run start -- --help -``` - -## Usage - -termDRAW! behaves more like a small vector-style editor than a paint program. Lines, boxes, and text are retained objects, so you can keep rearranging the diagram after you draw it. Boxes can also act as frames for fully contained children. - -Everything still snaps to terminal cells. termDRAW! outputs terminal art, not SVG or bitmap graphics. +# save plain text directly to a file +termdraw --output diagram.txt -Controls are shown in the app footer and tool palette. +# export a fenced Markdown code block +termdraw --fenced > diagram.md -## Output examples - -Plain text to stdout: - -```bash -bun run start > drawing.txt +# show CLI help +termdraw --help ``` -Plain text to a file: - -```bash -bun run start -- --output drawing.txt -``` +termDRAW! outputs terminal text, not SVG or bitmap graphics. -Markdown fenced output: +## Embed in an OpenTUI app ```bash -bun run start -- --fenced > drawing.md +npm install @termdraw/opentui @opentui/core @opentui/react react ``` -## Embedding - -termDRAW! can also be mounted as OpenTUI React components inside another terminal app. - -- `TermDrawApp`: the full app chrome with header, palette, footer, and splash -- `TermDrawEditor`: the bare editor surface without the surrounding app chrome -- `TermDraw`: an alias for `TermDrawApp` - -Full chrome: - ```tsx import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; -import { TermDrawApp } from "@benvinegar/termdraw"; +import { TermDrawApp } from "@termdraw/opentui"; const renderer = await createCliRenderer({ useMouse: true, @@ -129,29 +75,34 @@ createRoot(renderer).render( ); ``` -Bare editor surface: +Also exported from `@termdraw/opentui`: -```tsx -import { TermDrawEditor } from "@benvinegar/termdraw"; +- `TermDrawApp` +- `TermDrawEditor` +- `TermDraw` +- `TermDrawAppRenderable` +- `TermDrawEditorRenderable` +- `TermDrawRenderable` +- `formatSavedOutput` +- `buildHelpText` - console.log(art)} />; -``` +## Use it in Pi -## Development +```bash +pi install npm:@termdraw/pi +``` -This repo is organized as a small workspace: +Then inside Pi: -- `packages/tui` — the main `@benvinegar/termdraw` OpenTUI app/library package -- `packages/pi` — the Pi embedding prototype package +```text +/termdraw +``` -From the repo root, the main scripts still proxy to the TUI package and shared checks: +## Docs -```bash -bun run format -bun run lint -bun test -bun run typecheck -``` +- App package: [`packages/app`](https://github.com/benvinegar/termdraw/tree/main/packages/app) +- OpenTUI package: [`packages/opentui`](https://github.com/benvinegar/termdraw/tree/main/packages/opentui) +- Pi package: [`packages/pi`](https://github.com/benvinegar/termdraw/tree/main/packages/pi) ## Contributing @@ -160,19 +111,16 @@ Contributions are welcome. Before opening a PR: - keep the change focused -- run `bun run format`, `bun run lint`, `bun test`, and `bun run typecheck` -- add or update tests when you change editor behavior -- open an issue first for larger UX or architecture changes +- run `bun run check` +- add or update tests when editor behavior changes +- open an issue first for larger UX or API changes -## License - -MIT. See [LICENSE](LICENSE). +## Security -## Publishing note +Please report security issues privately through GitHub Security Advisories: -The unscoped `termdraw` package name is already taken on npm, so this package is configured to publish as `@benvinegar/termdraw`. +- -## Support +## License -- Bugs and feature requests: [GitHub issues](https://github.com/benvinegar/termdraw/issues) -- Source: [github.com/benvinegar/termdraw](https://github.com/benvinegar/termdraw) +MIT. See [LICENSE](LICENSE). diff --git a/bun.lock b/bun.lock index 65bcdbb..b1d1d4c 100644 --- a/bun.lock +++ b/bun.lock @@ -14,33 +14,29 @@ "typescript": "^5.9.3", }, }, - "packages/pi": { - "name": "pi-termdraw", - "version": "0.1.0", + "packages/app": { + "name": "@termdraw/app", + "version": "0.3.0", + "bin": { + "termdraw": "./dist/cli.js", + }, "dependencies": { - "@benvinegar/termdraw": "file:../tui", "@opentui/core": "^0.1.97", "@opentui/react": "^0.1.97", - "opentui-island": "^0.3.0", - "react": "^19.2.0", + "@termdraw/opentui": "0.3.0", + "react": "^19.2.5", }, "devDependencies": { - "@mariozechner/pi-coding-agent": "*", - "@mariozechner/pi-tui": "*", + "@types/bun": "latest", "@types/react": "^19.2.14", + "oxfmt": "^0.44.0", + "oxlint": "^1.59.0", "typescript": "^5.9.3", }, - "peerDependencies": { - "@mariozechner/pi-coding-agent": "*", - "@mariozechner/pi-tui": "*", - }, }, - "packages/tui": { - "name": "@benvinegar/termdraw", - "version": "0.2.0", - "bin": { - "termdraw": "./dist/cli.js", - }, + "packages/opentui": { + "name": "@termdraw/opentui", + "version": "0.3.0", "devDependencies": { "@opentui/core": "^0.1.97", "@opentui/react": "^0.1.97", @@ -57,6 +53,27 @@ "react": "^19.0.0", }, }, + "packages/pi": { + "name": "@termdraw/pi", + "version": "0.1.0", + "dependencies": { + "@opentui/core": "^0.1.97", + "@opentui/react": "^0.1.97", + "@termdraw/opentui": "0.3.0", + "opentui-island": "^0.3.0", + "react": "^19.2.0", + }, + "devDependencies": { + "@mariozechner/pi-coding-agent": "*", + "@mariozechner/pi-tui": "*", + "@types/react": "^19.2.14", + "typescript": "^5.9.3", + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*", + "@mariozechner/pi-tui": "*", + }, + }, }, "packages": { "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.73.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw=="], @@ -129,8 +146,6 @@ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@benvinegar/termdraw": ["@benvinegar/termdraw@workspace:packages/tui"], - "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], @@ -431,6 +446,12 @@ "@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + "@termdraw/app": ["@termdraw/app@workspace:packages/app"], + + "@termdraw/opentui": ["@termdraw/opentui@workspace:packages/opentui"], + + "@termdraw/pi": ["@termdraw/pi@workspace:packages/pi"], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -717,8 +738,6 @@ "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], - "pi-termdraw": ["pi-termdraw@workspace:packages/pi"], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], @@ -875,6 +894,10 @@ "@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/opentui/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@types/yauzl/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], "bun-types/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], @@ -897,8 +920,6 @@ "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], - "pi-termdraw/@benvinegar/termdraw": ["@benvinegar/termdraw@file:packages/tui", { "devDependencies": { "@opentui/core": "^0.1.97", "@opentui/react": "^0.1.97", "@types/bun": "latest", "@types/react": "^19.2.14", "oxfmt": "^0.44.0", "oxlint": "^1.59.0", "react": "^19.2.5", "typescript": "^5.9.3" }, "peerDependencies": { "@opentui/core": "^0.1.97", "@opentui/react": "^0.1.97", "react": "^19.0.0" }, "bin": { "termdraw": "./dist/cli.js" } }], - "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], "protobufjs/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], @@ -921,6 +942,10 @@ "@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/opentui/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "@types/yauzl/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -947,6 +972,14 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@termdraw/app/@types/bun/bun-types/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "@termdraw/opentui/@types/bun/bun-types/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@termdraw/app/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@termdraw/opentui/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], } } diff --git a/index.ts b/index.ts index a41bd16..7d66cb6 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,5 @@ -export * from "./packages/tui/index.ts"; +export * from "./packages/opentui/index.ts"; if (import.meta.main) { - await import("./packages/tui/src/cli.tsx"); + await import("./packages/app/src/cli.tsx"); } diff --git a/package.json b/package.json index 851af28..50a2682 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,12 @@ ], "scripts": { "start": "bun run index.ts", - "dev": "cd packages/tui && bun --watch run src/cli.tsx", - "clean": "rm -rf dist packages/tui/dist", - "build": "cd packages/tui && bun run build", - "test": "cd packages/tui && bun test", - "typecheck": "cd packages/tui && bun run typecheck && cd ../pi && bun run typecheck", + "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", + "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", "format": "oxfmt .", "format:check": "oxfmt --check .", @@ -38,5 +39,6 @@ "*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}": [ "oxlint --fix --quiet" ] - } + }, + "packageManager": "bun@1.3.10" } diff --git a/packages/tui/LICENSE b/packages/app/LICENSE similarity index 100% rename from packages/tui/LICENSE rename to packages/app/LICENSE diff --git a/packages/app/README.md b/packages/app/README.md new file mode 100644 index 0000000..3cc6681 --- /dev/null +++ b/packages/app/README.md @@ -0,0 +1,73 @@ +# @termdraw/app + +`@termdraw/app` is the standalone termDRAW terminal app for developers who want editable diagrams, UI mocks, and text graphics without leaving the terminal. + +## What it does + +- Draw boxes, lines, paint strokes, and text as retained objects. +- Select, move, resize, and recolor objects after you draw them. +- Group related content inside boxes while everything stays aligned to terminal cells. +- Export plain text or fenced Markdown for docs, tickets, and prompts. + +## Install + +Requirements: + +- [Bun](https://bun.sh) 1.3+ +- A terminal with mouse support + +```bash +npm install --global @termdraw/app +``` + +## Quick start + +```bash +termdraw +``` + +Draw something, then press `Enter` or `Ctrl+S` to write the result to stdout. + +## Usage + +```bash +# save plain text directly to a file +termdraw --output diagram.txt + +# export a fenced Markdown code block +termdraw --fenced > diagram.md + +# show CLI help +termdraw --help +``` + +termDRAW! outputs terminal text, not SVG or bitmap graphics. + +## OpenTUI package + +If you want the embeddable OpenTUI components instead of the packaged app: + +```bash +npm install @termdraw/opentui @opentui/core @opentui/react react +``` + +## Contributing + +Contributions are welcome. + +Before opening a PR: + +- keep the change focused +- run `bun run check` +- add or update tests when editor behavior changes +- open an issue first for larger UX or API changes + +## Security + +Please report security issues privately through GitHub Security Advisories: + +- + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/packages/app/package.json b/packages/app/package.json new file mode 100644 index 0000000..788c586 --- /dev/null +++ b/packages/app/package.json @@ -0,0 +1,77 @@ +{ + "name": "@termdraw/app", + "version": "0.3.0", + "description": "Standalone termDRAW terminal drawing app.", + "keywords": [ + "ascii-art", + "diagram", + "drawing", + "terminal", + "tui" + ], + "homepage": "https://github.com/benvinegar/termdraw/tree/main/packages/app#readme", + "bugs": { + "url": "https://github.com/benvinegar/termdraw/issues" + }, + "license": "MIT", + "author": "Ben Vinegar", + "repository": { + "type": "git", + "url": "git+https://github.com/benvinegar/termdraw.git" + }, + "bin": { + "termdraw": "./dist/cli.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "start": "bun run src/cli.tsx", + "dev": "bun --watch run src/cli.tsx", + "clean": "rm -rf dist", + "build": "bun run clean && bun run build:lib && bun run build:cli", + "build:lib": "tsc -p tsconfig.build.json", + "build:cli": "bun build src/cli.tsx --outdir dist --target=bun && chmod +x dist/cli.js", + "check": "bun run format:check && bun run lint && bun run typecheck", + "pack:check": "bun run build && npm pack --dry-run --ignore-scripts", + "prepack": "bun run build", + "prepublishOnly": "bun run check", + "typecheck": "tsc --noEmit", + "format": "oxfmt .", + "format:check": "oxfmt --check .", + "lint": "oxlint .", + "lint:fix": "oxlint --fix ." + }, + "dependencies": { + "@opentui/core": "^0.1.97", + "@opentui/react": "^0.1.97", + "@termdraw/opentui": "0.3.0", + "react": "^19.2.5" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/react": "^19.2.14", + "oxfmt": "^0.44.0", + "oxlint": "^1.59.0", + "typescript": "^5.9.3" + }, + "engines": { + "bun": ">=1.3.0" + } +} diff --git a/packages/app/src/cli.tsx b/packages/app/src/cli.tsx new file mode 100644 index 0000000..0d46a79 --- /dev/null +++ b/packages/app/src/cli.tsx @@ -0,0 +1,9 @@ +#!/usr/bin/env bun + +import { runTermDrawAppCli } from "./main.js"; + +runTermDrawAppCli().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exit(1); +}); diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts new file mode 100644 index 0000000..2ef0527 --- /dev/null +++ b/packages/app/src/index.ts @@ -0,0 +1 @@ +export { parseArgs, runTermDrawAppCli, type CliOptions } from "./main.js"; diff --git a/packages/tui/src/cli.tsx b/packages/app/src/main.tsx similarity index 80% rename from packages/tui/src/cli.tsx rename to packages/app/src/main.tsx index 7b7a6f5..e6e6698 100644 --- a/packages/tui/src/cli.tsx +++ b/packages/app/src/main.tsx @@ -1,17 +1,14 @@ -#!/usr/bin/env bun - import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; -import { buildHelpText, formatSavedOutput } from "./app.js"; -import { TermDrawApp } from "./react.js"; +import { buildHelpText, formatSavedOutput, TermDrawApp } from "@termdraw/opentui"; -interface CliOptions { +export interface CliOptions { outputPath?: string; fenced: boolean; help: boolean; } -function parseArgs(argv: string[]): CliOptions { +export function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { fenced: false, help: false, @@ -55,11 +52,11 @@ function withTrailingNewline(text: string): string { return text.endsWith("\n") ? text : `${text}\n`; } -async function main(): Promise { - const options = parseArgs(Bun.argv.slice(2)); +export async function runTermDrawAppCli(argv = Bun.argv.slice(2)): Promise { + const options = parseArgs(argv); if (options.help) { - process.stdout.write(buildHelpText("bun run start --")); + process.stdout.write(buildHelpText("termdraw")); return; } @@ -113,9 +110,3 @@ async function main(): Promise { />, ); } - -main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exit(1); -}); diff --git a/packages/app/tsconfig.build.json b/packages/app/tsconfig.build.json new file mode 100644 index 0000000..3e7358d --- /dev/null +++ b/packages/app/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "allowImportingTsExtensions": false + }, + "include": ["src/index.ts", "src/main.tsx"] +} diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json new file mode 100644 index 0000000..32a0dfe --- /dev/null +++ b/packages/app/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/tui/CHANGELOG.md b/packages/opentui/CHANGELOG.md similarity index 75% rename from packages/tui/CHANGELOG.md rename to packages/opentui/CHANGELOG.md index 7f9031d..c043b9c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/opentui/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v0.3.0 + +Initial scoped release of `@termdraw/opentui`. + +### Highlights + +- publishes the embeddable OpenTUI surface separately from the standalone app package +- exports `TermDrawApp`, `TermDrawEditor`, and `TermDraw` for host applications +- keeps the retained-object editor, selection model, export helpers, and renderables used by termDRAW + ## v0.2.0 Adds a dedicated select tool for selection-first editing workflows. @@ -26,10 +36,3 @@ Initial public release of termDRAW!. - embeddable OpenTUI React components: - `TermDrawApp` for the full chrome - `TermDrawEditor` for the bare editor surface - -### Packaging - -- publish-ready npm package configuration -- CLI entrypoint exposed as `termdraw` -- package name configured as `@benvinegar/termdraw` -- manual GitHub Actions workflow for npm publishing diff --git a/packages/opentui/LICENSE b/packages/opentui/LICENSE new file mode 100644 index 0000000..d773fa5 --- /dev/null +++ b/packages/opentui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ben Vinegar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/opentui/README.md b/packages/opentui/README.md new file mode 100644 index 0000000..2fbff2c --- /dev/null +++ b/packages/opentui/README.md @@ -0,0 +1,90 @@ +# @termdraw/opentui + +`@termdraw/opentui` provides the embeddable OpenTUI components and renderables behind termDRAW for terminal apps that want an in-process drawing surface. + +## What it provides + +- `TermDrawApp` for the full chrome with header, palette, footer, and splash +- `TermDrawEditor` for the bare editor surface +- `TermDraw` as an alias for `TermDrawApp` +- renderables and helpers for saved output and CLI help text + +## Install + +```bash +npm install @termdraw/opentui @opentui/core @opentui/react react +``` + +## Quick start + +```tsx +import { createCliRenderer } from "@opentui/core"; +import { createRoot } from "@opentui/react"; +import { TermDrawApp } from "@termdraw/opentui"; + +const renderer = await createCliRenderer({ + useMouse: true, + enableMouseMovement: true, + autoFocus: true, + screenMode: "alternate-screen", +}); + +createRoot(renderer).render( + { + console.log(art); + }} + onCancel={() => { + renderer.destroy(); + }} + />, +); +``` + +## Also exported + +- `TermDrawAppRenderable` +- `TermDrawEditorRenderable` +- `TermDrawRenderable` +- `formatSavedOutput` +- `buildHelpText` +- `registerTermDrawComponent` +- `registerTermDrawComponents` + +## Standalone app + +If you want the packaged terminal app instead of the embeddable OpenTUI surface: + +```bash +npm install --global @termdraw/app +``` + +Then run: + +```bash +termdraw +``` + +## Contributing + +Contributions are welcome. + +Before opening a PR: + +- keep the change focused +- run `bun run check` +- add or update tests when editor behavior changes +- open an issue first for larger UX or API changes + +## Security + +Please report security issues privately through GitHub Security Advisories: + +- + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/packages/opentui/index.ts b/packages/opentui/index.ts new file mode 100644 index 0000000..401c73a --- /dev/null +++ b/packages/opentui/index.ts @@ -0,0 +1 @@ +export * from "./src/index.ts"; diff --git a/packages/tui/package.json b/packages/opentui/package.json similarity index 75% rename from packages/tui/package.json rename to packages/opentui/package.json index 6673a52..b5ed9ce 100644 --- a/packages/tui/package.json +++ b/packages/opentui/package.json @@ -1,7 +1,7 @@ { - "name": "@benvinegar/termdraw", - "version": "0.2.0", - "description": "Object-based terminal illustrator for diagrams, UI mocks, and terminal-native graphics.", + "name": "@termdraw/opentui", + "version": "0.3.0", + "description": "OpenTUI components and renderables for embedding termDRAW in terminal apps.", "keywords": [ "ascii-art", "diagram", @@ -11,7 +11,7 @@ "terminal", "tui" ], - "homepage": "https://github.com/benvinegar/termdraw#readme", + "homepage": "https://github.com/benvinegar/termdraw/tree/main/packages/opentui#readme", "bugs": { "url": "https://github.com/benvinegar/termdraw/issues" }, @@ -21,9 +21,6 @@ "type": "git", "url": "git+https://github.com/benvinegar/termdraw.git" }, - "bin": { - "termdraw": "./dist/cli.js" - }, "files": [ "dist", "README.md", @@ -45,13 +42,11 @@ "access": "public" }, "scripts": { - "start": "bun run src/cli.tsx", - "dev": "bun --watch run src/cli.tsx", "clean": "rm -rf dist", - "build": "bun run clean && bun run build:lib && bun run build:cli", + "build": "bun run clean && bun run build:lib", "build:lib": "tsc -p tsconfig.build.json", - "build:cli": "bun build src/cli.tsx --outdir dist --target=bun && chmod +x dist/cli.js", "check": "bun run format:check && bun run lint && bun test && bun run typecheck", + "pack:check": "bun run build && npm pack --dry-run --ignore-scripts", "prepack": "bun run build", "prepublishOnly": "bun run check", "test": "bun test", diff --git a/packages/tui/src/app.ts b/packages/opentui/src/app.ts similarity index 100% rename from packages/tui/src/app.ts rename to packages/opentui/src/app.ts diff --git a/packages/tui/src/draw-state.test.ts b/packages/opentui/src/draw-state.test.ts similarity index 100% rename from packages/tui/src/draw-state.test.ts rename to packages/opentui/src/draw-state.test.ts diff --git a/packages/tui/src/draw-state.ts b/packages/opentui/src/draw-state.ts similarity index 100% rename from packages/tui/src/draw-state.ts rename to packages/opentui/src/draw-state.ts diff --git a/packages/tui/src/index.ts b/packages/opentui/src/index.ts similarity index 100% rename from packages/tui/src/index.ts rename to packages/opentui/src/index.ts diff --git a/packages/tui/src/react.test.tsx b/packages/opentui/src/react.test.tsx similarity index 100% rename from packages/tui/src/react.test.tsx rename to packages/opentui/src/react.test.tsx diff --git a/packages/tui/src/react.ts b/packages/opentui/src/react.ts similarity index 100% rename from packages/tui/src/react.ts rename to packages/opentui/src/react.ts diff --git a/packages/tui/tsconfig.build.json b/packages/opentui/tsconfig.build.json similarity index 100% rename from packages/tui/tsconfig.build.json rename to packages/opentui/tsconfig.build.json diff --git a/packages/tui/tsconfig.json b/packages/opentui/tsconfig.json similarity index 100% rename from packages/tui/tsconfig.json rename to packages/opentui/tsconfig.json diff --git a/packages/pi/README.md b/packages/pi/README.md index 4c3c53c..e4a56d3 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -1,42 +1,39 @@ -# pi-termdraw +# @termdraw/pi -`pi-termdraw` embeds [termDRAW!](https://github.com/benvinegar/termdraw) inside Pi using [`opentui-island`](https://github.com/benvinegar/opentui-island). +`@termdraw/pi` embeds termDRAW inside Pi using `opentui-island` so you can open the editor as a full-screen Pi overlay and insert drawings back into the current editor. -In this repo, it currently points at the sibling `packages/tui` `@benvinegar/termdraw` package via a file dependency so the prototype uses the current working tree version of termDRAW. +## Install -## Current status +```bash +pi install npm:@termdraw/pi +``` -This package is an **early embedding prototype**. +For a project-local install: -What works today: +```bash +pi install -l npm:@termdraw/pi +``` -- opens termDRAW in a full-screen Pi overlay -- keyboard and mouse input are forwarded into the Bun/OpenTUI surface -- save/export now comes back into the Pi editor via the `opentui-island` result bridge -- termDRAW runs inside terminal Pi without moving the main Pi process off Node +## Usage -What is still intentionally not solved yet: +Inside Pi: -- `pi-gui` support if the client is running through Pi RPC-only extension UI -- richer host/island commands beyond the save/cancel bridge +```text +/termdraw +``` Use `Enter` or `Ctrl+S` to insert the drawing into Pi. Use `Ctrl+Q` to close without inserting. -## Install locally +## Local development From this repo: ```bash bun install -``` - -Then install into Pi from the package path: - -```bash pi install ./packages/pi ``` -Or run directly for a one-off test: +Or run the extension directly for a one-off test: ```bash pi -e ./packages/pi/extensions/index.ts @@ -64,18 +61,13 @@ Requirements: Set `PI_TERMDRAW_SMOKE_KEEP_SESSION=1` if you want the tmux session left alive for debugging on exit. -## Usage - -Inside Pi: - -```text -/termdraw -``` - ## Notes - Requires Bun 1.3+ on the machine running Pi. - The embedded island currently loads from source (`islands/termdraw.island.tsx`) via Bun. -- For local development, `opentui-island@0.3.0` is used for save/cancel result bridging. Its optional `@mariozechner/pi-tui` peer range is still older than current Pi packages, so `npm` users may still need `--legacy-peer-deps` in some setups. -- Before publishing `pi-termdraw`, switch the local `file:../tui` dependency back to a real semver release of `@benvinegar/termdraw`. +- `opentui-island@0.3.0` still has an older optional Pi peer range, so some npm installs may still need `--legacy-peer-deps` depending on the Pi version in use. - This package targets the terminal Pi experience first. GUI support will depend on Pi's extension UI surface. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/packages/pi/islands/termdraw.island.tsx b/packages/pi/islands/termdraw.island.tsx index db64d2e..737244d 100644 --- a/packages/pi/islands/termdraw.island.tsx +++ b/packages/pi/islands/termdraw.island.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/react */ import { useOpenTuiIslandBridge } from "opentui-island"; -import { TermDrawApp } from "@benvinegar/termdraw"; +import { TermDrawApp } from "@termdraw/opentui"; type PiTermDrawIslandProps = { showStartupLogo?: boolean; diff --git a/packages/pi/package.json b/packages/pi/package.json index 2df31dd..c961b99 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -1,5 +1,5 @@ { - "name": "pi-termdraw", + "name": "@termdraw/pi", "version": "0.1.0", "description": "Pi extension package that embeds termDRAW inside Pi via opentui-island.", "keywords": [ @@ -10,7 +10,16 @@ "terminal", "tui" ], + "homepage": "https://github.com/benvinegar/termdraw/tree/main/packages/pi#readme", + "bugs": { + "url": "https://github.com/benvinegar/termdraw/issues" + }, "license": "MIT", + "author": "Ben Vinegar", + "repository": { + "type": "git", + "url": "git+https://github.com/benvinegar/termdraw.git" + }, "files": [ "extensions", "islands", @@ -18,14 +27,18 @@ "LICENSE" ], "type": "module", + "publishConfig": { + "access": "public" + }, "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", + "pack:check": "npm pack --dry-run --ignore-scripts", "smoke:pi": "bash ./scripts/smoke-pi-save.sh" }, "dependencies": { - "@benvinegar/termdraw": "file:../tui", "@opentui/core": "^0.1.97", "@opentui/react": "^0.1.97", + "@termdraw/opentui": "0.3.0", "opentui-island": "^0.3.0", "react": "^19.2.0" }, diff --git a/packages/pi/tsconfig.json b/packages/pi/tsconfig.json index 8fbc033..505bf42 100644 --- a/packages/pi/tsconfig.json +++ b/packages/pi/tsconfig.json @@ -5,7 +5,7 @@ "noEmit": true, "baseUrl": ".", "paths": { - "@benvinegar/termdraw": ["../tui/src/index.ts"] + "@termdraw/opentui": ["../opentui/src/index.ts"] } }, "include": ["extensions/**/*.ts", "islands/**/*.tsx"] diff --git a/packages/tui/README.md b/packages/tui/README.md deleted file mode 100644 index 31a259e..0000000 --- a/packages/tui/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# termDRAW! - -termDRAW! is an object-based terminal illustrator for diagrams, UI mocks, and terminal-native graphics. - -## Why termDRAW! - -- Make terminal-native diagrams without leaving your terminal. -- Keep editing as you think: drawn elements stay selectable, movable, and resizable. -- Group related content inside boxes so diagrams stay organized while you iterate. -- Export plain text or fenced Markdown for READMEs, docs, tickets, and agent prompts. - -## Install - -Requirements: - -- [Bun](https://bun.sh) -- A terminal with mouse support - -From npm: - -```bash -bun add @benvinegar/termdraw -``` - -Or from source: - -```bash -git clone https://github.com/benvinegar/termdraw.git -cd termdraw -bun install -``` - -## Quick start - -Start the app from a local checkout: - -```bash -bun run start -``` - -Or run the published CLI: - -```bash -bunx @benvinegar/termdraw -``` - -Draw something, then press `Enter` or `Ctrl+S` to save. By default, termDRAW! writes the result to stdout after the app exits. - -Write directly to a file: - -```bash -bun run start -- --output diagram.txt -``` - -Export as a fenced Markdown code block: - -```bash -bun run start -- --fenced > diagram.md -``` - -Show CLI help: - -```bash -bun run start -- --help -``` - -## Usage - -termDRAW! behaves more like a small vector-style editor than a paint program. Lines, boxes, and text are retained objects, so you can keep rearranging the diagram after you draw it. Boxes can also act as frames for fully contained children. - -Everything still snaps to terminal cells. termDRAW! outputs terminal art, not SVG or bitmap graphics. - -Controls are shown in the app footer and tool palette. - -## Output examples - -Plain text to stdout: - -```bash -bun run start > drawing.txt -``` - -Plain text to a file: - -```bash -bun run start -- --output drawing.txt -``` - -Markdown fenced output: - -```bash -bun run start -- --fenced > drawing.md -``` - -## Embedding - -termDRAW! can also be mounted as OpenTUI React components inside another terminal app. - -- `TermDrawApp`: the full app chrome with header, palette, footer, and splash -- `TermDrawEditor`: the bare editor surface without the surrounding app chrome -- `TermDraw`: an alias for `TermDrawApp` - -Full chrome: - -```tsx -import { createCliRenderer } from "@opentui/core"; -import { createRoot } from "@opentui/react"; -import { TermDrawApp } from "@benvinegar/termdraw"; - -const renderer = await createCliRenderer({ - useMouse: true, - enableMouseMovement: true, - autoFocus: true, - screenMode: "alternate-screen", -}); - -createRoot(renderer).render( - { - console.log(art); - }} - onCancel={() => { - renderer.destroy(); - }} - />, -); -``` - -Bare editor surface: - -```tsx -import { TermDrawEditor } from "@benvinegar/termdraw"; - - console.log(art)} />; -``` - -## Development - -If you want to hack on termDRAW! locally: - -```bash -bun run format -bun run lint -bun test -bun run typecheck -``` - -## Contributing - -Contributions are welcome. - -Before opening a PR: - -- keep the change focused -- run `bun run format`, `bun run lint`, `bun test`, and `bun run typecheck` -- add or update tests when you change editor behavior -- open an issue first for larger UX or architecture changes - -## License - -MIT. See [LICENSE](LICENSE). - -## Publishing note - -The unscoped `termdraw` package name is already taken on npm, so this package is configured to publish as `@benvinegar/termdraw`. - -## Support - -- Bugs and feature requests: [GitHub issues](https://github.com/benvinegar/termdraw/issues) -- Source: [github.com/benvinegar/termdraw](https://github.com/benvinegar/termdraw) diff --git a/packages/tui/index.ts b/packages/tui/index.ts deleted file mode 100644 index 368af17..0000000 --- a/packages/tui/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./src/index.ts"; - -if (import.meta.main) { - await import("./src/cli.tsx"); -} From d955c2f23208d4c98b4b638782289f5439dc066c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 11 Apr 2026 10:03:37 -0400 Subject: [PATCH 2/3] build: skip format checks in package prepublish hooks --- packages/app/package.json | 2 +- packages/opentui/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 788c586..89fb705 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -51,7 +51,7 @@ "check": "bun run format:check && bun run lint && bun run typecheck", "pack:check": "bun run build && npm pack --dry-run --ignore-scripts", "prepack": "bun run build", - "prepublishOnly": "bun run check", + "prepublishOnly": "bun run lint && bun run typecheck", "typecheck": "tsc --noEmit", "format": "oxfmt .", "format:check": "oxfmt --check .", diff --git a/packages/opentui/package.json b/packages/opentui/package.json index b5ed9ce..73647f8 100644 --- a/packages/opentui/package.json +++ b/packages/opentui/package.json @@ -48,7 +48,7 @@ "check": "bun run format:check && bun run lint && bun test && bun run typecheck", "pack:check": "bun run build && npm pack --dry-run --ignore-scripts", "prepack": "bun run build", - "prepublishOnly": "bun run check", + "prepublishOnly": "bun run lint && bun test && bun run typecheck", "test": "bun test", "typecheck": "tsc --noEmit", "format": "oxfmt .", From 34f07a682d5ca1cc8c685dd0c82f0b060c12f900 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 12 Apr 2026 11:38:15 -0400 Subject: [PATCH 3/3] feat: add a UI stencil browser --- packages/opentui/src/app.ts | 423 +++++++++++++++++++++++- packages/opentui/src/draw-state.test.ts | 30 ++ packages/opentui/src/draw-state.ts | 171 ++++++++++ packages/opentui/src/react.test.tsx | 50 +++ packages/opentui/src/stencil-browser.ts | 99 ++++++ packages/opentui/src/stencils.ts | 229 +++++++++++++ packages/pi/extensions/overlay.ts | 2 +- 7 files changed, 1002 insertions(+), 2 deletions(-) create mode 100644 packages/opentui/src/stencil-browser.ts create mode 100644 packages/opentui/src/stencils.ts diff --git a/packages/opentui/src/app.ts b/packages/opentui/src/app.ts index 5f2a8f0..fedcf54 100644 --- a/packages/opentui/src/app.ts +++ b/packages/opentui/src/app.ts @@ -21,6 +21,15 @@ import { type InkColor, type PointerEventLike, } from "./draw-state.js"; +import { + clampStencilBrowserSelection, + createStencilBrowserState, + getFilteredStencils, + renderStencilPreview, + STENCIL_BROWSER_CATEGORY_OPTIONS, + type StencilBrowserState, +} from "./stencil-browser.js"; +import type { StencilDefinition } from "./stencils.js"; const MIN_WIDTH = 45; const MIN_HEIGHT = 27; @@ -31,6 +40,10 @@ const TOOL_BUTTON_HEIGHT = 3; const BOX_STYLE_ROW_COUNT = 4; const COLOR_SWATCH_WIDTH = 3; const COLOR_SWATCH_COLUMNS = 4; +const STENCIL_BROWSER_SIDEBAR_WIDTH = 18; +const STENCIL_BROWSER_CARD_HEIGHT = 9; +const STENCIL_BROWSER_CARD_GAP = 1; +const STENCIL_BROWSER_MIN_CARD_WIDTH = 28; const COLORS = { background: RGBA.fromHex("#0f172a"), @@ -140,6 +153,66 @@ function drawSegment( return x + visibleCellCount(text); } +function drawFilledRect( + buffer: OptimizedBuffer, + left: number, + top: number, + width: number, + height: number, + bg: RGBA, + fg = COLORS.text, +): void { + const normalizedWidth = Math.max(0, width); + const normalizedHeight = Math.max(0, height); + const row = " ".repeat(normalizedWidth); + for (let y = 0; y < normalizedHeight; y += 1) { + buffer.drawText(row, left, top + y, fg, bg); + } +} + +function drawClippedText( + buffer: OptimizedBuffer, + left: number, + top: number, + width: number, + text: string, + fg: RGBA, + bg: RGBA, + attributes = TextAttributes.NONE, +): void { + if (width <= 0) return; + buffer.drawText(padToWidth(truncateToCells(text, width), width), left, top, fg, bg, attributes); +} + +function drawFrameBox( + buffer: OptimizedBuffer, + left: number, + top: number, + width: number, + height: number, + fg: RGBA, + bg: RGBA, +): void { + if (width <= 0 || height <= 0) return; + drawFilledRect(buffer, left, top, width, height, bg); + if (width === 1 || height === 1) return; + + buffer.setCell(left, top, "╭", fg, bg); + buffer.setCell(left + width - 1, top, "╮", fg, bg); + buffer.setCell(left, top + height - 1, "╰", fg, bg); + buffer.setCell(left + width - 1, top + height - 1, "╯", fg, bg); + + for (let x = left + 1; x < left + width - 1; x += 1) { + buffer.setCell(x, top, "─", fg, bg); + buffer.setCell(x, top + height - 1, "─", fg, bg); + } + + for (let y = top + 1; y < top + height - 1; y += 1) { + buffer.setCell(left, y, "│", fg, bg); + buffer.setCell(left + width - 1, y, "│", fg, bg); + } +} + function mixColor(a: RGBA, b: RGBA, t: number): RGBA { const [ar, ag, ab, aa] = a.toInts(); const [br, bg, bb, ba] = b.toInts(); @@ -222,6 +295,7 @@ export class TermDrawRenderable extends FrameBufferRenderable { private startupLogoDismissed = false; private cancelOnCtrlCEnabled = false; private footerTextOverride: string | null = null; + private readonly stencilBrowser: StencilBrowserState = createStencilBrowserState(); constructor(ctx: RenderContext, options: TermDrawRenderableOptions = {}) { const { @@ -320,6 +394,12 @@ export class TermDrawRenderable extends FrameBufferRenderable { this.dismissStartupLogo(); } + if (this.stencilBrowser.open) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (this.chromeMode === "full" && layout && !this.state.hasActivePointerInteraction) { const toolButton = this.getToolButtons(layout).find((button) => isInsideRect(x, y, button.left, button.top, button.width, button.height), @@ -402,6 +482,11 @@ export class TermDrawRenderable extends FrameBufferRenderable { this.drawCanvas(); this.drawStartupLogo(layout); + + if (this.stencilBrowser.open) { + this.drawStencilBrowser(); + } + super.renderSelf(buffer); } @@ -416,6 +501,16 @@ export class TermDrawRenderable extends FrameBufferRenderable { return true; } + if (this.stencilBrowser.open) { + return this.handleStencilBrowserKeyPress(key); + } + + if (key.ctrl && name === "p") { + key.preventDefault(); + this.openStencilBrowser(); + return true; + } + if (name === "escape") { key.preventDefault(); this.state.clearSelection(); @@ -600,6 +695,331 @@ export class TermDrawRenderable extends FrameBufferRenderable { return false; } + private openStencilBrowser(): void { + this.stencilBrowser.open = true; + this.stencilBrowser.query = ""; + this.stencilBrowser.category = "all"; + this.stencilBrowser.selectedIndex = 0; + this.requestRender(); + } + + private closeStencilBrowser(): void { + if (!this.stencilBrowser.open) return; + this.stencilBrowser.open = false; + this.requestRender(); + } + + private getFilteredStencilDefinitions(): StencilDefinition[] { + const stencils = getFilteredStencils(this.stencilBrowser.query, this.stencilBrowser.category); + clampStencilBrowserSelection(this.stencilBrowser, stencils); + return stencils; + } + + private getStencilBrowserColumnCount(mainWidth: number): number { + return mainWidth >= STENCIL_BROWSER_MIN_CARD_WIDTH * 2 + STENCIL_BROWSER_CARD_GAP ? 2 : 1; + } + + private cycleStencilBrowserCategory(direction: 1 | -1): void { + const currentIndex = STENCIL_BROWSER_CATEGORY_OPTIONS.findIndex( + (option) => option.id === this.stencilBrowser.category, + ); + const nextIndex = + (currentIndex + direction + STENCIL_BROWSER_CATEGORY_OPTIONS.length) % + STENCIL_BROWSER_CATEGORY_OPTIONS.length; + this.stencilBrowser.category = STENCIL_BROWSER_CATEGORY_OPTIONS[nextIndex]?.id ?? "all"; + this.stencilBrowser.selectedIndex = 0; + this.requestRender(); + } + + private moveStencilBrowserSelection(delta: number): void { + const stencils = this.getFilteredStencilDefinitions(); + if (stencils.length === 0) return; + this.stencilBrowser.selectedIndex = Math.max( + 0, + Math.min(this.stencilBrowser.selectedIndex + delta, stencils.length - 1), + ); + this.requestRender(); + } + + private updateStencilBrowserQuery(nextQuery: string): void { + this.stencilBrowser.query = nextQuery; + this.stencilBrowser.selectedIndex = 0; + this.requestRender(); + } + + private insertSelectedStencil(keepBrowserOpen: boolean): void { + const stencils = this.getFilteredStencilDefinitions(); + const stencil = stencils[this.stencilBrowser.selectedIndex]; + if (!stencil) { + this.requestRender(); + return; + } + + this.state.insertDraftObjects(stencil.create(), { + anchor: "cursor", + selectInserted: true, + switchToSelectMode: true, + statusLabel: stencil.name, + }); + + if (!keepBrowserOpen) { + this.stencilBrowser.open = false; + } + + this.requestRender(); + } + + private handleStencilBrowserKeyPress(key: KeyEvent): boolean { + const name = key.name.toLowerCase(); + const mainWidth = Math.max(1, this.width - STENCIL_BROWSER_SIDEBAR_WIDTH - 4); + const columns = this.getStencilBrowserColumnCount(mainWidth); + + if ((key.ctrl && name === "p") || name === "escape") { + key.preventDefault(); + this.closeStencilBrowser(); + return true; + } + + if (name === "enter" || name === "return") { + key.preventDefault(); + this.insertSelectedStencil(key.shift); + return true; + } + + if (name === "tab") { + key.preventDefault(); + this.cycleStencilBrowserCategory(key.shift ? -1 : 1); + return true; + } + + if (name === "up") { + key.preventDefault(); + this.moveStencilBrowserSelection(-columns); + return true; + } + + if (name === "down") { + key.preventDefault(); + this.moveStencilBrowserSelection(columns); + return true; + } + + if (name === "left") { + key.preventDefault(); + this.moveStencilBrowserSelection(-1); + return true; + } + + if (name === "right") { + key.preventDefault(); + this.moveStencilBrowserSelection(1); + return true; + } + + if (name === "backspace") { + key.preventDefault(); + this.updateStencilBrowserQuery(Array.from(this.stencilBrowser.query).slice(0, -1).join("")); + return true; + } + + if (key.ctrl && name === "u") { + key.preventDefault(); + this.updateStencilBrowserQuery(""); + return true; + } + + if (name === "space") { + key.preventDefault(); + this.updateStencilBrowserQuery(`${this.stencilBrowser.query} `); + return true; + } + + if (isPrintableKey(key)) { + key.preventDefault(); + this.updateStencilBrowserQuery(`${this.stencilBrowser.query}${key.raw}`); + return true; + } + + return true; + } + + private drawStencilBrowser(): void { + const modalBg = mixColor(COLORS.background, COLORS.panel, 0.3); + const panelBg = mixColor(COLORS.panel, COLORS.background, 0.15); + const selectedBg = mixColor(COLORS.select, COLORS.panel, 0.2); + const cardBg = mixColor(COLORS.panel, COLORS.background, 0.08); + const sidebarLeft = 1; + const sidebarWidth = STENCIL_BROWSER_SIDEBAR_WIDTH; + const dividerX = sidebarLeft + sidebarWidth; + const mainLeft = dividerX + 2; + const mainWidth = Math.max(1, this.width - mainLeft - 1); + const headerY = 1; + const dividerY = 2; + const contentTop = 3; + const footerY = this.height - 2; + const contentHeight = Math.max(1, footerY - contentTop); + const filtered = this.getFilteredStencilDefinitions(); + const selectedStencil = filtered[this.stencilBrowser.selectedIndex] ?? null; + const columns = this.getStencilBrowserColumnCount(mainWidth); + const cardWidth = + columns === 1 + ? mainWidth + : Math.max( + STENCIL_BROWSER_MIN_CARD_WIDTH, + Math.floor((mainWidth - STENCIL_BROWSER_CARD_GAP) / columns), + ); + const rowHeight = STENCIL_BROWSER_CARD_HEIGHT + STENCIL_BROWSER_CARD_GAP; + const totalRows = Math.max(1, Math.ceil(Math.max(filtered.length, 1) / columns)); + const visibleRows = Math.max( + 1, + Math.floor((contentHeight + STENCIL_BROWSER_CARD_GAP) / rowHeight), + ); + const selectedRow = Math.floor(this.stencilBrowser.selectedIndex / columns); + const scrollRow = Math.max(0, Math.min(selectedRow, totalRows - visibleRows)); + const queryLabel = + this.stencilBrowser.query.length > 0 + ? `Search: ${this.stencilBrowser.query}` + : "Search: type to filter"; + const countLabel = `${filtered.length} stencil${filtered.length === 1 ? "" : "s"}`; + + drawFilledRect(this.frameBuffer, 0, 0, this.width, this.height, modalBg); + drawFrameBox(this.frameBuffer, 0, 0, this.width, this.height, COLORS.accent, panelBg); + drawClippedText( + this.frameBuffer, + 2, + headerY, + Math.max(1, dividerX - 3), + "UI Stencils", + COLORS.accent, + panelBg, + TextAttributes.BOLD, + ); + drawClippedText( + this.frameBuffer, + mainLeft, + headerY, + Math.max(1, mainWidth - visibleCellCount(countLabel) - 2), + queryLabel, + this.stencilBrowser.query.length > 0 ? COLORS.text : COLORS.dim, + panelBg, + ); + drawClippedText( + this.frameBuffer, + Math.max(mainLeft, this.width - visibleCellCount(countLabel) - 2), + headerY, + Math.max(1, visibleCellCount(countLabel)), + countLabel, + COLORS.dim, + panelBg, + ); + + for (let x = 1; x < this.width - 1; x += 1) { + this.frameBuffer.setCell(x, dividerY, "─", COLORS.border, panelBg); + } + this.frameBuffer.setCell(0, dividerY, "├", COLORS.border, panelBg); + this.frameBuffer.setCell(this.width - 1, dividerY, "┤", COLORS.border, panelBg); + + for (let y = contentTop; y <= footerY; y += 1) { + this.frameBuffer.setCell(dividerX, y, "│", COLORS.border, panelBg); + } + this.frameBuffer.setCell(dividerX, dividerY, "┼", COLORS.border, panelBg); + + for (const [index, category] of STENCIL_BROWSER_CATEGORY_OPTIONS.entries()) { + const y = contentTop + index; + if (y >= footerY) break; + const isActive = category.id === this.stencilBrowser.category; + drawClippedText( + this.frameBuffer, + sidebarLeft + 1, + y, + Math.max(1, sidebarWidth - 1), + category.label, + isActive ? COLORS.panel : COLORS.text, + isActive ? COLORS.select : panelBg, + isActive ? TextAttributes.BOLD : TextAttributes.NONE, + ); + } + + if (filtered.length === 0) { + drawClippedText( + this.frameBuffer, + mainLeft, + contentTop + 1, + mainWidth, + "No stencils match the current filter.", + COLORS.dim, + panelBg, + ); + } + + for (let row = scrollRow; row < Math.min(totalRows, scrollRow + visibleRows); row += 1) { + for (let col = 0; col < columns; col += 1) { + const index = row * columns + col; + const stencil = filtered[index]; + if (!stencil) continue; + + const left = mainLeft + col * (cardWidth + STENCIL_BROWSER_CARD_GAP); + const top = contentTop + (row - scrollRow) * rowHeight; + const isSelected = index === this.stencilBrowser.selectedIndex; + const previewLines = renderStencilPreview(stencil, Math.max(1, cardWidth - 4), 4); + drawFrameBox( + this.frameBuffer, + left, + top, + cardWidth, + STENCIL_BROWSER_CARD_HEIGHT, + isSelected ? COLORS.accent : COLORS.border, + isSelected ? selectedBg : cardBg, + ); + drawClippedText( + this.frameBuffer, + left + 1, + top + 1, + Math.max(1, cardWidth - 2), + stencil.name, + isSelected ? COLORS.accent : COLORS.text, + isSelected ? selectedBg : cardBg, + TextAttributes.BOLD, + ); + for (const [previewIndex, line] of previewLines.entries()) { + drawClippedText( + this.frameBuffer, + left + 2, + top + 2 + previewIndex, + Math.max(1, cardWidth - 4), + line, + isSelected ? COLORS.text : COLORS.dim, + isSelected ? selectedBg : cardBg, + ); + } + drawClippedText( + this.frameBuffer, + left + 1, + top + 6, + Math.max(1, cardWidth - 2), + truncateToCells(stencil.description, Math.max(1, cardWidth - 2)), + COLORS.dim, + isSelected ? selectedBg : cardBg, + ); + drawClippedText( + this.frameBuffer, + left + 1, + top + 7, + Math.max(1, cardWidth - 2), + `${stencil.width}x${stencil.height} • ${stencil.category}`, + COLORS.dim, + isSelected ? selectedBg : cardBg, + ); + } + } + + const footerText = + selectedStencil === null + ? "Type to filter • Tab cycles categories • Esc closes" + : "↑↓←→ move • type to filter • Tab categories • Enter insert • Shift+Enter keep open • Esc close"; + drawClippedText(this.frameBuffer, 2, footerY, this.width - 4, footerText, COLORS.dim, panelBg); + } + private dismissStartupLogo(): void { if (!this.startupLogoEnabled || this.startupLogoDismissed) return; this.startupLogoDismissed = true; @@ -890,7 +1310,7 @@ export class TermDrawRenderable extends FrameBufferRenderable { private drawFooterRow(layout: AppLayout): void { const text = this.footerTextOverride ?? - "Right palette tools/styles/colors • select tool can marquee multiple objects • drag box corners / line endpoints to edit • Esc deselect • Ctrl+Q quit"; + "Right palette tools/styles/colors • select tool can marquee multiple objects • drag box corners / line endpoints to edit • Ctrl+P UI stencils • Esc deselect • Ctrl+Q quit"; const combined = `${text} ${this.state.currentStatus}`; const padded = padToWidth(combined, Math.max(1, this.width - 2)); this.frameBuffer.drawText(padded, 1, layout.footerY, COLORS.dim, COLORS.panel); @@ -1158,6 +1578,7 @@ export function buildHelpText(binaryName = "termdraw"): string { ` Ctrl+Q quit\n` + ` Ctrl+Z / Ctrl+Y undo / redo\n` + ` Ctrl+X clear canvas\n` + + ` Ctrl+P open the UI stencil browser\n` + ` [ / ] cycle box style or paint/line brush\n` + ` mouse wheel cycle box style or paint/line brush\n` + ` Space stamp brush in paint/line mode / insert space in text mode\n` + diff --git a/packages/opentui/src/draw-state.test.ts b/packages/opentui/src/draw-state.test.ts index 5d9cb40..dbcd8ef 100644 --- a/packages/opentui/src/draw-state.test.ts +++ b/packages/opentui/src/draw-state.test.ts @@ -549,4 +549,34 @@ describe("DrawState", () => { state.redo(); expect(state.getCompositeCell(4, 2)).toBe("┏"); }); + + test("insertDraftObjects inserts editable stencil groups", () => { + const state = new DrawState(24, 12); + + state.insertDraftObjects( + [ + { type: "box", left: 0, top: 0, right: 8, bottom: 4, style: "light" }, + { type: "text", x: 2, y: 2, content: "Dialog" }, + ], + { statusLabel: "Dialog" }, + ); + + expect(state.currentMode).toBe("select"); + expect(state.hasSelectedObject).toBe(true); + expect(state.getCompositeCell(0, 0)).toBe("┌"); + expect(state.getCompositeCell(2, 2)).toBe("D"); + }); + + test("insertDraftObjects clamps inserted groups inside the canvas", () => { + const state = new DrawState(16, 12); + state.moveCursor(13, 6); + + state.insertDraftObjects( + [{ type: "box", left: 0, top: 0, right: 5, bottom: 3, style: "light" }], + { statusLabel: null }, + ); + + expect(state.getCompositeCell(8, 3)).toBe("┌"); + expect(state.getCompositeCell(13, 6)).toBe("┘"); + }); }); diff --git a/packages/opentui/src/draw-state.ts b/packages/opentui/src/draw-state.ts index b90f36e..636f0a5 100644 --- a/packages/opentui/src/draw-state.ts +++ b/packages/opentui/src/draw-state.ts @@ -76,6 +76,51 @@ type TextObject = BaseDrawObject & { export type DrawObject = BoxObject | LineObject | PaintObject | TextObject; +export type DraftDrawObject = + | { + type: "box"; + color?: InkColor; + left: number; + top: number; + right: number; + bottom: number; + style?: BoxStyle; + } + | { + type: "line"; + color?: InkColor; + x1: number; + y1: number; + x2: number; + y2: number; + brush?: string; + } + | { + type: "paint"; + color?: InkColor; + points: Point[]; + brush?: string; + } + | { + type: "text"; + color?: InkColor; + x: number; + y: number; + content: string; + }; + +export type InsertDraftObjectsOptions = { + anchor?: "cursor" | "center"; + switchToSelectMode?: boolean; + selectInserted?: boolean; + statusLabel?: string | null; +}; + +export type InsertDraftObjectsResult = { + insertedIds: string[]; + bounds: { left: number; top: number; right: number; bottom: number } | null; +}; + type Snapshot = { objects: DrawObject[]; selectedObjectIds: string[]; @@ -1215,6 +1260,77 @@ export class DrawState { } } + public insertDraftObjects( + drafts: DraftDrawObject[], + options: InsertDraftObjectsOptions = {}, + ): InsertDraftObjectsResult { + if (drafts.length === 0) { + if (options.statusLabel !== null) { + this.setStatus("Nothing to insert."); + } + return { insertedIds: [], bounds: null }; + } + + const materialized = drafts.map((draft) => this.materializeDraftObject(draft)); + const originalBounds = getBoundsUnion(materialized); + if (!originalBounds) { + if (options.statusLabel !== null) { + this.setStatus("Nothing to insert."); + } + return { insertedIds: [], bounds: null }; + } + + const anchor = options.anchor ?? "cursor"; + const desiredLeft = + anchor === "center" + ? Math.max( + 0, + Math.floor((this.canvasWidth - (originalBounds.right - originalBounds.left + 1)) / 2), + ) + : this.cursorX; + const desiredTop = + anchor === "center" + ? Math.max( + 0, + Math.floor((this.canvasHeight - (originalBounds.bottom - originalBounds.top + 1)) / 2), + ) + : this.cursorY; + const translated = this.translateObjectTreeWithinCanvas( + materialized, + desiredLeft - originalBounds.left, + desiredTop - originalBounds.top, + ); + const bounds = getBoundsUnion(translated); + + this.pushUndo(); + this.setObjects([...this.objects, ...translated]); + this.activeTextObjectId = null; + + if (options.selectInserted ?? true) { + const insertedIds = translated.map((object) => object.id); + this.setSelectedObjects(insertedIds, insertedIds.at(-1) ?? null); + } + + if (options.switchToSelectMode ?? true) { + this.setMode("select"); + } + + if (options.statusLabel !== null) { + this.setStatus( + options.statusLabel?.trim() + ? `Inserted ${options.statusLabel}.` + : translated.length === 1 + ? `Inserted ${this.describeObject(translated[0]!)}.` + : `Inserted ${translated.length} objects.`, + ); + } + + return { + insertedIds: translated.map((object) => object.id), + bounds, + }; + } + public stampBrushAtCursor(): void { this.pushUndo(); @@ -2534,6 +2650,61 @@ export class DrawState { } as T; } + private materializeDraftObject(draft: DraftDrawObject): DrawObject { + if (draft.type === "box") { + return { + id: this.createObjectId(), + z: this.allocateZIndex(), + parentId: null, + color: draft.color ?? this.inkColor, + type: "box", + left: draft.left, + top: draft.top, + right: draft.right, + bottom: draft.bottom, + style: draft.style ?? this.boxStyle, + }; + } + + if (draft.type === "line") { + return { + id: this.createObjectId(), + z: this.allocateZIndex(), + parentId: null, + color: draft.color ?? this.inkColor, + type: "line", + x1: draft.x1, + y1: draft.y1, + x2: draft.x2, + y2: draft.y2, + brush: normalizeCellCharacter(draft.brush ?? this.brush), + }; + } + + if (draft.type === "paint") { + return { + id: this.createObjectId(), + z: this.allocateZIndex(), + parentId: null, + color: draft.color ?? this.inkColor, + type: "paint", + points: draft.points.map((point) => ({ ...point })), + brush: normalizeCellCharacter(draft.brush ?? this.brush), + }; + } + + return { + id: this.createObjectId(), + z: this.allocateZIndex(), + parentId: null, + color: draft.color ?? this.inkColor, + type: "text", + x: draft.x, + y: draft.y, + content: draft.content, + }; + } + private bringObjectsToFront(objects: DrawObject[]): DrawObject[] { const byId = new Map(); diff --git a/packages/opentui/src/react.test.tsx b/packages/opentui/src/react.test.tsx index 56dc4e1..2343801 100644 --- a/packages/opentui/src/react.test.tsx +++ b/packages/opentui/src/react.test.tsx @@ -70,6 +70,56 @@ test("TermDrawApp supports custom footer text", async () => { expect(frame).toContain("Ctrl+Q cancels"); }); +test("TermDrawApp can open the stencil browser and insert a template", async () => { + let savedArt: string | null = null; + + const { captureCharFrame, mockInput, renderOnce } = await testRender( + { + savedArt = art; + }} + />, + { + width: 96, + height: 32, + useMouse: true, + enableMouseMovement: true, + }, + ); + + await renderOnce(); + + mockInput.pressKey("p", { ctrl: true }); + await renderOnce(); + + expect(captureCharFrame()).toContain("UI Stencils"); + + await mockInput.typeText("dialog"); + await renderOnce(); + + expect(captureCharFrame()).toContain("Dialog / Modal"); + + mockInput.pressEnter(); + await renderOnce(); + + const insertedFrame = captureCharFrame(); + expect(insertedFrame).not.toContain("UI Stencils"); + expect(insertedFrame).toContain("Discard draft?"); + + mockInput.pressEnter(); + await renderOnce(); + + if (savedArt === null) { + throw new Error("Expected inserted stencil to save rendered art."); + } + + expect(String(savedArt).includes("Discard draft?")).toBe(true); +}); + test("TermDrawEditor renders without full chrome and can save", async () => { let savedArt: string | null = null; diff --git a/packages/opentui/src/stencil-browser.ts b/packages/opentui/src/stencil-browser.ts new file mode 100644 index 0000000..1ba922b --- /dev/null +++ b/packages/opentui/src/stencil-browser.ts @@ -0,0 +1,99 @@ +import { DrawState, type CanvasInsets } from "./draw-state.js"; +import { + STENCIL_CATEGORY_OPTIONS, + STENCIL_DEFINITIONS, + type StencilCategory, + type StencilDefinition, +} from "./stencils.js"; + +const PREVIEW_INSETS: CanvasInsets = { + left: 0, + top: 0, + right: 0, + bottom: 0, +}; + +export type StencilBrowserCategory = "all" | StencilCategory; + +export const STENCIL_BROWSER_CATEGORY_OPTIONS: { + id: StencilBrowserCategory; + label: string; +}[] = [{ id: "all", label: "All" }, ...STENCIL_CATEGORY_OPTIONS]; + +export type StencilBrowserState = { + open: boolean; + query: string; + category: StencilBrowserCategory; + selectedIndex: number; +}; + +export function createStencilBrowserState(): StencilBrowserState { + return { + open: false, + query: "", + category: "all", + selectedIndex: 0, + }; +} + +export function getFilteredStencils( + query: string, + category: StencilBrowserCategory, +): StencilDefinition[] { + const normalizedQuery = query.trim().toLowerCase(); + + return STENCIL_DEFINITIONS.filter((stencil) => { + if (category !== "all" && stencil.category !== category) { + return false; + } + + if (normalizedQuery.length === 0) { + return true; + } + + const haystack = [stencil.name, stencil.description, stencil.category, ...stencil.tags] + .join(" ") + .toLowerCase(); + + return haystack.includes(normalizedQuery); + }); +} + +export function clampStencilBrowserSelection( + state: StencilBrowserState, + stencils: StencilDefinition[], +): void { + if (stencils.length === 0) { + state.selectedIndex = 0; + return; + } + + state.selectedIndex = Math.max(0, Math.min(state.selectedIndex, stencils.length - 1)); +} + +export function renderStencilPreview( + stencil: StencilDefinition, + width: number, + height: number, +): string[] { + const previewWidth = Math.max(1, width); + const previewHeight = Math.max(1, height); + const state = new DrawState(previewWidth, previewHeight, PREVIEW_INSETS); + state.insertDraftObjects(stencil.create(), { + anchor: "center", + selectInserted: false, + switchToSelectMode: false, + statusLabel: null, + }); + + const lines: string[] = []; + for (let y = 0; y < previewHeight; y += 1) { + let line = ""; + for (let x = 0; x < previewWidth; x += 1) { + line += state.getCompositeCell(x, y); + } + lines.push(line); + } + + return lines; +} diff --git a/packages/opentui/src/stencils.ts b/packages/opentui/src/stencils.ts new file mode 100644 index 0000000..cab320e --- /dev/null +++ b/packages/opentui/src/stencils.ts @@ -0,0 +1,229 @@ +import type { BoxStyle, DraftDrawObject } from "./draw-state.js"; + +export type StencilCategory = "layouts" | "windows" | "navigation" | "data" | "feedback"; + +export type StencilDefinition = { + id: string; + name: string; + category: StencilCategory; + description: string; + tags: string[]; + width: number; + height: number; + create: () => DraftDrawObject[]; +}; + +export const STENCIL_CATEGORY_OPTIONS: { id: StencilCategory; label: string }[] = [ + { id: "layouts", label: "Layouts" }, + { id: "windows", label: "Windows" }, + { id: "navigation", label: "Navigation" }, + { id: "data", label: "Data" }, + { id: "feedback", label: "Feedback" }, +]; + +function box( + left: number, + top: number, + right: number, + bottom: number, + style: BoxStyle = "light", +): DraftDrawObject { + return { type: "box", left, top, right, bottom, style }; +} + +function text(x: number, y: number, content: string): DraftDrawObject { + return { type: "text", x, y, content }; +} + +function line(x1: number, y1: number, x2: number, y2: number, brush = "|"): DraftDrawObject { + return { type: "line", x1, y1, x2, y2, brush }; +} + +function paint(points: Array<[number, number]>, brush: string): DraftDrawObject { + return { + type: "paint", + points: points.map(([x, y]) => ({ x, y })), + brush, + }; +} + +function rangePointsX(startX: number, endX: number, y: number): Array<[number, number]> { + const points: Array<[number, number]> = []; + for (let x = startX; x <= endX; x += 1) { + points.push([x, y]); + } + return points; +} + +function rangePointsY(x: number, startY: number, endY: number): Array<[number, number]> { + const points: Array<[number, number]> = []; + for (let y = startY; y <= endY; y += 1) { + points.push([x, y]); + } + return points; +} + +export const STENCIL_DEFINITIONS: StencilDefinition[] = [ + { + id: "layout-sidebar", + name: "Sidebar Layout", + category: "layouts", + description: "App shell with sidebar navigation and a main content pane.", + tags: ["sidebar", "layout", "app", "navigation"], + width: 38, + height: 14, + create: () => [ + box(0, 0, 37, 13), + line(11, 1, 11, 12), + text(2, 1, "Project"), + text(2, 3, "> Inbox"), + text(2, 4, " Drafts"), + text(2, 5, " Archive"), + text(14, 1, "Overview"), + text(14, 3, "Content area"), + text(14, 5, "- summary card"), + text(14, 6, "- activity feed"), + ], + }, + { + id: "layout-two-column", + name: "Two Column Split", + category: "layouts", + description: "Balanced two-column content layout inside a frame.", + tags: ["columns", "split", "layout", "content"], + width: 38, + height: 14, + create: () => [ + box(0, 0, 37, 13), + line(18, 1, 18, 12), + text(2, 1, "Left pane"), + text(2, 3, "- notes"), + text(2, 4, "- checklist"), + text(21, 1, "Right pane"), + text(21, 3, "- preview"), + text(21, 4, "- details"), + ], + }, + { + id: "layout-inspector", + name: "Inspector Layout", + category: "layouts", + description: "Primary canvas area with a right-side inspector panel.", + tags: ["inspector", "sidebar", "layout", "editor"], + width: 40, + height: 14, + create: () => [ + box(0, 0, 39, 13), + line(28, 1, 28, 12), + text(2, 1, "Canvas"), + text(2, 3, "[ selected object ]"), + text(31, 1, "Inspector"), + text(31, 3, "Name"), + text(31, 5, "Position"), + text(31, 7, "Size"), + ], + }, + { + id: "window-app", + name: "App Window", + category: "windows", + description: "Window frame with title, content region, and status footer.", + tags: ["window", "frame", "status", "app"], + width: 36, + height: 12, + create: () => [ + box(0, 0, 35, 11), + line(1, 2, 34, 2, "-"), + line(1, 9, 34, 9, "-"), + text(2, 1, "termDRAW App"), + text(2, 4, "Main content"), + text(2, 10, "Status: synced"), + ], + }, + { + id: "dialog-modal", + name: "Dialog / Modal", + category: "feedback", + description: "Centered confirmation dialog with primary and secondary actions.", + tags: ["dialog", "modal", "confirm", "buttons"], + width: 32, + height: 10, + create: () => [ + box(0, 0, 31, 9), + text(2, 1, "Discard draft?"), + text(2, 3, "Unsaved changes will be lost."), + box(4, 6, 12, 8), + box(18, 6, 27, 8), + text(6, 7, "Cancel"), + text(21, 7, "Discard"), + ], + }, + { + id: "nav-tabs", + name: "Tabs Panel", + category: "navigation", + description: "Tabbed panel with one active tab and a content body.", + tags: ["tabs", "navigation", "panel", "header"], + width: 36, + height: 11, + create: () => [ + box(0, 2, 35, 10), + box(1, 0, 8, 2), + box(9, 1, 16, 2), + box(17, 1, 24, 2), + text(3, 1, "Home"), + text(11, 2, "Files"), + text(19, 2, "Logs"), + text(3, 5, "Active tab content"), + ], + }, + { + id: "data-list", + name: "List Panel", + category: "data", + description: "List view with a selected row and a vertical scrollbar.", + tags: ["list", "rows", "scrollbar", "panel"], + width: 32, + height: 13, + create: () => [ + box(0, 0, 31, 12), + text(2, 1, "Items"), + paint(rangePointsX(2, 26, 3), "█"), + text(3, 3, "Selected row"), + text(3, 5, "Second row"), + text(3, 7, "Third row"), + text(3, 9, "Fourth row"), + paint(rangePointsY(28, 2, 10), "│"), + paint(rangePointsY(28, 4, 6), "█"), + ], + }, + { + id: "data-table", + name: "Table", + category: "data", + description: "Simple table layout with headers, rows, and a scrollbar.", + tags: ["table", "grid", "rows", "columns"], + width: 40, + height: 13, + create: () => [ + box(0, 0, 39, 12), + line(1, 2, 37, 2, "-"), + line(12, 1, 12, 10), + line(24, 1, 24, 10), + text(2, 1, "Name"), + text(14, 1, "Status"), + text(26, 1, "Owner"), + text(2, 4, "Alpha"), + text(14, 4, "Open"), + text(26, 4, "Ben"), + text(2, 6, "Beta"), + text(14, 6, "Draft"), + text(26, 6, "Kai"), + text(2, 8, "Gamma"), + text(14, 8, "Done"), + text(26, 8, "You"), + paint(rangePointsY(37, 2, 10), "│"), + paint(rangePointsY(37, 5, 7), "█"), + ], + }, +]; diff --git a/packages/pi/extensions/overlay.ts b/packages/pi/extensions/overlay.ts index 8578862..76df1f9 100644 --- a/packages/pi/extensions/overlay.ts +++ b/packages/pi/extensions/overlay.ts @@ -16,7 +16,7 @@ import { const TERM_DRAW_ISLAND_MODULE_URL = new URL("../islands/termdraw.island.tsx", import.meta.url); const PI_FOOTER_TEXT = - "Right palette tools/styles/colors • select tool can marquee multiple objects • drag box corners / line endpoints to edit • Esc deselect • Enter inserts into Pi • Ctrl+Q cancels"; + "Right palette tools/styles/colors • select tool can marquee multiple objects • drag box corners / line endpoints to edit • Ctrl+P UI stencils • Esc deselect • Enter / Ctrl+S inserts into Pi • Ctrl+Q cancels"; const READY_STATUS = "termDRAW ready. Press Enter or Ctrl+S to insert into Pi. Ctrl+Q cancels."; const LOADING_STATUS = "Starting termDRAW in a Bun sidecar…"; const INSERTED_MESSAGE = "Inserted drawing into editor.";