From acae97bbe3810cf0d15f6149cb69009599a9c394 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Sat, 25 Apr 2026 17:35:18 +0000 Subject: [PATCH 1/6] feat(app): add native .td.json diagram load and save --- CHANGELOG.md | 4 + README.md | 21 +- bun.lock | 18 +- packages/app/src/main.test.ts | 63 ++++++ packages/app/src/main.tsx | 67 +++++- packages/opentui/src/app.ts | 169 ++++++++++++++- packages/opentui/src/app/input.ts | 20 +- packages/opentui/src/app/render.ts | 90 +++++++- packages/opentui/src/draw-state.test.ts | 66 +++++- packages/opentui/src/draw-state.ts | 262 +++++++++++++++++++++++ packages/opentui/src/draw-state/types.ts | 5 + packages/opentui/src/index.ts | 8 + packages/opentui/src/react.test.tsx | 129 +++++++++++ 13 files changed, 901 insertions(+), 21 deletions(-) create mode 100644 packages/app/src/main.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be262f4..24284a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --diagram ` or `termdraw --diagram -`. + ### 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.3] diff --git a/README.md b/README.md index 5bf1c1e..4239cc1 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,22 @@ 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 `--diagram`, 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 --diagram architecture.td.json + +# open an editable native document from stdin +cat architecture.td.json | termdraw --diagram - + +# save the rendered art directly to a file +termdraw --diagram architecture.td.json --output diagram.txt + # save plain text directly to a file termdraw --output diagram.txt @@ -63,6 +74,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 @@ -98,9 +111,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(); }} @@ -118,6 +136,7 @@ Also exported from `@termdraw/opentui`: - `TermDrawRenderable` - `formatSavedOutput` - `buildHelpText` +- `parseDrawDocument` ## Docs diff --git a/bun.lock b/bun.lock index 7ab7732..de72afe 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ }, "packages/app": { "name": "@termdraw/app", - "version": "0.3.0", + "version": "0.3.3", "bin": { "termdraw": "./dist/cli.js", }, @@ -36,7 +36,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 +55,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 +70,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": { @@ -896,7 +902,7 @@ "@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=="], + "@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=="], @@ -944,7 +950,7 @@ "@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=="], + "@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/packages/app/src/main.test.ts b/packages/app/src/main.test.ts new file mode 100644 index 0000000..d271bfe --- /dev/null +++ b/packages/app/src/main.test.ts @@ -0,0 +1,63 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { DRAW_DOCUMENT_VERSION } from "../../opentui/src/index"; +import { loadDiagramInput, parseArgs } 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 --diagram alongside existing output options", () => { + expect(parseArgs(["--diagram", "drawing.td.json", "--fenced", "--output", "art.txt"])).toEqual({ + diagramPath: "drawing.td.json", + fenced: true, + help: false, + outputPath: "art.txt", + }); +}); + +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 --diagram - is used", async () => { + await expect( + loadDiagramInput("-", async () => + JSON.stringify({ + version: DRAW_DOCUMENT_VERSION, + objects: [], + }), + ), + ).resolves.toEqual({ + version: DRAW_DOCUMENT_VERSION, + objects: [], + }); +}); + +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}`, + ); +}); diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx index 35f2cbe..f053b22 100644 --- a/packages/app/src/main.tsx +++ b/packages/app/src/main.tsx @@ -1,8 +1,15 @@ import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; -import { buildHelpText, formatSavedOutput, TermDrawApp } from "../../opentui/src/index.js"; +import { + buildHelpText, + formatSavedOutput, + parseDrawDocument, + TermDrawApp, + type DrawDocument, +} from "../../opentui/src/index.js"; export interface CliOptions { + diagramPath?: string; outputPath?: string; fenced: boolean; help: boolean; @@ -42,6 +49,16 @@ export function parseArgs(argv: string[]): CliOptions { continue; } + if (arg === "--diagram") { + const diagramPath = argv[i + 1]; + if (!diagramPath) { + throw new Error(`Missing value for ${arg}`); + } + options.diagramPath = diagramPath; + i += 1; + continue; + } + throw new Error(`Unknown argument: ${arg}`); } @@ -52,6 +69,43 @@ function withTrailingNewline(text: string): string { return text.endsWith("\n") ? text : `${text}\n`; } +export async function readTextFromStdin(): Promise { + let text = ""; + process.stdin.setEncoding("utf8"); + + for await (const chunk of process.stdin) { + text += chunk; + } + + return text; +} + +export async function loadDiagramInput( + path: string, + readFromStdin: () => Promise = readTextFromStdin, +): Promise { + const sourceLabel = path === "-" ? "stdin" : path; + + let content: string; + try { + content = path === "-" ? await readFromStdin() : await Bun.file(path).text(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read diagram from ${sourceLabel}: ${message}`); + } + + try { + return parseDrawDocument(content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to load diagram from ${sourceLabel}: ${message}`); + } +} + +function formatDiagramDocument(document: DrawDocument): string { + return `${JSON.stringify(document, null, 2)}\n`; +} + export async function runTermDrawAppCli(argv = Bun.argv.slice(2)): Promise { const options = parseArgs(argv); @@ -60,6 +114,12 @@ export async function runTermDrawAppCli(argv = Bun.argv.slice(2)): Promise return; } + const initialDocument = options.diagramPath + ? await loadDiagramInput(options.diagramPath) + : undefined; + const initialDiagramPath = + options.diagramPath && options.diagramPath !== "-" ? options.diagramPath : undefined; + const renderer = await createCliRenderer({ exitOnCtrlC: false, useMouse: true, @@ -101,9 +161,14 @@ export async function runTermDrawAppCli(argv = Bun.argv.slice(2)): Promise height="100%" autoFocus cancelOnCtrlC + initialDocument={initialDocument} + diagramPath={initialDiagramPath} onSave={(art: string) => { void finish(art); }} + onSaveDiagram={async (document, path) => { + await Bun.write(path, formatDiagramDocument(document)); + }} onCancel={() => { void finish(null); }} diff --git a/packages/opentui/src/app.ts b/packages/opentui/src/app.ts index 40fc384..7f3b315 100644 --- a/packages/opentui/src/app.ts +++ b/packages/opentui/src/app.ts @@ -13,7 +13,7 @@ import { type RenderContext, type RenderableOptions, } from "@opentui/core"; -import { DrawState, INK_COLORS, truncateToCells } from "./draw-state.js"; +import { DrawState, INK_COLORS, truncateToCells, type DrawDocument } from "./draw-state.js"; import { getColorSwatches, getContextualStyleButtons, @@ -21,10 +21,17 @@ import { getToolButtons, } from "./app/layout.js"; import { handleKeyPress, handleMouseEvent } from "./app/input.js"; -import { drawCanvas, drawChrome, drawTooSmallMessage, drawToolPalette } from "./app/render.js"; +import { + drawCanvas, + drawChrome, + drawDiagramSavePrompt, + drawTooSmallMessage, + drawToolPalette, +} from "./app/render.js"; import { renderStartupLogo } from "./app/startup-logo.js"; import { COLORS, MIN_HEIGHT, MIN_WIDTH, getCanvasInsets } from "./app/theme.js"; import type { AppLayout, ChromeMode } from "./app/types.js"; +import { splitGraphemes } from "./text.js"; /** Configures the shared termDRAW frame-buffer renderable. */ export interface TermDrawRenderableOptions extends RenderableOptions { @@ -32,7 +39,10 @@ export interface TermDrawRenderableOptions extends RenderableOptions void; + onSaveDiagram?: (document: DrawDocument, path: string) => void | Promise; onCancel?: () => void; + initialDocument?: DrawDocument; + diagramPath?: string; autoFocus?: boolean; showStartupLogo?: boolean; cancelOnCtrlC?: boolean; @@ -50,7 +60,15 @@ export class TermDrawRenderable extends FrameBufferRenderable { private readonly state: DrawState; private readonly chromeMode: ChromeMode; private onSaveCallback: ((art: string) => void) | null = null; + private onSaveDiagramCallback: + | ((document: DrawDocument, path: string) => void | Promise) + | null = null; private onCancelCallback: (() => void) | null = null; + private pendingInitialDocument: DrawDocument | null = null; + private diagramPath: string | null = null; + private diagramSavePromptValue: string | null = null; + private diagramSavePromptError: string | null = null; + private diagramSavePending = false; private autoFocusEnabled = false; private startupLogoEnabled = true; private startupLogoDismissed = false; @@ -63,7 +81,10 @@ export class TermDrawRenderable extends FrameBufferRenderable { width, height, onSave, + onSaveDiagram, onCancel, + initialDocument, + diagramPath, autoFocus = false, showStartupLogo = true, cancelOnCtrlC = false, @@ -85,7 +106,10 @@ export class TermDrawRenderable extends FrameBufferRenderable { this.state = new DrawState(this.width, this.height, getCanvasInsets(this.chromeMode)); this.focusable = true; this.onSave = onSave; + this.onSaveDiagram = onSaveDiagram; this.onCancel = onCancel; + this.pendingInitialDocument = initialDocument ?? null; + this.diagramPath = diagramPath?.trim() ? diagramPath : null; this.showStartupLogo = showStartupLogo; this.autoFocus = autoFocus; this.cancelOnCtrlC = cancelOnCtrlC; @@ -111,6 +135,13 @@ export class TermDrawRenderable extends FrameBufferRenderable { this.onCancelCallback = handler ?? null; } + /** Sets the callback invoked when the user saves the editable diagram document. */ + public set onSaveDiagram( + handler: ((document: DrawDocument, path: string) => void | Promise) | undefined, + ) { + this.onSaveDiagramCallback = handler ?? null; + } + /** Enables or disables automatic focus after construction. */ public set autoFocus(value: boolean | undefined) { this.autoFocusEnabled = value ?? false; @@ -145,13 +176,21 @@ export class TermDrawRenderable extends FrameBufferRenderable { /** Exports the current drawing as plain text art. */ public exportArt(): string { + this.loadPendingInitialDocumentIfNeeded(); return this.state.exportArt(); } + /** Exports the current drawing as a versioned editable document. */ + public exportDocument(): DrawDocument { + this.loadPendingInitialDocumentIfNeeded(); + return this.state.exportDocument(); + } + /** Resizes the retained canvas whenever the outer renderable changes size. */ protected override onResize(width: number, height: number): void { super.onResize(width, height); this.syncCanvasLayout(); + this.loadPendingInitialDocumentIfNeeded(); } /** Dispatches mouse interaction to chrome hit targets or the draw-state pointer handler. */ @@ -175,6 +214,7 @@ export class TermDrawRenderable extends FrameBufferRenderable { /** Draws either the full app chrome or the editor-only surface into the frame buffer. */ protected override renderSelf(buffer: OptimizedBuffer): void { const layout = this.syncCanvasLayout(); + this.loadPendingInitialDocumentIfNeeded(); this.frameBuffer.clear(COLORS.panel); if (this.chromeMode === "full") { @@ -196,6 +236,7 @@ export class TermDrawRenderable extends FrameBufferRenderable { this.state, fullLayout, this.footerTextOverride, + this.onSaveDiagramCallback !== null, ); drawToolPalette( this.frameBuffer, @@ -216,16 +257,29 @@ export class TermDrawRenderable extends FrameBufferRenderable { this.startupLogoEnabled, this.startupLogoDismissed, ); + drawDiagramSavePrompt( + this.frameBuffer, + this.width, + this.height, + this.diagramSavePromptValue, + this.diagramSavePromptError, + this.diagramSavePending, + ); super.renderSelf(buffer); } /** Dispatches keyboard shortcuts, cursor movement, and text entry. */ public override handleKeyPress(key: KeyEvent): boolean { + if (this.handleDiagramSavePromptKey(key)) { + return true; + } + return handleKeyPress({ key, state: this.state, cancelOnCtrlCEnabled: this.cancelOnCtrlCEnabled, onSave: this.onSaveCallback ? () => this.onSaveCallback?.(this.state.exportArt()) : null, + onSaveDiagram: this.onSaveDiagramCallback ? () => this.beginDiagramSave() : null, onCancel: this.onCancelCallback, requestRender: () => this.requestRender(), dismissStartupLogo: () => this.dismissStartupLogo(), @@ -254,6 +308,110 @@ export class TermDrawRenderable extends FrameBufferRenderable { ); return layout; } + + /** Applies any deferred initial document once the renderable has a usable canvas size. */ + private loadPendingInitialDocumentIfNeeded(): void { + if (!this.pendingInitialDocument) return; + + this.state.loadDocument(this.pendingInitialDocument); + this.pendingInitialDocument = null; + } + + /** Starts a diagram save, prompting for a path when no diagram path is known yet. */ + private beginDiagramSave(): void { + if (this.diagramSavePending) return; + + if (this.diagramPath) { + void this.saveDiagramToPath(this.diagramPath); + return; + } + + this.diagramSavePromptValue = ""; + this.diagramSavePromptError = null; + this.state.setStatusMessage("Enter a path and press Enter to save the diagram."); + this.requestRender(); + } + + /** Handles keyboard input while the diagram save prompt is visible. */ + private handleDiagramSavePromptKey(key: KeyEvent): boolean { + if (this.diagramSavePromptValue === null) return false; + + const name = key.name.toLowerCase(); + if (name === "escape") { + key.preventDefault(); + this.diagramSavePromptValue = null; + this.diagramSavePromptError = null; + this.state.setStatusMessage("Save diagram cancelled."); + this.requestRender(); + return true; + } + + if (name === "enter" || name === "return") { + key.preventDefault(); + const path = this.diagramSavePromptValue.trim(); + if (!path) { + this.diagramSavePromptError = "Path is required."; + this.state.setStatusMessage("Diagram path is required."); + this.requestRender(); + return true; + } + + void this.saveDiagramToPath(path); + return true; + } + + if (name === "backspace") { + key.preventDefault(); + const graphemes = splitGraphemes(this.diagramSavePromptValue); + graphemes.pop(); + this.diagramSavePromptValue = graphemes.join(""); + this.diagramSavePromptError = null; + this.requestRender(); + return true; + } + + if ( + !key.ctrl && + !key.meta && + !key.option && + key.raw && + !key.raw.startsWith("\u001b") && + name !== "tab" + ) { + key.preventDefault(); + this.diagramSavePromptValue += key.raw; + this.diagramSavePromptError = null; + this.requestRender(); + return true; + } + + return true; + } + + /** Persists the editable document and updates the active diagram path on success. */ + private async saveDiagramToPath(path: string): Promise { + if (!this.onSaveDiagramCallback || this.diagramSavePending) return; + + this.diagramSavePending = true; + this.diagramSavePromptError = null; + this.state.setStatusMessage(`Saving diagram to ${path}...`); + this.requestRender(); + + try { + await this.onSaveDiagramCallback(this.state.exportDocument(), path); + this.diagramPath = path; + this.diagramSavePromptValue = null; + this.diagramSavePromptError = null; + this.state.setStatusMessage(`Saved diagram to ${path}.`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.diagramSavePromptError = message; + this.state.setStatusMessage(`Failed to save diagram: ${message}`); + } finally { + this.diagramSavePending = false; + this.requestRender(); + } + } } /** Options for the full-chrome standalone app renderable. */ @@ -292,7 +450,7 @@ export function formatSavedOutput(art: string, fenced: boolean): string { /** Builds the CLI help text shown by the standalone termDRAW app. */ export function buildHelpText(binaryName = "termdraw"): string { return truncateToCells( - `${binaryName} [--output file] [--fenced|--plain]\n\n` + + `${binaryName} [--diagram file.td.json|-] [--output file] [--fenced|--plain]\n\n` + `Controls:\n` + ` right palette click Select / Box / Line / Brush / Text, box styles, and colors\n` + ` Ctrl+T / Tab cycle select / box / line / brush / text\n` + @@ -313,8 +471,11 @@ export function buildHelpText(binaryName = "termdraw"): string { ` mouse wheel cycle box style in Box mode, line style in Line mode, or brush in Brush mode\n` + ` brush tool choose from preset brush stencils in the palette\n` + ` Space stamp a line point or current brush / insert space in Text mode\n` + - ` Enter / Ctrl+S save\n\n` + + ` Enter / Ctrl+S export art\n` + + ` Ctrl+D save diagram (.td.json), prompting for a path when needed\n\n` + `Options:\n` + + ` --diagram open a native termDRAW document from a file\n` + + ` --diagram - read a native termDRAW document from stdin\n` + ` -o, --output write the result to a file\n` + ` --fenced output as a fenced markdown code block\n` + ` --plain output plain text (default)\n` + diff --git a/packages/opentui/src/app/input.ts b/packages/opentui/src/app/input.ts index b8a02cd..751f1c9 100644 --- a/packages/opentui/src/app/input.ts +++ b/packages/opentui/src/app/input.ts @@ -153,11 +153,20 @@ export function handleKeyPress( state: DrawState; cancelOnCtrlCEnabled: boolean; onSave: (() => void) | null; + onSaveDiagram: (() => void) | null; onCancel: (() => void) | null; } & InputCallbacks, ): boolean { - const { key, state, cancelOnCtrlCEnabled, onSave, onCancel, requestRender, dismissStartupLogo } = - options; + const { + key, + state, + cancelOnCtrlCEnabled, + onSave, + onSaveDiagram, + onCancel, + requestRender, + dismissStartupLogo, + } = options; const name = key.name.toLowerCase(); dismissStartupLogo(); @@ -181,6 +190,13 @@ export function handleKeyPress( return true; } + if (key.ctrl && name === "d") { + if (!onSaveDiagram) return false; + key.preventDefault(); + onSaveDiagram(); + return true; + } + if (name === "tab" || (key.ctrl && name === "t")) { key.preventDefault(); state.cycleMode(); diff --git a/packages/opentui/src/app/render.ts b/packages/opentui/src/app/render.ts index e270d2b..35b5fc5 100644 --- a/packages/opentui/src/app/render.ts +++ b/packages/opentui/src/app/render.ts @@ -6,7 +6,7 @@ */ import { TextAttributes, type OptimizedBuffer } from "@opentui/core"; import type { DrawState } from "../draw-state.js"; -import { padToWidth, visibleCellCount } from "../text.js"; +import { padToWidth, truncateToCells, visibleCellCount } from "../text.js"; import type { AppLayout, ColorSwatch, StyleButton, ToolButton } from "./types.js"; import { BOX_STYLE_OPTIONS, @@ -70,6 +70,7 @@ export function drawChrome( state: DrawState, layout: AppLayout, footerTextOverride: string | null, + canSaveDiagram: boolean, ): void { drawHorizontalBorder(frameBuffer, width, 0, "╭", "╮"); drawHorizontalBorder(frameBuffer, width, height - 1, "╰", "╯"); @@ -84,7 +85,14 @@ export function drawChrome( drawHeaderRow(frameBuffer, width, state, layout); drawHeaderDivider(frameBuffer, width, layout); - drawFooterRow(frameBuffer, width, state.currentStatus, layout, footerTextOverride); + drawFooterRow( + frameBuffer, + width, + state.currentStatus, + layout, + footerTextOverride, + canSaveDiagram, + ); } /** Draws the header row describing the active tool, style, and color. */ @@ -210,10 +218,13 @@ function drawFooterRow( status: string, layout: AppLayout, footerTextOverride: string | null, + canSaveDiagram: boolean, ): void { const text = footerTextOverride ?? - "B Brush • A Select • U Box • P Line • T Text • Esc Deselect • Enter/Ctrl+S Save • Ctrl+Q Quit"; + `B Brush • A Select • U Box • P Line • T Text • Esc Deselect • Enter/Ctrl+S Export Art${ + canSaveDiagram ? " • Ctrl+D Save Diagram" : "" + } • Ctrl+Q Quit`; const combined = `${text} ${status}`; const padded = padToWidth(combined, Math.max(1, width - 2)); frameBuffer.drawText(padded, 1, layout.footerY, COLORS.dim, COLORS.panel); @@ -401,6 +412,72 @@ export function drawCanvas(frameBuffer: OptimizedBuffer, state: DrawState): void } } +/** Draws the minimal save-as prompt used for native diagram persistence. */ +export function drawDiagramSavePrompt( + frameBuffer: OptimizedBuffer, + width: number, + height: number, + value: string | null, + error: string | null, + pending: boolean, +): void { + if (value === null) return; + + const label = "Save diagram as"; + const pathLine = pending ? "Saving..." : value; + const displayPath = pathLine.length > 0 ? pathLine : " "; + const errorLine = error ?? "Enter confirms • Esc cancels"; + const contentWidth = Math.max( + 24, + Math.min( + width - 6, + Math.max( + visibleCellCount(label), + visibleCellCount(displayPath), + visibleCellCount(errorLine), + ) + 2, + ), + ); + const boxWidth = Math.max(10, contentWidth + 2); + const boxHeight = 5; + const left = Math.max(0, Math.floor((width - boxWidth) / 2)); + const top = Math.max(0, Math.floor((height - boxHeight) / 2)); + const pathText = padToWidth(displayPath, contentWidth); + const helperText = truncateToCells(padToWidth(errorLine, contentWidth), contentWidth); + + drawHorizontalBorder(frameBuffer, boxWidth, top, "╭", "╮", left); + for (let y = 1; y < boxHeight - 1; y += 1) { + frameBuffer.setCell(left, top + y, "│", COLORS.border, COLORS.panel); + frameBuffer.drawText(" ".repeat(boxWidth - 2), left + 1, top + y, COLORS.text, COLORS.panel); + frameBuffer.setCell(left + boxWidth - 1, top + y, "│", COLORS.border, COLORS.panel); + } + drawHorizontalBorder(frameBuffer, boxWidth, top + boxHeight - 1, "╰", "╯", left); + + frameBuffer.drawText( + padToWidth(label, contentWidth), + left + 1, + top + 1, + COLORS.text, + COLORS.panel, + TextAttributes.BOLD, + ); + frameBuffer.drawText( + pathText, + left + 1, + top + 2, + COLORS.accent, + COLORS.panel, + TextAttributes.BOLD, + ); + frameBuffer.drawText( + helperText, + left + 1, + top + 3, + error ? COLORS.warning : COLORS.dim, + COLORS.panel, + ); +} + /** Draws the outer vertical borders for a single frame row. */ function drawOuterSideBorders(frameBuffer: OptimizedBuffer, width: number, y: number): void { frameBuffer.setCell(0, y, "│", COLORS.border, COLORS.panel); @@ -414,10 +491,11 @@ function drawHorizontalBorder( y: number, left: string, right: string, + startX = 0, ): void { - frameBuffer.setCell(0, y, left, COLORS.border, COLORS.panel); + frameBuffer.setCell(startX, y, left, COLORS.border, COLORS.panel); for (let x = 1; x < width - 1; x += 1) { - frameBuffer.setCell(x, y, "─", COLORS.border, COLORS.panel); + frameBuffer.setCell(startX + x, y, "─", COLORS.border, COLORS.panel); } - frameBuffer.setCell(width - 1, y, right, COLORS.border, COLORS.panel); + frameBuffer.setCell(startX + width - 1, y, right, COLORS.border, COLORS.panel); } diff --git a/packages/opentui/src/draw-state.test.ts b/packages/opentui/src/draw-state.test.ts index 4f653d7..3db281d 100644 --- a/packages/opentui/src/draw-state.test.ts +++ b/packages/opentui/src/draw-state.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; import { MouseButton } from "@opentui/core"; -import { DrawState, TEXT_BORDER_MODES } from "./draw-state"; +import { + DRAW_DOCUMENT_VERSION, + DrawState, + parseDrawDocument, + TEXT_BORDER_MODES, +} from "./draw-state"; /** Converts canvas-local coordinates into the pointer coordinates expected by `DrawState`. */ function canvasPoint(state: DrawState, x: number, y: number) { @@ -828,4 +833,63 @@ describe("DrawState", () => { state.redo(); expect(state.getCompositeCell(4, 2)).toBe("┏"); }); + + test("exports and reloads a native document without losing object metadata", () => { + const document = { + version: DRAW_DOCUMENT_VERSION, + objects: [ + { + id: "obj-3", + type: "box" as const, + z: 1, + parentId: null, + color: "cyan" as const, + left: 1, + top: 1, + right: 6, + bottom: 4, + style: "double" as const, + }, + { + id: "obj-7", + type: "text" as const, + z: 2, + parentId: null, + color: "yellow" as const, + x: 2, + y: 2, + content: "Hi", + border: "none" as const, + }, + ], + }; + + const state = new DrawState(20, 10); + state.loadDocument(document); + + expect(state.exportDocument()).toEqual({ + ...document, + objects: [ + document.objects[0]!, + { + ...document.objects[1]!, + parentId: "obj-3", + }, + ], + }); + expect(state.exportArt()).toBe(" ╔════╗\n ║Hi ║\n ║ ║\n ╚════╝"); + expect(state.currentStatus).toContain("Loaded diagram with 2 objects"); + }); + + test("parseDrawDocument rejects invalid document shapes with clear errors", () => { + expect(() => + parseDrawDocument( + JSON.stringify({ version: DRAW_DOCUMENT_VERSION, objects: [{ id: "obj-1" }] }), + ), + ).toThrow("objects[0].z must be an integer."); + + expect(() => parseDrawDocument(JSON.stringify({ version: 999, objects: [] }))).toThrow( + `termDRAW document version must be ${DRAW_DOCUMENT_VERSION}`, + ); + }); }); diff --git a/packages/opentui/src/draw-state.ts b/packages/opentui/src/draw-state.ts index 0df23cb..16f1568 100644 --- a/packages/opentui/src/draw-state.ts +++ b/packages/opentui/src/draw-state.ts @@ -59,6 +59,7 @@ import { import { BRUSHES, BOX_STYLES, + DRAW_DOCUMENT_VERSION, DEFAULT_CANVAS_INSETS, INK_COLORS, LINE_STYLES, @@ -72,6 +73,7 @@ import { type ConnectionGrid, type ConnectionStyle, type DragState, + type DrawDocument, type DrawMode, type DrawObject, type EraseState, @@ -97,6 +99,7 @@ import { export { BRUSHES, BOX_STYLES, + DRAW_DOCUMENT_VERSION, INK_COLORS, LINE_STYLES, TEXT_BORDER_MODES, @@ -107,6 +110,7 @@ export { export type { BoxStyle, CanvasInsets, + DrawDocument, DrawMode, DrawObject, InkColor, @@ -118,6 +122,185 @@ export type { const MAX_HISTORY = 100; const HANDLE_CHARACTER = "●"; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readInteger(value: unknown, label: string): number { + if (!Number.isInteger(value)) { + throw new Error(`${label} must be an integer.`); + } + return value as number; +} + +function readNonEmptyString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${label} must be a non-empty string.`); + } + return value; +} + +function readString(value: unknown, label: string): string { + if (typeof value !== "string") { + throw new Error(`${label} must be a string.`); + } + return value; +} + +function readNullableString(value: unknown, label: string): string | null { + if (value === null) return null; + if (typeof value !== "string") { + throw new Error(`${label} must be a string or null.`); + } + return value; +} + +function readEnumValue(value: unknown, label: string, options: readonly T[]): T { + if (typeof value !== "string" || !options.includes(value as T)) { + throw new Error(`${label} must be one of: ${options.join(", ")}.`); + } + return value as T; +} + +function readPoint(value: unknown, label: string): Point { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + + return { + x: readInteger(value.x, `${label}.x`), + y: readInteger(value.y, `${label}.y`), + }; +} + +function parseDocumentObject(value: unknown, index: number): DrawObject { + const label = `objects[${index}]`; + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + + const id = readNonEmptyString(value.id, `${label}.id`); + const z = readInteger(value.z, `${label}.z`); + const parentId = readNullableString(value.parentId, `${label}.parentId`); + const color = readEnumValue(value.color, `${label}.color`, INK_COLORS); + const type = readString(value.type, `${label}.type`); + + switch (type) { + case "box": { + const box = { + id, + type, + z, + parentId, + color, + left: readInteger(value.left, `${label}.left`), + top: readInteger(value.top, `${label}.top`), + right: readInteger(value.right, `${label}.right`), + bottom: readInteger(value.bottom, `${label}.bottom`), + style: readEnumValue(value.style, `${label}.style`, BOX_STYLES), + } satisfies BoxObject; + + if (!isValidRect(box)) { + throw new Error(`${label} must have valid box bounds.`); + } + + return box; + } + case "line": + return { + id, + type, + z, + parentId, + color, + x1: readInteger(value.x1, `${label}.x1`), + y1: readInteger(value.y1, `${label}.y1`), + x2: readInteger(value.x2, `${label}.x2`), + y2: readInteger(value.y2, `${label}.y2`), + style: readEnumValue(value.style, `${label}.style`, LINE_STYLES), + } satisfies LineObject; + case "paint": { + const pointsValue = value.points; + if (!Array.isArray(pointsValue) || pointsValue.length === 0) { + throw new Error(`${label}.points must be a non-empty array.`); + } + + const brush = readString(value.brush, `${label}.brush`); + if (visibleCellCount(brush) !== 1) { + throw new Error(`${label}.brush must be exactly one visible cell.`); + } + + return { + id, + type, + z, + parentId, + color, + points: pointsValue.map((point, pointIndex) => + readPoint(point, `${label}.points[${pointIndex}]`), + ), + brush, + } satisfies PaintObject; + } + case "text": + return { + id, + type, + z, + parentId, + color, + x: readInteger(value.x, `${label}.x`), + y: readInteger(value.y, `${label}.y`), + content: readString(value.content, `${label}.content`), + border: readEnumValue(value.border, `${label}.border`, TEXT_BORDER_MODES), + } satisfies TextObject; + default: + throw new Error(`${label}.type must be one of: box, line, paint, text.`); + } +} + +export function validateDrawDocument(value: unknown): DrawDocument { + if (!isRecord(value)) { + throw new Error("termDRAW document must be a JSON object."); + } + + if (value.version !== DRAW_DOCUMENT_VERSION) { + throw new Error( + `termDRAW document version must be ${DRAW_DOCUMENT_VERSION}; received ${String(value.version)}.`, + ); + } + + if (!Array.isArray(value.objects)) { + throw new Error("termDRAW document objects must be an array."); + } + + const objects = value.objects.map((object, index) => parseDocumentObject(object, index)); + const ids = new Set(); + for (const object of objects) { + if (ids.has(object.id)) { + throw new Error(`termDRAW document contains duplicate object id "${object.id}".`); + } + ids.add(object.id); + } + + return { + version: DRAW_DOCUMENT_VERSION, + objects, + }; +} + +export function parseDrawDocument(input: string): DrawDocument { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid JSON."; + throw new Error(`Failed to parse termDRAW document JSON: ${message}`); + } + + return validateDrawDocument(parsed); +} + /** * Coordinates the editable termDRAW scene, tool state, selection state, and rendering caches. */ @@ -962,6 +1145,62 @@ export class DrawState { return lines.join("\n"); } + /** Exports the editable scene as a versioned termDRAW document. */ + public exportDocument(): DrawDocument { + return { + version: DRAW_DOCUMENT_VERSION, + objects: cloneObjects(this.objects), + }; + } + + /** Replaces the current editable scene from a validated termDRAW document. */ + public loadDocument(document: DrawDocument): void { + const validatedDocument = validateDrawDocument(document); + const nextObjects = cloneObjects(validatedDocument.objects).map((object) => + this.shiftObjectInsideCanvas(object), + ); + + this.objects = this.recomputeParentAssignments(nextObjects); + this.selectedObjectIds = []; + this.selectedObjectId = null; + 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.cursorX = 0; + this.cursorY = 0; + this.mode = "line"; + this.brush = BRUSHES[0]; + this.brushIndex = 0; + this.boxStyle = BOX_STYLES[0]; + this.boxStyleIndex = 0; + this.lineStyle = LINE_STYLES[0]; + this.lineStyleIndex = 0; + this.textBorderMode = TEXT_BORDER_MODES[0]; + this.textBorderModeIndex = 0; + this.inkColor = INK_COLORS[0]; + this.inkColorIndex = 0; + this.nextObjectNumber = this.getNextDocumentObjectNumber(this.objects); + this.nextZIndex = this.getNextDocumentZIndex(this.objects); + this.markSceneDirty(); + this.setStatus( + this.objects.length === 0 + ? "Loaded empty diagram." + : `Loaded diagram with ${this.objects.length} object${this.objects.length === 1 ? "" : "s"}.`, + ); + } + + /** Replaces the footer status text with an explicit application message. */ + public setStatusMessage(message: string): void { + this.setStatus(message); + } + /** Attempts to start a resize, endpoint drag, or move interaction at the given cell. */ private tryBeginObjectInteraction(x: number, y: number): boolean { this.activeTextObjectId = null; @@ -2231,6 +2470,29 @@ export class DrawState { return z; } + /** Derives the next stable `obj-N` identifier after loading a document. */ + private getNextDocumentObjectNumber(objects: DrawObject[]): number { + let maxNumber = 0; + + for (const object of objects) { + const match = /^obj-(\d+)$/.exec(object.id); + if (!match) continue; + + const parsed = Number.parseInt(match[1]!, 10); + if (Number.isInteger(parsed)) { + maxNumber = Math.max(maxNumber, parsed); + } + } + + return Math.max(1, maxNumber + 1, objects.length + 1); + } + + /** Derives the next z-index after loading a document. */ + private getNextDocumentZIndex(objects: DrawObject[]): number { + const maxZ = objects.reduce((currentMax, object) => Math.max(currentMax, object.z), 0); + return Math.max(1, maxZ + 1); + } + /** Formats rectangle bounds for user-facing status text. */ private describeRect(rect: Rect): string { return `${rect.left + 1},${rect.top + 1} → ${rect.right + 1},${rect.bottom + 1}`; diff --git a/packages/opentui/src/draw-state/types.ts b/packages/opentui/src/draw-state/types.ts index ef36f12..00833b1 100644 --- a/packages/opentui/src/draw-state/types.ts +++ b/packages/opentui/src/draw-state/types.ts @@ -18,6 +18,7 @@ export const INK_COLORS = [ "magenta", ] as const; export const TEXT_BORDER_MODES = ["none", "single", "double", "underline"] as const; +export const DRAW_DOCUMENT_VERSION = 1 as const; export type DrawMode = "select" | "box" | "line" | "paint" | "text"; export type BoxStyle = (typeof BOX_STYLES)[number]; @@ -83,6 +84,10 @@ export type TextObject = BaseDrawObject & { }; export type DrawObject = BoxObject | LineObject | PaintObject | TextObject; +export type DrawDocument = { + version: typeof DRAW_DOCUMENT_VERSION; + objects: DrawObject[]; +}; export type Snapshot = { objects: DrawObject[]; diff --git a/packages/opentui/src/index.ts b/packages/opentui/src/index.ts index 638189c..e7ead1d 100644 --- a/packages/opentui/src/index.ts +++ b/packages/opentui/src/index.ts @@ -11,11 +11,15 @@ import { import { BRUSHES, BOX_STYLES, + DRAW_DOCUMENT_VERSION, DrawState, INK_COLORS, LINE_STYLES, TEXT_BORDER_MODES, + parseDrawDocument, + validateDrawDocument, type BoxStyle, + type DrawDocument, type DrawMode, type DrawObject, type InkColor, @@ -40,6 +44,7 @@ import { export { BRUSHES, BOX_STYLES, + DRAW_DOCUMENT_VERSION, DrawState, INK_COLORS, LINE_STYLES, @@ -55,9 +60,12 @@ export { TermDrawRenderable, buildHelpText, formatSavedOutput, + parseDrawDocument, registerTermDrawComponent, registerTermDrawComponents, + validateDrawDocument, type BoxStyle, + type DrawDocument, type DrawMode, type DrawObject, type InkColor, diff --git a/packages/opentui/src/react.test.tsx b/packages/opentui/src/react.test.tsx index bdb97d9..052ad44 100644 --- a/packages/opentui/src/react.test.tsx +++ b/packages/opentui/src/react.test.tsx @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { buildHelpText } from "./app"; +import { DRAW_DOCUMENT_VERSION } from "./draw-state"; import { TermDraw, TermDrawApp, TermDrawEditor } from "./react"; function expectEmptySave(savedArt: string | null): void { @@ -121,6 +122,8 @@ test("help text documents tool hotkeys and automatic line rendering", () => { expect(help).toContain("B / A / U / P / T"); expect(help).toContain("choose Smooth (Braille-aware), Single, or Double line stencils"); expect(help).toContain("choose from preset brush stencils in the palette"); + expect(help).toContain("--diagram "); + expect(help).toContain("Ctrl+D save diagram (.td.json)"); expect(help).toContain( "Shift + drag constrain line creation/editing to horizontal or vertical", ); @@ -199,3 +202,129 @@ test("TermDraw remains an alias for the full app component", async () => { expect(frame).toContain("termDRAW!"); expect(frame).toContain("Tools"); }); + +test("TermDrawApp renders a provided initial document", async () => { + const { captureCharFrame, renderOnce } = await testRender( + , + { + width: 64, + height: 29, + useMouse: true, + enableMouseMovement: true, + }, + ); + + await renderOnce(); + + expect(captureCharFrame()).toContain("────"); +}); + +test("TermDrawApp saves the current diagram to the loaded path", async () => { + let savedPath: string | null = null; + let savedDocument: unknown = null; + + const { mockInput, renderOnce } = await testRender( + { + savedDocument = document; + savedPath = path; + }} + />, + { + width: 64, + height: 29, + useMouse: true, + enableMouseMovement: true, + }, + ); + + await renderOnce(); + + mockInput.pressKey("d", { ctrl: true }); + await renderOnce(); + + if (savedPath === null) { + throw new Error("Expected diagram save to capture a path."); + } + expect(savedPath === "loaded.td.json").toBe(true); + expect(savedDocument).toEqual({ + version: DRAW_DOCUMENT_VERSION, + objects: [], + }); +}); + +test("TermDrawApp prompts for a diagram path and reuses it on later saves", async () => { + const savedPaths: string[] = []; + + const { captureCharFrame, mockInput, renderOnce } = await testRender( + { + savedPaths.push(path); + }} + />, + { + width: 64, + height: 29, + useMouse: true, + enableMouseMovement: true, + }, + ); + + await renderOnce(); + + mockInput.pressKey("d", { ctrl: true }); + await renderOnce(); + expect(captureCharFrame()).toContain("Save diagram as"); + + for (const char of "diagram") { + mockInput.pressKey(char); + } + mockInput.pressEnter(); + await renderOnce(); + + expect(savedPaths).toEqual(["diagram"]); + + mockInput.pressKey("d", { ctrl: true }); + await renderOnce(); + + expect(savedPaths).toEqual(["diagram", "diagram"]); +}); From 2daadcbff52bdc9fe0f8ffc821d3620152b78533 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Sat, 25 Apr 2026 17:55:24 +0000 Subject: [PATCH 2/6] fix(app): improve diagram load and save behavior --- diag.td.json | 711 ++++++++++++++++++++++++++++ packages/app/src/main.test.ts | 35 +- packages/app/src/main.tsx | 56 ++- packages/opentui/src/app.ts | 23 +- packages/opentui/src/react.test.tsx | 46 +- 5 files changed, 851 insertions(+), 20 deletions(-) create mode 100644 diag.td.json diff --git a/diag.td.json b/diag.td.json new file mode 100644 index 0000000..ef27098 --- /dev/null +++ b/diag.td.json @@ -0,0 +1,711 @@ +{ + "version": 1, + "objects": [ + { + "id": "obj-1", + "type": "paint", + "z": 7, + "parentId": null, + "color": "white", + "points": [ + { + "x": 70, + "y": 4 + }, + { + "x": 69, + "y": 4 + }, + { + "x": 68, + "y": 4 + }, + { + "x": 67, + "y": 4 + }, + { + "x": 66, + "y": 4 + }, + { + "x": 65, + "y": 4 + }, + { + "x": 64, + "y": 4 + }, + { + "x": 63, + "y": 4 + }, + { + "x": 62, + "y": 4 + }, + { + "x": 61, + "y": 4 + }, + { + "x": 60, + "y": 4 + }, + { + "x": 59, + "y": 5 + }, + { + "x": 58, + "y": 5 + }, + { + "x": 57, + "y": 5 + }, + { + "x": 56, + "y": 5 + }, + { + "x": 55, + "y": 5 + }, + { + "x": 54, + "y": 6 + }, + { + "x": 53, + "y": 6 + }, + { + "x": 52, + "y": 7 + }, + { + "x": 51, + "y": 7 + }, + { + "x": 51, + "y": 8 + }, + { + "x": 50, + "y": 8 + }, + { + "x": 49, + "y": 9 + }, + { + "x": 48, + "y": 10 + }, + { + "x": 47, + "y": 11 + }, + { + "x": 46, + "y": 12 + }, + { + "x": 46, + "y": 13 + }, + { + "x": 45, + "y": 14 + }, + { + "x": 45, + "y": 15 + }, + { + "x": 45, + "y": 16 + }, + { + "x": 46, + "y": 16 + }, + { + "x": 46, + "y": 17 + }, + { + "x": 47, + "y": 18 + }, + { + "x": 48, + "y": 19 + }, + { + "x": 49, + "y": 20 + }, + { + "x": 50, + "y": 21 + }, + { + "x": 51, + "y": 21 + }, + { + "x": 52, + "y": 22 + }, + { + "x": 53, + "y": 22 + }, + { + "x": 54, + "y": 22 + }, + { + "x": 55, + "y": 23 + }, + { + "x": 56, + "y": 23 + }, + { + "x": 57, + "y": 24 + }, + { + "x": 58, + "y": 24 + }, + { + "x": 59, + "y": 24 + }, + { + "x": 60, + "y": 24 + }, + { + "x": 61, + "y": 25 + }, + { + "x": 62, + "y": 25 + }, + { + "x": 63, + "y": 25 + }, + { + "x": 64, + "y": 25 + }, + { + "x": 65, + "y": 25 + }, + { + "x": 66, + "y": 26 + }, + { + "x": 67, + "y": 26 + }, + { + "x": 68, + "y": 26 + }, + { + "x": 69, + "y": 26 + }, + { + "x": 70, + "y": 26 + }, + { + "x": 71, + "y": 26 + }, + { + "x": 72, + "y": 26 + }, + { + "x": 73, + "y": 26 + }, + { + "x": 74, + "y": 26 + }, + { + "x": 75, + "y": 26 + }, + { + "x": 76, + "y": 26 + }, + { + "x": 77, + "y": 26 + }, + { + "x": 78, + "y": 26 + }, + { + "x": 79, + "y": 26 + }, + { + "x": 80, + "y": 26 + }, + { + "x": 81, + "y": 26 + }, + { + "x": 82, + "y": 26 + }, + { + "x": 83, + "y": 26 + }, + { + "x": 84, + "y": 26 + }, + { + "x": 85, + "y": 26 + }, + { + "x": 86, + "y": 26 + }, + { + "x": 87, + "y": 25 + }, + { + "x": 88, + "y": 25 + }, + { + "x": 89, + "y": 25 + }, + { + "x": 90, + "y": 24 + }, + { + "x": 91, + "y": 23 + }, + { + "x": 92, + "y": 22 + }, + { + "x": 92, + "y": 21 + }, + { + "x": 92, + "y": 20 + }, + { + "x": 92, + "y": 19 + }, + { + "x": 92, + "y": 18 + }, + { + "x": 92, + "y": 17 + }, + { + "x": 92, + "y": 16 + }, + { + "x": 92, + "y": 15 + }, + { + "x": 91, + "y": 14 + }, + { + "x": 91, + "y": 13 + }, + { + "x": 90, + "y": 13 + }, + { + "x": 90, + "y": 12 + }, + { + "x": 89, + "y": 11 + }, + { + "x": 88, + "y": 10 + }, + { + "x": 87, + "y": 10 + }, + { + "x": 86, + "y": 9 + }, + { + "x": 85, + "y": 8 + }, + { + "x": 84, + "y": 8 + }, + { + "x": 83, + "y": 7 + }, + { + "x": 82, + "y": 7 + }, + { + "x": 82, + "y": 6 + }, + { + "x": 81, + "y": 6 + }, + { + "x": 80, + "y": 6 + }, + { + "x": 80, + "y": 5 + }, + { + "x": 79, + "y": 5 + }, + { + "x": 78, + "y": 4 + }, + { + "x": 77, + "y": 4 + }, + { + "x": 76, + "y": 4 + }, + { + "x": 76, + "y": 3 + }, + { + "x": 75, + "y": 3 + }, + { + "x": 74, + "y": 3 + }, + { + "x": 73, + "y": 3 + }, + { + "x": 72, + "y": 3 + }, + { + "x": 71, + "y": 3 + }, + { + "x": 70, + "y": 3 + }, + { + "x": 69, + "y": 3 + }, + { + "x": 68, + "y": 3 + }, + { + "x": 67, + "y": 3 + }, + { + "x": 66, + "y": 3 + }, + { + "x": 65, + "y": 3 + } + ], + "brush": "#" + }, + { + "id": "obj-2", + "type": "paint", + "z": 2, + "parentId": null, + "color": "white", + "points": [ + { + "x": 63, + "y": 10 + }, + { + "x": 62, + "y": 10 + }, + { + "x": 61, + "y": 10 + }, + { + "x": 64, + "y": 10 + }, + { + "x": 64, + "y": 9 + }, + { + "x": 63, + "y": 9 + } + ], + "brush": "#" + }, + { + "id": "obj-3", + "type": "paint", + "z": 3, + "parentId": null, + "color": "white", + "points": [ + { + "x": 77, + "y": 10 + }, + { + "x": 76, + "y": 10 + }, + { + "x": 75, + "y": 10 + }, + { + "x": 74, + "y": 10 + }, + { + "x": 74, + "y": 11 + }, + { + "x": 75, + "y": 11 + }, + { + "x": 76, + "y": 11 + }, + { + "x": 76, + "y": 12 + }, + { + "x": 77, + "y": 12 + }, + { + "x": 78, + "y": 12 + }, + { + "x": 78, + "y": 11 + }, + { + "x": 78, + "y": 10 + }, + { + "x": 77, + "y": 9 + }, + { + "x": 76, + "y": 9 + }, + { + "x": 75, + "y": 9 + } + ], + "brush": "#" + }, + { + "id": "obj-4", + "type": "paint", + "z": 4, + "parentId": null, + "color": "white", + "points": [ + { + "x": 68, + "y": 14 + }, + { + "x": 68, + "y": 15 + }, + { + "x": 68, + "y": 16 + } + ], + "brush": "#" + }, + { + "id": "obj-5", + "type": "paint", + "z": 5, + "parentId": null, + "color": "white", + "points": [ + { + "x": 61, + "y": 16 + }, + { + "x": 61, + "y": 17 + }, + { + "x": 61, + "y": 18 + }, + { + "x": 62, + "y": 18 + }, + { + "x": 62, + "y": 19 + }, + { + "x": 63, + "y": 19 + }, + { + "x": 64, + "y": 20 + }, + { + "x": 65, + "y": 21 + }, + { + "x": 66, + "y": 21 + }, + { + "x": 67, + "y": 22 + }, + { + "x": 68, + "y": 22 + }, + { + "x": 69, + "y": 22 + }, + { + "x": 70, + "y": 22 + }, + { + "x": 71, + "y": 22 + }, + { + "x": 72, + "y": 22 + }, + { + "x": 73, + "y": 22 + }, + { + "x": 74, + "y": 22 + }, + { + "x": 75, + "y": 22 + }, + { + "x": 76, + "y": 21 + }, + { + "x": 77, + "y": 21 + }, + { + "x": 77, + "y": 20 + }, + { + "x": 78, + "y": 20 + }, + { + "x": 78, + "y": 19 + } + ], + "brush": "#" + } + ] +} diff --git a/packages/app/src/main.test.ts b/packages/app/src/main.test.ts index d271bfe..fc33c64 100644 --- a/packages/app/src/main.test.ts +++ b/packages/app/src/main.test.ts @@ -1,9 +1,15 @@ +import { PassThrough } from "node:stream"; import { afterEach, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { DRAW_DOCUMENT_VERSION } from "../../opentui/src/index"; -import { loadDiagramInput, parseArgs } from "./main"; +import { + loadDiagramInput, + parseArgs, + readTextFromStdin, + shouldUseInteractiveTtyInput, +} from "./main"; const tempDirs: string[] = []; @@ -56,6 +62,33 @@ test("loadDiagramInput reads stdin when --diagram - is used", async () => { }); }); +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 diagram input", () => { + expect(shouldUseInteractiveTtyInput("-", { isTTY: false })).toBe(true); + expect(shouldUseInteractiveTtyInput("-", { isTTY: true })).toBe(false); + expect(shouldUseInteractiveTtyInput("diagram.td.json", { isTTY: false })).toBe(false); +}); + 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}`, diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx index f053b22..51ae4cb 100644 --- a/packages/app/src/main.tsx +++ b/packages/app/src/main.tsx @@ -1,3 +1,5 @@ +import { openSync } from "node:fs"; +import { ReadStream } from "node:tty"; import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { @@ -15,6 +17,16 @@ export interface CliOptions { help: boolean; } +type StdinLike = NodeJS.ReadableStream & { + isTTY?: boolean; + pause(): void; + setEncoding(encoding: BufferEncoding): void; +}; + +function openInteractiveStdin(): NodeJS.ReadStream { + return new ReadStream(openSync("/dev/tty", "r")); +} + export function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { fenced: false, @@ -69,17 +81,28 @@ function withTrailingNewline(text: string): string { return text.endsWith("\n") ? text : `${text}\n`; } -export async function readTextFromStdin(): Promise { +export async function readTextFromStdin(stdin: StdinLike = process.stdin): Promise { let text = ""; - process.stdin.setEncoding("utf8"); + stdin.setEncoding("utf8"); - for await (const chunk of process.stdin) { - text += chunk; + try { + for await (const chunk of stdin) { + text += chunk; + } + } finally { + stdin.pause(); } return text; } +export function shouldUseInteractiveTtyInput( + diagramPath: string | undefined, + stdin: Pick = process.stdin, +): boolean { + return diagramPath === "-" && !stdin.isTTY; +} + export async function loadDiagramInput( path: string, readFromStdin: () => Promise = readTextFromStdin, @@ -119,14 +142,24 @@ export async function runTermDrawAppCli(argv = Bun.argv.slice(2)): Promise : undefined; const initialDiagramPath = options.diagramPath && options.diagramPath !== "-" ? options.diagramPath : undefined; + const interactiveStdin = shouldUseInteractiveTtyInput(options.diagramPath) + ? openInteractiveStdin() + : null; - const renderer = await createCliRenderer({ - exitOnCtrlC: false, - useMouse: true, - enableMouseMovement: true, - autoFocus: true, - screenMode: "alternate-screen", - }); + let renderer; + try { + renderer = await createCliRenderer({ + stdin: interactiveStdin ?? process.stdin, + exitOnCtrlC: false, + useMouse: true, + enableMouseMovement: true, + autoFocus: true, + screenMode: "alternate-screen", + }); + } catch (error) { + interactiveStdin?.destroy(); + throw error; + } const root = createRoot(renderer); let finished = false; @@ -136,6 +169,7 @@ export async function runTermDrawAppCli(argv = Bun.argv.slice(2)): Promise finished = true; renderer.destroy(); + interactiveStdin?.destroy(); await new Promise((resolve) => setTimeout(resolve, 20)); if (art === null) { diff --git a/packages/opentui/src/app.ts b/packages/opentui/src/app.ts index 7f3b315..232d4f9 100644 --- a/packages/opentui/src/app.ts +++ b/packages/opentui/src/app.ts @@ -33,6 +33,19 @@ import { COLORS, MIN_HEIGHT, MIN_WIDTH, getCanvasInsets } from "./app/theme.js"; import type { AppLayout, ChromeMode } from "./app/types.js"; import { splitGraphemes } from "./text.js"; +function normalizeDiagramPath(path: string): string { + const trimmedPath = path.trim(); + const lastSeparatorIndex = Math.max(trimmedPath.lastIndexOf("/"), trimmedPath.lastIndexOf("\\")); + const fileName = trimmedPath.slice(lastSeparatorIndex + 1); + const extensionIndex = fileName.lastIndexOf("."); + + if (extensionIndex <= 0) { + return `${trimmedPath}.td.json`; + } + + return trimmedPath; +} + /** Configures the shared termDRAW frame-buffer renderable. */ export interface TermDrawRenderableOptions extends RenderableOptions { width?: number | "auto" | `${number}%`; @@ -110,6 +123,7 @@ export class TermDrawRenderable extends FrameBufferRenderable { this.onCancel = onCancel; this.pendingInitialDocument = initialDocument ?? null; this.diagramPath = diagramPath?.trim() ? diagramPath : null; + this.startupLogoDismissed = initialDocument !== undefined; this.showStartupLogo = showStartupLogo; this.autoFocus = autoFocus; this.cancelOnCtrlC = cancelOnCtrlC; @@ -392,17 +406,18 @@ export class TermDrawRenderable extends FrameBufferRenderable { private async saveDiagramToPath(path: string): Promise { if (!this.onSaveDiagramCallback || this.diagramSavePending) return; + const normalizedPath = normalizeDiagramPath(path); this.diagramSavePending = true; this.diagramSavePromptError = null; - this.state.setStatusMessage(`Saving diagram to ${path}...`); + this.state.setStatusMessage(`Saving diagram to ${normalizedPath}...`); this.requestRender(); try { - await this.onSaveDiagramCallback(this.state.exportDocument(), path); - this.diagramPath = path; + await this.onSaveDiagramCallback(this.state.exportDocument(), normalizedPath); + this.diagramPath = normalizedPath; this.diagramSavePromptValue = null; this.diagramSavePromptError = null; - this.state.setStatusMessage(`Saved diagram to ${path}.`); + this.state.setStatusMessage(`Saved diagram to ${normalizedPath}.`); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.diagramSavePromptError = message; diff --git a/packages/opentui/src/react.test.tsx b/packages/opentui/src/react.test.tsx index 052ad44..90461cd 100644 --- a/packages/opentui/src/react.test.tsx +++ b/packages/opentui/src/react.test.tsx @@ -209,7 +209,6 @@ test("TermDrawApp renders a provided initial document", async () => { width="100%" height="100%" autoFocus - showStartupLogo={false} initialDocument={{ version: DRAW_DOCUMENT_VERSION, objects: [ @@ -238,7 +237,9 @@ test("TermDrawApp renders a provided initial document", async () => { await renderOnce(); - expect(captureCharFrame()).toContain("────"); + const frame = captureCharFrame(); + expect(frame).toContain("────"); + expect(frame).not.toContain("Licensed under MIT"); }); test("TermDrawApp saves the current diagram to the loaded path", async () => { @@ -321,10 +322,47 @@ test("TermDrawApp prompts for a diagram path and reuses it on later saves", asyn mockInput.pressEnter(); await renderOnce(); - expect(savedPaths).toEqual(["diagram"]); + expect(savedPaths).toEqual(["diagram.td.json"]); + + mockInput.pressKey("d", { ctrl: true }); + await renderOnce(); + + expect(savedPaths).toEqual(["diagram.td.json", "diagram.td.json"]); +}); + +test("TermDrawApp appends .td.json when saving a loaded diagram without an extension", async () => { + let savedPath: string | null = null; + + const { mockInput, renderOnce } = await testRender( + { + savedPath = path; + }} + />, + { + width: 64, + height: 29, + useMouse: true, + enableMouseMovement: true, + }, + ); + + await renderOnce(); mockInput.pressKey("d", { ctrl: true }); await renderOnce(); - expect(savedPaths).toEqual(["diagram", "diagram"]); + if (savedPath === null) { + throw new Error("Expected diagram save to capture a path."); + } + expect(savedPath === "loaded-diagram.td.json").toBe(true); }); From 648754217ce275363c5ac4ca9443b53957efd673 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Sat, 25 Apr 2026 18:28:04 +0000 Subject: [PATCH 3/6] remove sample file --- diag.td.json | 711 --------------------------------------------------- 1 file changed, 711 deletions(-) delete mode 100644 diag.td.json diff --git a/diag.td.json b/diag.td.json deleted file mode 100644 index ef27098..0000000 --- a/diag.td.json +++ /dev/null @@ -1,711 +0,0 @@ -{ - "version": 1, - "objects": [ - { - "id": "obj-1", - "type": "paint", - "z": 7, - "parentId": null, - "color": "white", - "points": [ - { - "x": 70, - "y": 4 - }, - { - "x": 69, - "y": 4 - }, - { - "x": 68, - "y": 4 - }, - { - "x": 67, - "y": 4 - }, - { - "x": 66, - "y": 4 - }, - { - "x": 65, - "y": 4 - }, - { - "x": 64, - "y": 4 - }, - { - "x": 63, - "y": 4 - }, - { - "x": 62, - "y": 4 - }, - { - "x": 61, - "y": 4 - }, - { - "x": 60, - "y": 4 - }, - { - "x": 59, - "y": 5 - }, - { - "x": 58, - "y": 5 - }, - { - "x": 57, - "y": 5 - }, - { - "x": 56, - "y": 5 - }, - { - "x": 55, - "y": 5 - }, - { - "x": 54, - "y": 6 - }, - { - "x": 53, - "y": 6 - }, - { - "x": 52, - "y": 7 - }, - { - "x": 51, - "y": 7 - }, - { - "x": 51, - "y": 8 - }, - { - "x": 50, - "y": 8 - }, - { - "x": 49, - "y": 9 - }, - { - "x": 48, - "y": 10 - }, - { - "x": 47, - "y": 11 - }, - { - "x": 46, - "y": 12 - }, - { - "x": 46, - "y": 13 - }, - { - "x": 45, - "y": 14 - }, - { - "x": 45, - "y": 15 - }, - { - "x": 45, - "y": 16 - }, - { - "x": 46, - "y": 16 - }, - { - "x": 46, - "y": 17 - }, - { - "x": 47, - "y": 18 - }, - { - "x": 48, - "y": 19 - }, - { - "x": 49, - "y": 20 - }, - { - "x": 50, - "y": 21 - }, - { - "x": 51, - "y": 21 - }, - { - "x": 52, - "y": 22 - }, - { - "x": 53, - "y": 22 - }, - { - "x": 54, - "y": 22 - }, - { - "x": 55, - "y": 23 - }, - { - "x": 56, - "y": 23 - }, - { - "x": 57, - "y": 24 - }, - { - "x": 58, - "y": 24 - }, - { - "x": 59, - "y": 24 - }, - { - "x": 60, - "y": 24 - }, - { - "x": 61, - "y": 25 - }, - { - "x": 62, - "y": 25 - }, - { - "x": 63, - "y": 25 - }, - { - "x": 64, - "y": 25 - }, - { - "x": 65, - "y": 25 - }, - { - "x": 66, - "y": 26 - }, - { - "x": 67, - "y": 26 - }, - { - "x": 68, - "y": 26 - }, - { - "x": 69, - "y": 26 - }, - { - "x": 70, - "y": 26 - }, - { - "x": 71, - "y": 26 - }, - { - "x": 72, - "y": 26 - }, - { - "x": 73, - "y": 26 - }, - { - "x": 74, - "y": 26 - }, - { - "x": 75, - "y": 26 - }, - { - "x": 76, - "y": 26 - }, - { - "x": 77, - "y": 26 - }, - { - "x": 78, - "y": 26 - }, - { - "x": 79, - "y": 26 - }, - { - "x": 80, - "y": 26 - }, - { - "x": 81, - "y": 26 - }, - { - "x": 82, - "y": 26 - }, - { - "x": 83, - "y": 26 - }, - { - "x": 84, - "y": 26 - }, - { - "x": 85, - "y": 26 - }, - { - "x": 86, - "y": 26 - }, - { - "x": 87, - "y": 25 - }, - { - "x": 88, - "y": 25 - }, - { - "x": 89, - "y": 25 - }, - { - "x": 90, - "y": 24 - }, - { - "x": 91, - "y": 23 - }, - { - "x": 92, - "y": 22 - }, - { - "x": 92, - "y": 21 - }, - { - "x": 92, - "y": 20 - }, - { - "x": 92, - "y": 19 - }, - { - "x": 92, - "y": 18 - }, - { - "x": 92, - "y": 17 - }, - { - "x": 92, - "y": 16 - }, - { - "x": 92, - "y": 15 - }, - { - "x": 91, - "y": 14 - }, - { - "x": 91, - "y": 13 - }, - { - "x": 90, - "y": 13 - }, - { - "x": 90, - "y": 12 - }, - { - "x": 89, - "y": 11 - }, - { - "x": 88, - "y": 10 - }, - { - "x": 87, - "y": 10 - }, - { - "x": 86, - "y": 9 - }, - { - "x": 85, - "y": 8 - }, - { - "x": 84, - "y": 8 - }, - { - "x": 83, - "y": 7 - }, - { - "x": 82, - "y": 7 - }, - { - "x": 82, - "y": 6 - }, - { - "x": 81, - "y": 6 - }, - { - "x": 80, - "y": 6 - }, - { - "x": 80, - "y": 5 - }, - { - "x": 79, - "y": 5 - }, - { - "x": 78, - "y": 4 - }, - { - "x": 77, - "y": 4 - }, - { - "x": 76, - "y": 4 - }, - { - "x": 76, - "y": 3 - }, - { - "x": 75, - "y": 3 - }, - { - "x": 74, - "y": 3 - }, - { - "x": 73, - "y": 3 - }, - { - "x": 72, - "y": 3 - }, - { - "x": 71, - "y": 3 - }, - { - "x": 70, - "y": 3 - }, - { - "x": 69, - "y": 3 - }, - { - "x": 68, - "y": 3 - }, - { - "x": 67, - "y": 3 - }, - { - "x": 66, - "y": 3 - }, - { - "x": 65, - "y": 3 - } - ], - "brush": "#" - }, - { - "id": "obj-2", - "type": "paint", - "z": 2, - "parentId": null, - "color": "white", - "points": [ - { - "x": 63, - "y": 10 - }, - { - "x": 62, - "y": 10 - }, - { - "x": 61, - "y": 10 - }, - { - "x": 64, - "y": 10 - }, - { - "x": 64, - "y": 9 - }, - { - "x": 63, - "y": 9 - } - ], - "brush": "#" - }, - { - "id": "obj-3", - "type": "paint", - "z": 3, - "parentId": null, - "color": "white", - "points": [ - { - "x": 77, - "y": 10 - }, - { - "x": 76, - "y": 10 - }, - { - "x": 75, - "y": 10 - }, - { - "x": 74, - "y": 10 - }, - { - "x": 74, - "y": 11 - }, - { - "x": 75, - "y": 11 - }, - { - "x": 76, - "y": 11 - }, - { - "x": 76, - "y": 12 - }, - { - "x": 77, - "y": 12 - }, - { - "x": 78, - "y": 12 - }, - { - "x": 78, - "y": 11 - }, - { - "x": 78, - "y": 10 - }, - { - "x": 77, - "y": 9 - }, - { - "x": 76, - "y": 9 - }, - { - "x": 75, - "y": 9 - } - ], - "brush": "#" - }, - { - "id": "obj-4", - "type": "paint", - "z": 4, - "parentId": null, - "color": "white", - "points": [ - { - "x": 68, - "y": 14 - }, - { - "x": 68, - "y": 15 - }, - { - "x": 68, - "y": 16 - } - ], - "brush": "#" - }, - { - "id": "obj-5", - "type": "paint", - "z": 5, - "parentId": null, - "color": "white", - "points": [ - { - "x": 61, - "y": 16 - }, - { - "x": 61, - "y": 17 - }, - { - "x": 61, - "y": 18 - }, - { - "x": 62, - "y": 18 - }, - { - "x": 62, - "y": 19 - }, - { - "x": 63, - "y": 19 - }, - { - "x": 64, - "y": 20 - }, - { - "x": 65, - "y": 21 - }, - { - "x": 66, - "y": 21 - }, - { - "x": 67, - "y": 22 - }, - { - "x": 68, - "y": 22 - }, - { - "x": 69, - "y": 22 - }, - { - "x": 70, - "y": 22 - }, - { - "x": 71, - "y": 22 - }, - { - "x": 72, - "y": 22 - }, - { - "x": 73, - "y": 22 - }, - { - "x": 74, - "y": 22 - }, - { - "x": 75, - "y": 22 - }, - { - "x": 76, - "y": 21 - }, - { - "x": 77, - "y": 21 - }, - { - "x": 77, - "y": 20 - }, - { - "x": 78, - "y": 20 - }, - { - "x": 78, - "y": 19 - } - ], - "brush": "#" - } - ] -} From f0760a821a4a6417ef1a2d7131964b2106c12748 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Sat, 25 Apr 2026 21:05:34 +0000 Subject: [PATCH 4/6] refactor(opentui): extract save dialog control helpers --- packages/opentui/src/app.ts | 131 ++++++++++++------------- packages/opentui/src/app/input.test.ts | 50 ++++++++++ packages/opentui/src/app/input.ts | 96 +++++++++++++++++- packages/opentui/src/app/layout.ts | 49 ++++++++- packages/opentui/src/app/render.ts | 98 +++++++++--------- packages/opentui/src/app/types.ts | 34 +++++++ packages/opentui/src/react.test.tsx | 86 ++++++++++++++++ 7 files changed, 424 insertions(+), 120 deletions(-) create mode 100644 packages/opentui/src/app/input.test.ts diff --git a/packages/opentui/src/app.ts b/packages/opentui/src/app.ts index 232d4f9..6104802 100644 --- a/packages/opentui/src/app.ts +++ b/packages/opentui/src/app.ts @@ -17,10 +17,11 @@ import { DrawState, INK_COLORS, truncateToCells, type DrawDocument } from "./dra import { getColorSwatches, getContextualStyleButtons, + getDiagramSavePromptLayout, getLayout, getToolButtons, } from "./app/layout.js"; -import { handleKeyPress, handleMouseEvent } from "./app/input.js"; +import { handleDiagramSavePromptKey, handleKeyPress, handleMouseEvent } from "./app/input.js"; import { drawCanvas, drawChrome, @@ -30,8 +31,12 @@ import { } from "./app/render.js"; import { renderStartupLogo } from "./app/startup-logo.js"; import { COLORS, MIN_HEIGHT, MIN_WIDTH, getCanvasInsets } from "./app/theme.js"; -import type { AppLayout, ChromeMode } from "./app/types.js"; -import { splitGraphemes } from "./text.js"; +import type { + AppLayout, + ChromeMode, + DiagramSavePromptKeyResult, + DiagramSaveState, +} from "./app/types.js"; function normalizeDiagramPath(path: string): string { const trimmedPath = path.trim(); @@ -79,9 +84,10 @@ export class TermDrawRenderable extends FrameBufferRenderable { private onCancelCallback: (() => void) | null = null; private pendingInitialDocument: DrawDocument | null = null; private diagramPath: string | null = null; - private diagramSavePromptValue: string | null = null; - private diagramSavePromptError: string | null = null; - private diagramSavePending = false; + private readonly diagramSaveState: DiagramSaveState = { + pending: false, + prompt: null, + }; private autoFocusEnabled = false; private startupLogoEnabled = true; private startupLogoDismissed = false; @@ -273,18 +279,16 @@ export class TermDrawRenderable extends FrameBufferRenderable { ); drawDiagramSavePrompt( this.frameBuffer, - this.width, - this.height, - this.diagramSavePromptValue, - this.diagramSavePromptError, - this.diagramSavePending, + getDiagramSavePromptLayout(this.width, this.height, this.getDiagramSavePrompt()), ); super.renderSelf(buffer); } /** Dispatches keyboard shortcuts, cursor movement, and text entry. */ public override handleKeyPress(key: KeyEvent): boolean { - if (this.handleDiagramSavePromptKey(key)) { + const diagramSavePromptResult = handleDiagramSavePromptKey(key, this.getDiagramSavePrompt()); + if (diagramSavePromptResult.handled) { + this.applyDiagramSavePromptKeyResult(diagramSavePromptResult); return true; } @@ -307,6 +311,11 @@ export class TermDrawRenderable extends FrameBufferRenderable { this.requestRender(); } + /** Returns the active save prompt state when the save dialog is visible. */ + private getDiagramSavePrompt() { + return this.diagramSaveState.prompt; + } + /** Recomputes the canvas size and returns the full-chrome layout when applicable. */ private syncCanvasLayout(): AppLayout | null { if (this.chromeMode === "editor") { @@ -333,97 +342,79 @@ export class TermDrawRenderable extends FrameBufferRenderable { /** Starts a diagram save, prompting for a path when no diagram path is known yet. */ private beginDiagramSave(): void { - if (this.diagramSavePending) return; + if (this.diagramSaveState.pending) return; if (this.diagramPath) { void this.saveDiagramToPath(this.diagramPath); return; } - this.diagramSavePromptValue = ""; - this.diagramSavePromptError = null; + this.diagramSaveState.prompt = { + value: "", + error: null, + pending: false, + }; this.state.setStatusMessage("Enter a path and press Enter to save the diagram."); this.requestRender(); } - /** Handles keyboard input while the diagram save prompt is visible. */ - private handleDiagramSavePromptKey(key: KeyEvent): boolean { - if (this.diagramSavePromptValue === null) return false; - - const name = key.name.toLowerCase(); - if (name === "escape") { - key.preventDefault(); - this.diagramSavePromptValue = null; - this.diagramSavePromptError = null; - this.state.setStatusMessage("Save diagram cancelled."); - this.requestRender(); - return true; - } - - if (name === "enter" || name === "return") { - key.preventDefault(); - const path = this.diagramSavePromptValue.trim(); - if (!path) { - this.diagramSavePromptError = "Path is required."; - this.state.setStatusMessage("Diagram path is required."); - this.requestRender(); - return true; - } + /** Applies the result of the extracted save-prompt key handler to the renderable state. */ + private applyDiagramSavePromptKeyResult(result: DiagramSavePromptKeyResult): void { + if (!result.handled) return; - void this.saveDiagramToPath(path); - return true; - } + this.diagramSaveState.prompt = result.prompt; - if (name === "backspace") { - key.preventDefault(); - const graphemes = splitGraphemes(this.diagramSavePromptValue); - graphemes.pop(); - this.diagramSavePromptValue = graphemes.join(""); - this.diagramSavePromptError = null; - this.requestRender(); - return true; + if (result.statusMessage) { + this.state.setStatusMessage(result.statusMessage); } - if ( - !key.ctrl && - !key.meta && - !key.option && - key.raw && - !key.raw.startsWith("\u001b") && - name !== "tab" - ) { - key.preventDefault(); - this.diagramSavePromptValue += key.raw; - this.diagramSavePromptError = null; - this.requestRender(); - return true; + if (result.submitPath) { + void this.saveDiagramToPath(result.submitPath); + return; } - return true; + this.requestRender(); } /** Persists the editable document and updates the active diagram path on success. */ private async saveDiagramToPath(path: string): Promise { - if (!this.onSaveDiagramCallback || this.diagramSavePending) return; + if (!this.onSaveDiagramCallback || this.diagramSaveState.pending) return; const normalizedPath = normalizeDiagramPath(path); - this.diagramSavePending = true; - this.diagramSavePromptError = null; + this.diagramSaveState.pending = true; + if (this.diagramSaveState.prompt) { + this.diagramSaveState.prompt = { + ...this.diagramSaveState.prompt, + error: null, + pending: true, + }; + } this.state.setStatusMessage(`Saving diagram to ${normalizedPath}...`); this.requestRender(); try { await this.onSaveDiagramCallback(this.state.exportDocument(), normalizedPath); this.diagramPath = normalizedPath; - this.diagramSavePromptValue = null; - this.diagramSavePromptError = null; + this.diagramSaveState.prompt = null; this.state.setStatusMessage(`Saved diagram to ${normalizedPath}.`); } catch (error) { const message = error instanceof Error ? error.message : String(error); - this.diagramSavePromptError = message; + if (this.diagramSaveState.prompt) { + this.diagramSaveState.prompt = { + ...this.diagramSaveState.prompt, + error: message, + pending: false, + }; + } this.state.setStatusMessage(`Failed to save diagram: ${message}`); } finally { - this.diagramSavePending = false; + this.diagramSaveState.pending = false; + if (this.diagramSaveState.prompt) { + this.diagramSaveState.prompt = { + ...this.diagramSaveState.prompt, + pending: false, + }; + } this.requestRender(); } } diff --git a/packages/opentui/src/app/input.test.ts b/packages/opentui/src/app/input.test.ts new file mode 100644 index 0000000..ba75e6d --- /dev/null +++ b/packages/opentui/src/app/input.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import type { KeyEvent } from "@opentui/core"; +import { handleDiagramSavePromptKey } from "./input"; +import type { DiagramSavePromptState } from "./types"; + +function createKeyEvent( + name: string, + overrides: Partial = {}, +): { event: KeyEvent; wasPrevented: () => boolean } { + let prevented = false; + + return { + event: { + name, + ctrl: false, + meta: false, + shift: false, + option: false, + sequence: "", + number: false, + raw: "", + eventType: "press", + source: "raw", + preventDefault() { + prevented = true; + }, + stopPropagation() {}, + ...overrides, + } as KeyEvent, + wasPrevented: () => prevented, + }; +} + +test("handleDiagramSavePromptKey cancels the prompt for esc keys", () => { + const prompt: DiagramSavePromptState = { + value: "diagram", + error: null, + pending: false, + }; + const { event, wasPrevented } = createKeyEvent("esc"); + + const result = handleDiagramSavePromptKey(event, prompt); + + expect(result).toEqual({ + handled: true, + prompt: null, + statusMessage: "Save diagram cancelled.", + }); + expect(wasPrevented()).toBe(true); +}); diff --git a/packages/opentui/src/app/input.ts b/packages/opentui/src/app/input.ts index 751f1c9..d7d3060 100644 --- a/packages/opentui/src/app/input.ts +++ b/packages/opentui/src/app/input.ts @@ -12,7 +12,7 @@ import type { PointerEventLike, TextBorderMode, } from "../draw-state.js"; -import { visibleCellCount } from "../text.js"; +import { splitGraphemes, visibleCellCount } from "../text.js"; import { getColorSwatches, getContextualStyleButtons, @@ -21,7 +21,12 @@ import { isInsideRect, } from "./layout.js"; import { INK_COLORS } from "../draw-state.js"; -import type { AppLayout, ChromeMode } from "./types.js"; +import type { + AppLayout, + ChromeMode, + DiagramSavePromptKeyResult, + DiagramSavePromptState, +} from "./types.js"; import { TOOL_HOTKEYS } from "./theme.js"; /** Describes the callbacks needed by the extracted input handlers. */ @@ -177,7 +182,7 @@ export function handleKeyPress( return true; } - if (name === "escape") { + if (name === "escape" || name === "esc") { key.preventDefault(); state.clearSelection(); requestRender(); @@ -417,3 +422,88 @@ export function handleKeyPress( return false; } + +/** Handles keyboard input while the diagram save prompt is visible. */ +export function handleDiagramSavePromptKey( + key: KeyEvent, + prompt: DiagramSavePromptState | null, +): DiagramSavePromptKeyResult { + if (!prompt) { + return { + handled: false, + prompt: null, + }; + } + + const name = key.name.toLowerCase(); + if (name === "escape" || name === "esc") { + key.preventDefault(); + return { + handled: true, + prompt: null, + statusMessage: "Save diagram cancelled.", + }; + } + + if (name === "enter" || name === "return") { + key.preventDefault(); + const path = prompt.value.trim(); + if (!path) { + return { + handled: true, + prompt: { + ...prompt, + error: "Path is required.", + }, + statusMessage: "Diagram path is required.", + }; + } + + return { + handled: true, + prompt: { + ...prompt, + error: null, + }, + submitPath: path, + }; + } + + if (name === "backspace") { + key.preventDefault(); + const graphemes = splitGraphemes(prompt.value); + graphemes.pop(); + return { + handled: true, + prompt: { + ...prompt, + value: graphemes.join(""), + error: null, + }, + }; + } + + if ( + !key.ctrl && + !key.meta && + !key.option && + key.raw && + !key.raw.startsWith("\u001b") && + name !== "tab" + ) { + key.preventDefault(); + return { + handled: true, + prompt: { + ...prompt, + value: prompt.value + key.raw, + error: null, + }, + }; + } + + return { + handled: true, + prompt, + }; +} diff --git a/packages/opentui/src/app/layout.ts b/packages/opentui/src/app/layout.ts index 618f482..50edc9f 100644 --- a/packages/opentui/src/app/layout.ts +++ b/packages/opentui/src/app/layout.ts @@ -5,7 +5,15 @@ * rendering and mouse-input handling. */ import type { DrawMode, InkColor } from "../draw-state.js"; -import type { AppLayout, ColorSwatch, StyleButton, ToolButton } from "./types.js"; +import { padToWidth, truncateToCells, visibleCellCount } from "../text.js"; +import type { + AppLayout, + ColorSwatch, + DiagramSavePromptLayout, + DiagramSavePromptState, + StyleButton, + ToolButton, +} from "./types.js"; import { BOX_STYLE_OPTIONS, BRUSH_OPTIONS, @@ -149,6 +157,45 @@ export function getColorSwatches(layout: AppLayout, colors: readonly InkColor[]) })); } +/** Returns the computed overlay layout for the diagram save prompt. */ +export function getDiagramSavePromptLayout( + width: number, + height: number, + prompt: DiagramSavePromptState | null, +): DiagramSavePromptLayout | null { + if (!prompt) return null; + + const label = "Save diagram as"; + const pathLine = prompt.pending ? "Saving..." : prompt.value; + const displayPath = pathLine.length > 0 ? pathLine : " "; + const helperLine = prompt.error ?? "Enter confirms • Esc cancels"; + const contentWidth = Math.max( + 24, + Math.min( + width - 6, + Math.max( + visibleCellCount(label), + visibleCellCount(displayPath), + visibleCellCount(helperLine), + ) + 2, + ), + ); + const boxWidth = Math.max(10, contentWidth + 2); + const boxHeight = 5; + + return { + left: Math.max(0, Math.floor((width - boxWidth) / 2)), + top: Math.max(0, Math.floor((height - boxHeight) / 2)), + width: boxWidth, + height: boxHeight, + contentWidth, + label, + pathText: padToWidth(displayPath, contentWidth), + helperText: truncateToCells(padToWidth(helperLine, contentWidth), contentWidth), + hasError: prompt.error !== null, + }; +} + /** Returns whether the pointer event lands inside the drawable canvas region. */ export function isCanvasChromeEvent( canvasLeftCol: number, diff --git a/packages/opentui/src/app/render.ts b/packages/opentui/src/app/render.ts index 35b5fc5..3888941 100644 --- a/packages/opentui/src/app/render.ts +++ b/packages/opentui/src/app/render.ts @@ -6,8 +6,14 @@ */ import { TextAttributes, type OptimizedBuffer } from "@opentui/core"; import type { DrawState } from "../draw-state.js"; -import { padToWidth, truncateToCells, visibleCellCount } from "../text.js"; -import type { AppLayout, ColorSwatch, StyleButton, ToolButton } from "./types.js"; +import { padToWidth, visibleCellCount } from "../text.js"; +import type { + AppLayout, + ColorSwatch, + DiagramSavePromptLayout, + StyleButton, + ToolButton, +} from "./types.js"; import { BOX_STYLE_OPTIONS, BRUSH_OPTIONS, @@ -415,65 +421,65 @@ export function drawCanvas(frameBuffer: OptimizedBuffer, state: DrawState): void /** Draws the minimal save-as prompt used for native diagram persistence. */ export function drawDiagramSavePrompt( frameBuffer: OptimizedBuffer, - width: number, - height: number, - value: string | null, - error: string | null, - pending: boolean, + promptLayout: DiagramSavePromptLayout | null, ): void { - if (value === null) return; - - const label = "Save diagram as"; - const pathLine = pending ? "Saving..." : value; - const displayPath = pathLine.length > 0 ? pathLine : " "; - const errorLine = error ?? "Enter confirms • Esc cancels"; - const contentWidth = Math.max( - 24, - Math.min( - width - 6, - Math.max( - visibleCellCount(label), - visibleCellCount(displayPath), - visibleCellCount(errorLine), - ) + 2, - ), + if (!promptLayout) return; + + drawHorizontalBorder( + frameBuffer, + promptLayout.width, + promptLayout.top, + "╭", + "╮", + promptLayout.left, ); - const boxWidth = Math.max(10, contentWidth + 2); - const boxHeight = 5; - const left = Math.max(0, Math.floor((width - boxWidth) / 2)); - const top = Math.max(0, Math.floor((height - boxHeight) / 2)); - const pathText = padToWidth(displayPath, contentWidth); - const helperText = truncateToCells(padToWidth(errorLine, contentWidth), contentWidth); - - drawHorizontalBorder(frameBuffer, boxWidth, top, "╭", "╮", left); - for (let y = 1; y < boxHeight - 1; y += 1) { - frameBuffer.setCell(left, top + y, "│", COLORS.border, COLORS.panel); - frameBuffer.drawText(" ".repeat(boxWidth - 2), left + 1, top + y, COLORS.text, COLORS.panel); - frameBuffer.setCell(left + boxWidth - 1, top + y, "│", COLORS.border, COLORS.panel); + for (let y = 1; y < promptLayout.height - 1; y += 1) { + frameBuffer.setCell(promptLayout.left, promptLayout.top + y, "│", COLORS.border, COLORS.panel); + frameBuffer.drawText( + " ".repeat(promptLayout.width - 2), + promptLayout.left + 1, + promptLayout.top + y, + COLORS.text, + COLORS.panel, + ); + frameBuffer.setCell( + promptLayout.left + promptLayout.width - 1, + promptLayout.top + y, + "│", + COLORS.border, + COLORS.panel, + ); } - drawHorizontalBorder(frameBuffer, boxWidth, top + boxHeight - 1, "╰", "╯", left); + drawHorizontalBorder( + frameBuffer, + promptLayout.width, + promptLayout.top + promptLayout.height - 1, + "╰", + "╯", + promptLayout.left, + ); frameBuffer.drawText( - padToWidth(label, contentWidth), - left + 1, - top + 1, + padToWidth(promptLayout.label, promptLayout.contentWidth), + promptLayout.left + 1, + promptLayout.top + 1, COLORS.text, COLORS.panel, TextAttributes.BOLD, ); frameBuffer.drawText( - pathText, - left + 1, - top + 2, + promptLayout.pathText, + promptLayout.left + 1, + promptLayout.top + 2, COLORS.accent, COLORS.panel, TextAttributes.BOLD, ); frameBuffer.drawText( - helperText, - left + 1, - top + 3, - error ? COLORS.warning : COLORS.dim, + promptLayout.helperText, + promptLayout.left + 1, + promptLayout.top + 3, + promptLayout.hasError ? COLORS.warning : COLORS.dim, COLORS.panel, ); } diff --git a/packages/opentui/src/app/types.ts b/packages/opentui/src/app/types.ts index 1483645..2f219f8 100644 --- a/packages/opentui/src/app/types.ts +++ b/packages/opentui/src/app/types.ts @@ -50,3 +50,37 @@ export type ColorSwatch = { top: number; width: number; }; + +/** Represents the editable state of the diagram save prompt while it is visible. */ +export type DiagramSavePromptState = { + value: string; + error: string | null; + pending: boolean; +}; + +/** Captures the computed geometry and display strings for the save prompt overlay. */ +export type DiagramSavePromptLayout = { + left: number; + top: number; + width: number; + height: number; + contentWidth: number; + label: string; + pathText: string; + helperText: string; + hasError: boolean; +}; + +/** Describes the outcome of handling a key press while the save prompt is visible. */ +export type DiagramSavePromptKeyResult = { + handled: boolean; + prompt: DiagramSavePromptState | null; + statusMessage?: string; + submitPath?: string; +}; + +/** Tracks the save dialog prompt plus save-in-flight state owned by the renderable. */ +export type DiagramSaveState = { + pending: boolean; + prompt: DiagramSavePromptState | null; +}; diff --git a/packages/opentui/src/react.test.tsx b/packages/opentui/src/react.test.tsx index 90461cd..3bf4ab5 100644 --- a/packages/opentui/src/react.test.tsx +++ b/packages/opentui/src/react.test.tsx @@ -330,6 +330,92 @@ test("TermDrawApp prompts for a diagram path and reuses it on later saves", asyn expect(savedPaths).toEqual(["diagram.td.json", "diagram.td.json"]); }); +test("TermDrawApp validates that a diagram path is provided", async () => { + let saveCount = 0; + + const { captureCharFrame, mockInput, renderOnce } = await testRender( + { + saveCount += 1; + }} + />, + { + width: 64, + height: 29, + useMouse: true, + enableMouseMovement: true, + }, + ); + + await renderOnce(); + + mockInput.pressKey("d", { ctrl: true }); + await renderOnce(); + mockInput.pressEnter(); + await renderOnce(); + + const frame = captureCharFrame(); + expect(frame).toContain("Save diagram as"); + expect(frame).toContain("Path is required."); + expect(saveCount).toBe(0); +}); + +test("TermDrawApp shows a pending save state while a diagram save is in flight", async () => { + const pendingSave = { + resolve: null as (() => void) | null, + }; + + const { captureCharFrame, mockInput, renderOnce } = await testRender( + { + await new Promise((resolve) => { + pendingSave.resolve = resolve; + }); + }} + />, + { + width: 64, + height: 29, + useMouse: true, + enableMouseMovement: true, + }, + ); + + await renderOnce(); + + mockInput.pressKey("d", { ctrl: true }); + await renderOnce(); + for (const char of "diagram") { + mockInput.pressKey(char); + } + mockInput.pressEnter(); + await renderOnce(); + + expect(captureCharFrame()).toContain("Saving..."); + + pendingSave.resolve?.(); + await Promise.resolve(); + await renderOnce(); + + expect(captureCharFrame()).not.toContain("Save diagram as"); +}); + test("TermDrawApp appends .td.json when saving a loaded diagram without an extension", async () => { let savedPath: string | null = null; From 5d9a900fa402b98fc6fe07c7225245e8158e8a62 Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Sun, 26 Apr 2026 13:45:31 +0000 Subject: [PATCH 5/6] fix: rename CLI load flag and preserve native document imports --- CHANGELOG.md | 6 ++++- README.md | 11 +++++---- packages/app/README.md | 8 +++++++ packages/app/src/main.test.ts | 23 ++++++++++++++---- packages/app/src/main.tsx | 31 ++++++++++++++++++++----- packages/opentui/src/app.ts | 6 ++--- packages/opentui/src/draw-state.test.ts | 26 +++++++++++++++++++++ packages/opentui/src/draw-state.ts | 4 +--- packages/opentui/src/react.test.tsx | 2 +- 9 files changed, 93 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 778b4c8..cf5c476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,18 @@ 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 --diagram ` or `termdraw --diagram -`. +- Added native `.td.json` diagram documents that can be opened with `termdraw --load ` 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. +- Renamed the native document CLI flag from `--diagram` to `--load`. ### Fixed +- Preserved stored object coordinates when loading native `.td.json` documents so load/save round-trips no longer rewrite positions. +- Replaced the raw `/dev/tty` failure from stdin-based loading without a controlling terminal with a clearer message that explains `--load -` still needs a TTY and that `--load ` should be used otherwise. + ## [0.3.4] ### Added diff --git a/README.md b/README.md index 4239cc1..599c70a 100644 --- a/README.md +++ b/README.md @@ -48,19 +48,20 @@ termdraw 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 `--diagram`, termDRAW reuses that path by default; otherwise it prompts for one inside the app. +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 --diagram architecture.td.json +termdraw --load architecture.td.json # open an editable native document from stdin -cat architecture.td.json | termdraw --diagram - +# requires a controlling terminal for the interactive editor session +cat architecture.td.json | termdraw --load - # save the rendered art directly to a file -termdraw --diagram architecture.td.json --output diagram.txt +termdraw --load architecture.td.json --output diagram.txt # save plain text directly to a file termdraw --output diagram.txt @@ -74,7 +75,7 @@ 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 native `.td.json` documents when you want to reopen and keep editing a drawing. Native load/save preserves stored object coordinates unless you change them in the editor. Plain-text output remains an export format and does not preserve the original object metadata. ## Use it in Pi diff --git a/packages/app/README.md b/packages/app/README.md index 3cc6681..2467c35 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -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 @@ -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 ` when no TTY is available. + ## OpenTUI package If you want the embeddable OpenTUI components instead of the packaged app: diff --git a/packages/app/src/main.test.ts b/packages/app/src/main.test.ts index 2630efb..39d6a30 100644 --- a/packages/app/src/main.test.ts +++ b/packages/app/src/main.test.ts @@ -7,6 +7,7 @@ import packageJson from "../package.json"; import { DRAW_DOCUMENT_VERSION } from "../../opentui/src/index"; import { buildCliHelpText, + getInteractiveStdin, loadDiagramInput, parseArgs, readTextFromStdin, @@ -24,8 +25,8 @@ afterEach(() => { } }); -test("parseArgs accepts --diagram alongside existing output options", () => { - expect(parseArgs(["--diagram", "drawing.td.json", "--fenced", "--output", "art.txt"])).toEqual({ +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, @@ -51,7 +52,7 @@ test("parseArgs accepts --version and -v", () => { test("buildCliHelpText only shows CLI options", () => { const help = buildCliHelpText(); - expect(help).toContain("--diagram"); + expect(help).toContain("--load"); expect(help).toContain("--version"); expect(help).toContain("--output"); expect(help).not.toContain("Controls:"); @@ -95,7 +96,7 @@ test("loadDiagramInput reads and parses a diagram file", async () => { }); }); -test("loadDiagramInput reads stdin when --diagram - is used", async () => { +test("loadDiagramInput reads stdin when --load - is used", async () => { await expect( loadDiagramInput("-", async () => JSON.stringify({ @@ -130,12 +131,24 @@ test("readTextFromStdin drains and pauses stdin", async () => { expect(paused).toBe(true); }); -test("shouldUseInteractiveTtyInput only swaps stdin for piped diagram input", () => { +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 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}`, diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx index 60ce2f8..e9320b7 100644 --- a/packages/app/src/main.tsx +++ b/packages/app/src/main.tsx @@ -24,6 +24,9 @@ type StdinLike = NodeJS.ReadableStream & { setEncoding(encoding: BufferEncoding): void; }; +const STDIN_LOAD_REQUIRES_TTY_MESSAGE = + "Interactive editing from stdin requires a controlling terminal. Use --load instead."; + function openInteractiveStdin(): NodeJS.ReadStream { return new ReadStream(openSync("/dev/tty", "r")); } @@ -68,7 +71,7 @@ export function parseArgs(argv: string[]): CliOptions { continue; } - if (arg === "--diagram") { + if (arg === "--load") { const diagramPath = argv[i + 1]; if (!diagramPath) { throw new Error(`Missing value for ${arg}`); @@ -110,6 +113,24 @@ export function shouldUseInteractiveTtyInput( return diagramPath === "-" && !stdin.isTTY; } +export function getInteractiveStdin( + diagramPath: string | undefined, + stdin: Pick = process.stdin, + openStdin: () => NodeJS.ReadStream = openInteractiveStdin, +): NodeJS.ReadStream | null { + if (!shouldUseInteractiveTtyInput(diagramPath, stdin)) { + return null; + } + + try { + return openStdin(); + } catch (error) { + throw new Error(STDIN_LOAD_REQUIRES_TTY_MESSAGE, { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } +} + export async function loadDiagramInput( path: string, readFromStdin: () => Promise = readTextFromStdin, @@ -138,9 +159,9 @@ function formatDiagramDocument(document: DrawDocument): string { export function buildCliHelpText(binaryName = "termdraw"): string { return ( - `${binaryName} [--diagram file|-] [--output file] [--fenced|--plain] [--version]\n\n` + + `${binaryName} [--load file|-] [--output file] [--fenced|--plain] [--version]\n\n` + `Options:\n` + - ` --diagram load a .td.json diagram file or read one from stdin\n` + + ` --load load a .td.json diagram file or read one from stdin\n` + ` -o, --output write the rendered result to a file\n` + ` --fenced output as a fenced markdown code block\n` + ` --plain output plain text (default)\n` + @@ -167,9 +188,7 @@ export async function runTermDrawAppCli(argv = Bun.argv.slice(2)): Promise : undefined; const initialDiagramPath = options.diagramPath && options.diagramPath !== "-" ? options.diagramPath : undefined; - const interactiveStdin = shouldUseInteractiveTtyInput(options.diagramPath) - ? openInteractiveStdin() - : null; + const interactiveStdin = getInteractiveStdin(options.diagramPath); let renderer; try { diff --git a/packages/opentui/src/app.ts b/packages/opentui/src/app.ts index 6104802..7678840 100644 --- a/packages/opentui/src/app.ts +++ b/packages/opentui/src/app.ts @@ -456,7 +456,7 @@ export function formatSavedOutput(art: string, fenced: boolean): string { /** Builds the CLI help text shown by the standalone termDRAW app. */ export function buildHelpText(binaryName = "termdraw"): string { return truncateToCells( - `${binaryName} [--diagram file.td.json|-] [--output file] [--fenced|--plain]\n\n` + + `${binaryName} [--load file.td.json|-] [--output file] [--fenced|--plain]\n\n` + `Controls:\n` + ` right palette click Select / Box / Line / Brush / Text, box styles, and colors\n` + ` Ctrl+T / Tab cycle select / box / line / brush / text\n` + @@ -480,8 +480,8 @@ export function buildHelpText(binaryName = "termdraw"): string { ` Enter / Ctrl+S export art\n` + ` Ctrl+D save diagram (.td.json), prompting for a path when needed\n\n` + `Options:\n` + - ` --diagram open a native termDRAW document from a file\n` + - ` --diagram - read a native termDRAW document from stdin\n` + + ` --load open a native termDRAW document from a file\n` + + ` --load - read a native termDRAW document from stdin\n` + ` -o, --output write the result to a file\n` + ` --fenced output as a fenced markdown code block\n` + ` --plain output plain text (default)\n` + diff --git a/packages/opentui/src/draw-state.test.ts b/packages/opentui/src/draw-state.test.ts index 3db281d..9bf8898 100644 --- a/packages/opentui/src/draw-state.test.ts +++ b/packages/opentui/src/draw-state.test.ts @@ -881,6 +881,32 @@ describe("DrawState", () => { expect(state.currentStatus).toContain("Loaded diagram with 2 objects"); }); + test("loadDocument preserves stored coordinates for native documents", () => { + const document = { + version: DRAW_DOCUMENT_VERSION, + objects: [ + { + id: "obj-10", + type: "box" as const, + z: 1, + parentId: null, + color: "cyan" as const, + left: 18, + top: 7, + right: 23, + bottom: 11, + style: "light" as const, + }, + ], + }; + + const state = new DrawState(20, 10); + state.loadDocument(document); + + expect(state.exportDocument()).toEqual(document); + expect(state.currentStatus).toContain("Loaded diagram with 1 object"); + }); + test("parseDrawDocument rejects invalid document shapes with clear errors", () => { expect(() => parseDrawDocument( diff --git a/packages/opentui/src/draw-state.ts b/packages/opentui/src/draw-state.ts index 16f1568..5bb4af9 100644 --- a/packages/opentui/src/draw-state.ts +++ b/packages/opentui/src/draw-state.ts @@ -1156,9 +1156,7 @@ export class DrawState { /** Replaces the current editable scene from a validated termDRAW document. */ public loadDocument(document: DrawDocument): void { const validatedDocument = validateDrawDocument(document); - const nextObjects = cloneObjects(validatedDocument.objects).map((object) => - this.shiftObjectInsideCanvas(object), - ); + const nextObjects = cloneObjects(validatedDocument.objects); this.objects = this.recomputeParentAssignments(nextObjects); this.selectedObjectIds = []; diff --git a/packages/opentui/src/react.test.tsx b/packages/opentui/src/react.test.tsx index 3bf4ab5..14e2de8 100644 --- a/packages/opentui/src/react.test.tsx +++ b/packages/opentui/src/react.test.tsx @@ -122,7 +122,7 @@ test("help text documents tool hotkeys and automatic line rendering", () => { expect(help).toContain("B / A / U / P / T"); expect(help).toContain("choose Smooth (Braille-aware), Single, or Double line stencils"); expect(help).toContain("choose from preset brush stencils in the palette"); - expect(help).toContain("--diagram "); + expect(help).toContain("--load "); expect(help).toContain("Ctrl+D save diagram (.td.json)"); expect(help).toContain( "Shift + drag constrain line creation/editing to horizontal or vertical", From 474250c671991d0fd3f7d313d3a1a2de3b035f2f Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Sun, 26 Apr 2026 14:22:10 +0000 Subject: [PATCH 6/6] removed some comments --- CHANGELOG.md | 4 ---- README.md | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf5c476..4644a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,9 @@ All notable user-visible changes to this project are documented in this file. ### 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. -- Renamed the native document CLI flag from `--diagram` to `--load`. ### Fixed -- Preserved stored object coordinates when loading native `.td.json` documents so load/save round-trips no longer rewrite positions. -- Replaced the raw `/dev/tty` failure from stdin-based loading without a controlling terminal with a clearer message that explains `--load -` still needs a TTY and that `--load ` should be used otherwise. - ## [0.3.4] ### Added diff --git a/README.md b/README.md index 599c70a..578133a 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ 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. Native load/save preserves stored object coordinates unless you change them in the editor. Plain-text output remains an export format and does not preserve the original object metadata. +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