Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ All notable user-visible changes to this project are documented in this file.

### Added

- Added native `.td.json` diagram documents that can be opened with `termdraw --load <file>` or `termdraw --load -`.

### Changed

- Split rendered-art export from native diagram saving in the app: Enter/Ctrl+S still exports art, while Ctrl+D saves the editable diagram and prompts for a path when needed.

### Fixed

## [0.3.5]
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,23 @@ npm install --global @termdraw/app
termdraw
```

Draw something, then press `Enter` or `Ctrl+S` to write the result to stdout.
Draw something, then press `Enter` or `Ctrl+S` to export the rendered art to stdout.

Press `Ctrl+D` to save the editable diagram as a native `.td.json` document. If you opened a diagram with `--load`, termDRAW reuses that path by default; otherwise it prompts for one inside the app.

## App usage

```bash
# open an editable native document from a file
termdraw --load architecture.td.json

# open an editable native document from stdin
# requires a controlling terminal for the interactive editor session
cat architecture.td.json | termdraw --load -

# save the rendered art directly to a file
termdraw --load architecture.td.json --output diagram.txt

# save plain text directly to a file
termdraw --output diagram.txt

Expand All @@ -63,6 +75,8 @@ termdraw --help

termDRAW! outputs terminal text, not SVG or bitmap graphics.

Use native `.td.json` documents when you want to reopen and keep editing a drawing. Plain-text output remains an export format and does not preserve the original object metadata.

## Use it in Pi

```bash
Expand Down Expand Up @@ -98,9 +112,14 @@ createRoot(renderer).render(
width="100%"
height="100%"
autoFocus
initialDocument={existingDocument}
diagramPath="architecture.td.json"
onSave={(art) => {
console.log(art);
}}
onSaveDiagram={async (document, path) => {
await Bun.write(path, `${JSON.stringify(document, null, 2)}\n`);
}}
onCancel={() => {
renderer.destroy();
}}
Expand All @@ -118,6 +137,7 @@ Also exported from `@termdraw/opentui`:
- `TermDrawRenderable`
- `formatSavedOutput`
- `buildHelpText`
- `parseDrawDocument`

## Docs

Expand Down
5 changes: 3 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions packages/app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ Draw something, then press `Enter` or `Ctrl+S` to write the result to stdout.
## Usage

```bash
# load an editable native document from a file
termdraw --load architecture.td.json

# load from stdin, then continue interactively on the controlling terminal
cat architecture.td.json | termdraw --load -

# save plain text directly to a file
termdraw --output diagram.txt

Expand All @@ -43,6 +49,8 @@ termdraw --help

termDRAW! outputs terminal text, not SVG or bitmap graphics.

Use native `.td.json` documents when you want load/save round-tripping for the editable object model. If you load from stdin, termDRAW still needs a controlling terminal for the interactive session; use `--load <file>` when no TTY is available.

## OpenTUI package

If you want the embeddable OpenTUI components instead of the packaged app:
Expand Down
114 changes: 111 additions & 3 deletions packages/app/src/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,39 @@
import { expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, expect, test } from "bun:test";
import { version as appVersion } from "../package.json";
import { buildCliHelpText, parseArgs, runTermDrawAppCli } from "./main";
import { DRAW_DOCUMENT_VERSION } from "../../opentui/src/index";
import {
buildCliHelpText,
getInteractiveStdin,
loadDiagramInput,
parseArgs,
readTextFromStdin,
runTermDrawAppCli,
shouldUseInteractiveTtyInput,
} from "./main";

const tempDirs: string[] = [];

afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) continue;
rmSync(dir, { recursive: true, force: true });
}
});

test("parseArgs accepts --load alongside existing output options", () => {
expect(parseArgs(["--load", "drawing.td.json", "--fenced", "--output", "art.txt"])).toEqual({
diagramPath: "drawing.td.json",
fenced: true,
help: false,
outputPath: "art.txt",
version: false,
});
});

test("parseArgs accepts --version and -v", () => {
expect(parseArgs(["--version"])).toEqual({
Expand Down Expand Up @@ -41,6 +71,7 @@ test("parseArgs rejects missing output values and unknown args", () => {
test("buildCliHelpText only shows CLI options", () => {
const help = buildCliHelpText();

expect(help).toContain("--load");
expect(help).toContain("--version");
expect(help).toContain("--output");
expect(help).not.toContain("Controls:");
Expand Down Expand Up @@ -84,6 +115,83 @@ test("runTermDrawAppCli prints the current version", async () => {
expect(stdoutWrites.join("")).toBe(`${appVersion}\n`);
});

test("loadDiagramInput reads and parses a diagram file", async () => {
const dir = mkdtempSync(join(tmpdir(), "termdraw-app-test-"));
tempDirs.push(dir);
const path = join(dir, "diagram.td.json");
await Bun.write(
path,
JSON.stringify({
version: DRAW_DOCUMENT_VERSION,
objects: [],
}),
);

await expect(loadDiagramInput(path)).resolves.toEqual({
version: DRAW_DOCUMENT_VERSION,
objects: [],
});
});

test("loadDiagramInput reads stdin when --load - is used", async () => {
await expect(
loadDiagramInput("-", async () =>
JSON.stringify({
version: DRAW_DOCUMENT_VERSION,
objects: [],
}),
),
).resolves.toEqual({
version: DRAW_DOCUMENT_VERSION,
objects: [],
});
});

test("readTextFromStdin drains and pauses stdin", async () => {
const stdin = new PassThrough();
let paused = false;

Reflect.set(stdin, "isTTY", false);
Reflect.set(stdin, "pause", () => {
paused = true;
return stdin;
});

stdin.end(
JSON.stringify({
version: DRAW_DOCUMENT_VERSION,
objects: [],
}),
);

await expect(readTextFromStdin(stdin)).resolves.toContain('"version"');
expect(paused).toBe(true);
});

test("shouldUseInteractiveTtyInput only swaps stdin for piped load input", () => {
expect(shouldUseInteractiveTtyInput("-", { isTTY: false })).toBe(true);
expect(shouldUseInteractiveTtyInput("-", { isTTY: true })).toBe(false);
expect(shouldUseInteractiveTtyInput("diagram.td.json", { isTTY: false })).toBe(false);
});

test("getInteractiveStdin surfaces a clear error without a controlling terminal", () => {
const ttyError = new Error("ENXIO: no such device or address, open '/dev/tty'");

expect(() =>
getInteractiveStdin("-", { isTTY: false }, () => {
throw ttyError;
}),
).toThrow(
"Interactive editing from stdin requires a controlling terminal. Use --load <file> instead.",
);
});

test("loadDiagramInput surfaces clear parse errors", async () => {
await expect(loadDiagramInput("-", async () => '{"version":999,"objects":[]}')).rejects.toThrow(
`Failed to load diagram from stdin: termDRAW document version must be ${DRAW_DOCUMENT_VERSION}`,
);
});

test("with --output parseArgs records the destination path", () => {
expect(parseArgs(["--output", "diagram.txt"])).toEqual({
outputPath: "diagram.txt",
Expand Down
Loading