diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ee33c..ea55ca7 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 --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. + ### Fixed ## [0.3.5] diff --git a/README.md b/README.md index 5bf1c1e..578133a 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,23 @@ npm install --global @termdraw/app termdraw ``` -Draw something, then press `Enter` or `Ctrl+S` to write the result to stdout. +Draw something, then press `Enter` or `Ctrl+S` to export the rendered art to stdout. + +Press `Ctrl+D` to save the editable diagram as a native `.td.json` document. If you opened a diagram with `--load`, termDRAW reuses that path by default; otherwise it prompts for one inside the app. ## App usage ```bash +# open an editable native document from a file +termdraw --load architecture.td.json + +# open an editable native document from stdin +# requires a controlling terminal for the interactive editor session +cat architecture.td.json | termdraw --load - + +# save the rendered art directly to a file +termdraw --load architecture.td.json --output diagram.txt + # save plain text directly to a file termdraw --output diagram.txt @@ -63,6 +75,8 @@ termdraw --help termDRAW! outputs terminal text, not SVG or bitmap graphics. +Use native `.td.json` documents when you want to reopen and keep editing a drawing. Plain-text output remains an export format and does not preserve the original object metadata. + ## Use it in Pi ```bash @@ -98,9 +112,14 @@ createRoot(renderer).render( width="100%" height="100%" autoFocus + initialDocument={existingDocument} + diagramPath="architecture.td.json" onSave={(art) => { console.log(art); }} + onSaveDiagram={async (document, path) => { + await Bun.write(path, `${JSON.stringify(document, null, 2)}\n`); + }} onCancel={() => { renderer.destroy(); }} @@ -118,6 +137,7 @@ Also exported from `@termdraw/opentui`: - `TermDrawRenderable` - `formatSavedOutput` - `buildHelpText` +- `parseDrawDocument` ## Docs diff --git a/bun.lock b/bun.lock index bc2e002..1220d38 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,7 @@ "dependencies": { "@opentui/core": "0.1.97", "@opentui/react": "0.1.97", - "@termdraw/opentui": "0.3.3", + "@termdraw/opentui": "0.3.4", "react": "^19.2.5", }, "devDependencies": { @@ -32,6 +32,7 @@ "@types/react": "^19.2.14", "oxfmt": "^0.44.0", "oxlint": "^1.59.0", + "tuistory": "^0.3.0", "typescript": "^5.9.3", }, }, @@ -60,7 +61,7 @@ "dependencies": { "@opentui/core": "0.1.97", "@opentui/react": "0.1.97", - "@termdraw/opentui": "0.3.3", + "@termdraw/opentui": "0.3.4", "opentui-island": "^0.4.0", "react": "^19.2.0", }, 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 a542c0d..8e5c0c6 100644 --- a/packages/app/src/main.test.ts +++ b/packages/app/src/main.test.ts @@ -1,9 +1,39 @@ -import { expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, expect, test } from "bun:test"; import { version as appVersion } from "../package.json"; -import { buildCliHelpText, parseArgs, runTermDrawAppCli } from "./main"; +import { DRAW_DOCUMENT_VERSION } from "../../opentui/src/index"; +import { + buildCliHelpText, + getInteractiveStdin, + loadDiagramInput, + parseArgs, + readTextFromStdin, + runTermDrawAppCli, + shouldUseInteractiveTtyInput, +} from "./main"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) continue; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("parseArgs accepts --load alongside existing output options", () => { + expect(parseArgs(["--load", "drawing.td.json", "--fenced", "--output", "art.txt"])).toEqual({ + diagramPath: "drawing.td.json", + fenced: true, + help: false, + outputPath: "art.txt", + version: false, + }); +}); test("parseArgs accepts --version and -v", () => { expect(parseArgs(["--version"])).toEqual({ @@ -41,6 +71,7 @@ test("parseArgs rejects missing output values and unknown args", () => { test("buildCliHelpText only shows CLI options", () => { const help = buildCliHelpText(); + expect(help).toContain("--load"); expect(help).toContain("--version"); expect(help).toContain("--output"); expect(help).not.toContain("Controls:"); @@ -84,6 +115,83 @@ test("runTermDrawAppCli prints the current version", async () => { expect(stdoutWrites.join("")).toBe(`${appVersion}\n`); }); +test("loadDiagramInput reads and parses a diagram file", async () => { + const dir = mkdtempSync(join(tmpdir(), "termdraw-app-test-")); + tempDirs.push(dir); + const path = join(dir, "diagram.td.json"); + await Bun.write( + path, + JSON.stringify({ + version: DRAW_DOCUMENT_VERSION, + objects: [], + }), + ); + + await expect(loadDiagramInput(path)).resolves.toEqual({ + version: DRAW_DOCUMENT_VERSION, + objects: [], + }); +}); + +test("loadDiagramInput reads stdin when --load - is used", async () => { + await expect( + loadDiagramInput("-", async () => + JSON.stringify({ + version: DRAW_DOCUMENT_VERSION, + objects: [], + }), + ), + ).resolves.toEqual({ + version: DRAW_DOCUMENT_VERSION, + objects: [], + }); +}); + +test("readTextFromStdin drains and pauses stdin", async () => { + const stdin = new PassThrough(); + let paused = false; + + Reflect.set(stdin, "isTTY", false); + Reflect.set(stdin, "pause", () => { + paused = true; + return stdin; + }); + + stdin.end( + JSON.stringify({ + version: DRAW_DOCUMENT_VERSION, + objects: [], + }), + ); + + await expect(readTextFromStdin(stdin)).resolves.toContain('"version"'); + expect(paused).toBe(true); +}); + +test("shouldUseInteractiveTtyInput only swaps stdin for piped load input", () => { + expect(shouldUseInteractiveTtyInput("-", { isTTY: false })).toBe(true); + expect(shouldUseInteractiveTtyInput("-", { isTTY: true })).toBe(false); + expect(shouldUseInteractiveTtyInput("diagram.td.json", { isTTY: false })).toBe(false); +}); + +test("getInteractiveStdin surfaces a clear error without a controlling terminal", () => { + const ttyError = new Error("ENXIO: no such device or address, open '/dev/tty'"); + + expect(() => + getInteractiveStdin("-", { isTTY: false }, () => { + throw ttyError; + }), + ).toThrow( + "Interactive editing from stdin requires a controlling terminal. Use --load instead.", + ); +}); + +test("loadDiagramInput surfaces clear parse errors", async () => { + await expect(loadDiagramInput("-", async () => '{"version":999,"objects":[]}')).rejects.toThrow( + `Failed to load diagram from stdin: termDRAW document version must be ${DRAW_DOCUMENT_VERSION}`, + ); +}); + test("with --output parseArgs records the destination path", () => { expect(parseArgs(["--output", "diagram.txt"])).toEqual({ outputPath: "diagram.txt", diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx index 5f98e8e..e9320b7 100644 --- a/packages/app/src/main.tsx +++ b/packages/app/src/main.tsx @@ -1,13 +1,34 @@ +import { openSync } from "node:fs"; +import { ReadStream } from "node:tty"; import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; -import { formatSavedOutput, TermDrawApp } from "@termdraw/opentui"; +import { + formatSavedOutput, + parseDrawDocument, + TermDrawApp, + type DrawDocument, +} from "@termdraw/opentui"; import packageJson from "../package.json"; export interface CliOptions { + diagramPath?: string; outputPath?: string; fenced: boolean; help: boolean; - version?: boolean; + version: boolean; +} + +type StdinLike = NodeJS.ReadableStream & { + isTTY?: boolean; + pause(): void; + 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")); } export function parseArgs(argv: string[]): CliOptions { @@ -50,6 +71,16 @@ export function parseArgs(argv: string[]): CliOptions { continue; } + if (arg === "--load") { + 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}`); } @@ -60,15 +91,82 @@ function withTrailingNewline(text: string): string { return text.endsWith("\n") ? text : `${text}\n`; } +export async function readTextFromStdin(stdin: StdinLike = process.stdin): Promise { + let text = ""; + stdin.setEncoding("utf8"); + + 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 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, +): 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 function buildCliHelpText(binaryName = "termdraw"): string { return ( - `${binaryName} [--output file] [--fenced|--plain] [--version]\n\n` + + `${binaryName} [--load file|-] [--output file] [--fenced|--plain] [--version]\n\n` + `Options:\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` + - ` -v, --version show the current version\n` + - ` -h, --help show this help\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` + + ` -v, --version show the current version\n` + + ` -h, --help show this help\n` ); } @@ -85,13 +183,27 @@ export async function runTermDrawAppCli(argv = Bun.argv.slice(2)): Promise return; } - const renderer = await createCliRenderer({ - exitOnCtrlC: false, - useMouse: true, - enableMouseMovement: true, - autoFocus: true, - screenMode: "alternate-screen", - }); + const initialDocument = options.diagramPath + ? await loadDiagramInput(options.diagramPath) + : undefined; + const initialDiagramPath = + options.diagramPath && options.diagramPath !== "-" ? options.diagramPath : undefined; + const interactiveStdin = getInteractiveStdin(options.diagramPath); + + 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; @@ -101,6 +213,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) { @@ -126,9 +239,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..7678840 100644 --- a/packages/opentui/src/app.ts +++ b/packages/opentui/src/app.ts @@ -13,18 +13,43 @@ 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, + getDiagramSavePromptLayout, getLayout, getToolButtons, } from "./app/layout.js"; -import { handleKeyPress, handleMouseEvent } from "./app/input.js"; -import { drawCanvas, drawChrome, drawTooSmallMessage, drawToolPalette } from "./app/render.js"; +import { handleDiagramSavePromptKey, handleKeyPress, handleMouseEvent } from "./app/input.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 type { + AppLayout, + ChromeMode, + DiagramSavePromptKeyResult, + DiagramSaveState, +} from "./app/types.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 { @@ -32,7 +57,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 +78,16 @@ 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 readonly diagramSaveState: DiagramSaveState = { + pending: false, + prompt: null, + }; private autoFocusEnabled = false; private startupLogoEnabled = true; private startupLogoDismissed = false; @@ -63,7 +100,10 @@ export class TermDrawRenderable extends FrameBufferRenderable { width, height, onSave, + onSaveDiagram, onCancel, + initialDocument, + diagramPath, autoFocus = false, showStartupLogo = true, cancelOnCtrlC = false, @@ -85,7 +125,11 @@ 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.startupLogoDismissed = initialDocument !== undefined; this.showStartupLogo = showStartupLogo; this.autoFocus = autoFocus; this.cancelOnCtrlC = cancelOnCtrlC; @@ -111,6 +155,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 +196,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 +234,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 +256,7 @@ export class TermDrawRenderable extends FrameBufferRenderable { this.state, fullLayout, this.footerTextOverride, + this.onSaveDiagramCallback !== null, ); drawToolPalette( this.frameBuffer, @@ -216,16 +277,27 @@ export class TermDrawRenderable extends FrameBufferRenderable { this.startupLogoEnabled, this.startupLogoDismissed, ); + drawDiagramSavePrompt( + this.frameBuffer, + getDiagramSavePromptLayout(this.width, this.height, this.getDiagramSavePrompt()), + ); super.renderSelf(buffer); } /** Dispatches keyboard shortcuts, cursor movement, and text entry. */ public override handleKeyPress(key: KeyEvent): boolean { + const diagramSavePromptResult = handleDiagramSavePromptKey(key, this.getDiagramSavePrompt()); + if (diagramSavePromptResult.handled) { + this.applyDiagramSavePromptKeyResult(diagramSavePromptResult); + 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(), @@ -239,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") { @@ -254,6 +331,93 @@ 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.diagramSaveState.pending) return; + + if (this.diagramPath) { + void this.saveDiagramToPath(this.diagramPath); + return; + } + + this.diagramSaveState.prompt = { + value: "", + error: null, + pending: false, + }; + this.state.setStatusMessage("Enter a path and press Enter to save the diagram."); + this.requestRender(); + } + + /** Applies the result of the extracted save-prompt key handler to the renderable state. */ + private applyDiagramSavePromptKeyResult(result: DiagramSavePromptKeyResult): void { + if (!result.handled) return; + + this.diagramSaveState.prompt = result.prompt; + + if (result.statusMessage) { + this.state.setStatusMessage(result.statusMessage); + } + + if (result.submitPath) { + void this.saveDiagramToPath(result.submitPath); + return; + } + + this.requestRender(); + } + + /** Persists the editable document and updates the active diagram path on success. */ + private async saveDiagramToPath(path: string): Promise { + if (!this.onSaveDiagramCallback || this.diagramSaveState.pending) return; + + const normalizedPath = normalizeDiagramPath(path); + 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.diagramSaveState.prompt = null; + this.state.setStatusMessage(`Saved diagram to ${normalizedPath}.`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (this.diagramSaveState.prompt) { + this.diagramSaveState.prompt = { + ...this.diagramSaveState.prompt, + error: message, + pending: false, + }; + } + this.state.setStatusMessage(`Failed to save diagram: ${message}`); + } finally { + this.diagramSaveState.pending = false; + if (this.diagramSaveState.prompt) { + this.diagramSaveState.prompt = { + ...this.diagramSaveState.prompt, + pending: false, + }; + } + this.requestRender(); + } + } } /** Options for the full-chrome standalone app renderable. */ @@ -292,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} [--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` + @@ -313,8 +477,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` + + ` --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/app/input.test.ts b/packages/opentui/src/app/input.test.ts index c2422f1..5aae83a 100644 --- a/packages/opentui/src/app/input.test.ts +++ b/packages/opentui/src/app/input.test.ts @@ -1,30 +1,32 @@ import { expect, test } from "bun:test"; -import { handleKeyPress } from "./input"; +import type { KeyEvent } from "@opentui/core"; +import { handleDiagramSavePromptKey, handleKeyPress } from "./input"; +import type { DiagramSavePromptState } from "./types"; -function createMockKey( +function createKeyEvent( name: string, - options: Partial<{ - raw: string; - ctrl: boolean; - shift: boolean; - meta: boolean; - option: boolean; - }> = {}, -) { + overrides: Partial = {}, +): { event: KeyEvent; wasPrevented: () => boolean } { let prevented = false; return { - key: { + event: { name, - raw: options.raw ?? name, - ctrl: options.ctrl ?? false, - shift: options.shift ?? false, - meta: options.meta ?? false, - option: options.option ?? false, - preventDefault: () => { + 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, }; } @@ -57,14 +59,32 @@ function createMockState(overrides: Record = {}) { }; } +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); +}); + test("handleKeyPress clears selection on Escape", () => { let cleared = 0; let renders = 0; let dismissed = 0; - const { key, wasPrevented } = createMockKey("escape", { raw: "\u001b" }); + const { event, wasPrevented } = createKeyEvent("escape", { raw: "\u001b" }); const handled = handleKeyPress({ - key: key as never, + key: event as never, state: createMockState({ clearSelection: () => { cleared += 1; @@ -72,6 +92,7 @@ test("handleKeyPress clears selection on Escape", () => { }) as never, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => { renders += 1; @@ -90,13 +111,14 @@ test("handleKeyPress clears selection on Escape", () => { test("handleKeyPress invokes cancel on Ctrl+Q", () => { let cancelled = 0; - const { key, wasPrevented } = createMockKey("q", { ctrl: true }); + const { event, wasPrevented } = createKeyEvent("q", { ctrl: true }); const handled = handleKeyPress({ - key: key as never, + key: event as never, state: createMockState() as never, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: () => { cancelled += 1; }, @@ -111,15 +133,16 @@ test("handleKeyPress invokes cancel on Ctrl+Q", () => { test("handleKeyPress invokes save on Ctrl+S", () => { let saved = 0; - const { key, wasPrevented } = createMockKey("s", { ctrl: true }); + const { event, wasPrevented } = createKeyEvent("s", { ctrl: true }); const handled = handleKeyPress({ - key: key as never, + key: event as never, state: createMockState() as never, cancelOnCtrlCEnabled: true, onSave: () => { saved += 1; }, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -133,10 +156,10 @@ test("handleKeyPress invokes save on Ctrl+S", () => { test("handleKeyPress switches tools with hotkeys outside text entry", () => { let mode: string | null = null; let renders = 0; - const { key, wasPrevented } = createMockKey("b", { raw: "b" }); + const { event, wasPrevented } = createKeyEvent("b", { raw: "b" }); const handled = handleKeyPress({ - key: key as never, + key: event as never, state: createMockState({ currentMode: "line", setMode: (next: string) => { @@ -145,6 +168,7 @@ test("handleKeyPress switches tools with hotkeys outside text entry", () => { }) as never, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => { renders += 1; @@ -161,10 +185,10 @@ test("handleKeyPress switches tools with hotkeys outside text entry", () => { test("handleKeyPress does not switch tools while text entry is armed", () => { let mode: string | null = null; let inserted: string | null = null; - const { key } = createMockKey("b", { raw: "b" }); + const { event } = createKeyEvent("b", { raw: "b" }); const handled = handleKeyPress({ - key: key as never, + key: event as never, state: createMockState({ currentMode: "text", isTextEntryArmed: true, @@ -177,6 +201,7 @@ test("handleKeyPress does not switch tools while text entry is armed", () => { }) as never, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -189,8 +214,8 @@ test("handleKeyPress does not switch tools while text entry is armed", () => { test("handleKeyPress cycles line styles with bracket keys", () => { const cycles: number[] = []; - const { key: leftKey } = createMockKey("[", { raw: "[" }); - const { key: rightKey } = createMockKey("]", { raw: "]" }); + const { event: leftKey } = createKeyEvent("[", { raw: "[" }); + const { event: rightKey } = createKeyEvent("]", { raw: "]" }); const state = createMockState({ currentMode: "line", cycleLineStyle: (delta: number) => { @@ -204,6 +229,7 @@ test("handleKeyPress cycles line styles with bracket keys", () => { state: state as never, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -216,6 +242,7 @@ test("handleKeyPress cycles line styles with bracket keys", () => { state: state as never, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -228,10 +255,10 @@ test("handleKeyPress cycles line styles with bracket keys", () => { test("handleKeyPress deletes selected objects outside text editing", () => { let deleted = 0; let renders = 0; - const { key, wasPrevented } = createMockKey("delete", { raw: "\u007f" }); + const { event, wasPrevented } = createKeyEvent("delete", { raw: "\u007f" }); const handled = handleKeyPress({ - key: key as never, + key: event as never, state: createMockState({ hasSelectedObject: true, deleteSelectedObject: () => { @@ -240,6 +267,7 @@ test("handleKeyPress deletes selected objects outside text editing", () => { }) as never, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => { renders += 1; @@ -256,10 +284,10 @@ test("handleKeyPress deletes selected objects outside text editing", () => { test("handleKeyPress inserts printable text in text mode when entry is armed", () => { const inserted: string[] = []; let renders = 0; - const { key, wasPrevented } = createMockKey("a", { raw: "a" }); + const { event, wasPrevented } = createKeyEvent("a", { raw: "a" }); const handled = handleKeyPress({ - key: key as never, + key: event as never, state: createMockState({ currentMode: "text", isTextEntryArmed: true, @@ -269,6 +297,7 @@ test("handleKeyPress inserts printable text in text mode when entry is armed", ( }) as never, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => { renders += 1; diff --git a/packages/opentui/src/app/input.ts b/packages/opentui/src/app/input.ts index b8a02cd..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. */ @@ -153,11 +158,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(); @@ -168,7 +182,7 @@ export function handleKeyPress( return true; } - if (name === "escape") { + if (name === "escape" || name === "esc") { key.preventDefault(); state.clearSelection(); requestRender(); @@ -181,6 +195,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(); @@ -401,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.test.ts b/packages/opentui/src/app/layout.test.ts index c6f86bf..5bb6b8e 100644 --- a/packages/opentui/src/app/layout.test.ts +++ b/packages/opentui/src/app/layout.test.ts @@ -70,6 +70,7 @@ test("handleKeyPress routes arrow keys to cursor movement without a selection", } as DrawState, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => { renders += 1; @@ -100,6 +101,7 @@ test("handleKeyPress routes arrow keys to selected object movement", () => { } as DrawState, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -135,6 +137,7 @@ test("handleKeyPress handles undo, redo, and clear canvas shortcuts", () => { state, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -146,6 +149,7 @@ test("handleKeyPress handles undo, redo, and clear canvas shortcuts", () => { state, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -157,6 +161,7 @@ test("handleKeyPress handles undo, redo, and clear canvas shortcuts", () => { state, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -190,6 +195,7 @@ test("handleKeyPress supports paint-mode stamping and erasing", () => { state, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -201,6 +207,7 @@ test("handleKeyPress supports paint-mode stamping and erasing", () => { state, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -241,6 +248,7 @@ test("handleKeyPress supports text-mode border cycling and editing keys", () => state, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -252,6 +260,7 @@ test("handleKeyPress supports text-mode border cycling and editing keys", () => state, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -263,6 +272,7 @@ test("handleKeyPress supports text-mode border cycling and editing keys", () => state, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, @@ -274,6 +284,7 @@ test("handleKeyPress supports text-mode border cycling and editing keys", () => state, cancelOnCtrlCEnabled: true, onSave: null, + onSaveDiagram: null, onCancel: null, requestRender: () => {}, dismissStartupLogo: () => {}, 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 e270d2b..3888941 100644 --- a/packages/opentui/src/app/render.ts +++ b/packages/opentui/src/app/render.ts @@ -7,7 +7,13 @@ import { TextAttributes, type OptimizedBuffer } from "@opentui/core"; import type { DrawState } from "../draw-state.js"; import { padToWidth, visibleCellCount } from "../text.js"; -import type { AppLayout, ColorSwatch, StyleButton, ToolButton } from "./types.js"; +import type { + AppLayout, + ColorSwatch, + DiagramSavePromptLayout, + StyleButton, + ToolButton, +} from "./types.js"; import { BOX_STYLE_OPTIONS, BRUSH_OPTIONS, @@ -70,6 +76,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 +91,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 +224,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 +418,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, + promptLayout: DiagramSavePromptLayout | null, +): void { + if (!promptLayout) return; + + drawHorizontalBorder( + frameBuffer, + promptLayout.width, + promptLayout.top, + "╭", + "╮", + promptLayout.left, + ); + 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, + promptLayout.width, + promptLayout.top + promptLayout.height - 1, + "╰", + "╯", + promptLayout.left, + ); + + frameBuffer.drawText( + padToWidth(promptLayout.label, promptLayout.contentWidth), + promptLayout.left + 1, + promptLayout.top + 1, + COLORS.text, + COLORS.panel, + TextAttributes.BOLD, + ); + frameBuffer.drawText( + promptLayout.pathText, + promptLayout.left + 1, + promptLayout.top + 2, + COLORS.accent, + COLORS.panel, + TextAttributes.BOLD, + ); + frameBuffer.drawText( + promptLayout.helperText, + promptLayout.left + 1, + promptLayout.top + 3, + promptLayout.hasError ? 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 +497,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/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/draw-state.test.ts b/packages/opentui/src/draw-state.test.ts index 4f653d7..9bf8898 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,89 @@ 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("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( + 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..5bb4af9 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,60 @@ 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); + + 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 +2468,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..14e2de8 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("--load "); + 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,253 @@ 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(); + + const frame = captureCharFrame(); + expect(frame).toContain("────"); + expect(frame).not.toContain("Licensed under MIT"); +}); + +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.td.json"]); + + mockInput.pressKey("d", { ctrl: true }); + await renderOnce(); + + 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; + + const { mockInput, renderOnce } = await testRender( + { + 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-diagram.td.json").toBe(true); +}); diff --git a/packages/pi/scripts/smoke-pi-save.sh b/packages/pi/scripts/smoke-pi-save.sh index 7db37fd..cf3ec59 100755 --- a/packages/pi/scripts/smoke-pi-save.sh +++ b/packages/pi/scripts/smoke-pi-save.sh @@ -67,6 +67,25 @@ wait_for_text() { fail "Timed out waiting for text: ${needle}" } +wait_for_any_text() { + local timeout_seconds=${1:-30} + shift + local start_time=$SECONDS + + while (( SECONDS - start_time < timeout_seconds )); do + local pane + pane="$(capture_pane)" + for needle in "$@"; do + if grep -Fq -- "${needle}" <<<"${pane}"; then + return 0 + fi + done + sleep 0.2 + done + + fail "Timed out waiting for startup text: $*" +} + assert_contains() { local needle=$1 capture_pane | grep -Fq -- "${needle}" || fail "Expected pane to contain: ${needle}" @@ -85,15 +104,17 @@ main() { -c "${REPO_ROOT}" \ "PI_TERMDRAW_SMOKE_TEXT=${SMOKE_TEXT@Q} pi --offline --no-session -e ${EXTENSION_PATH@Q}" - wait_for_text 'Kernel:' 30 + wait_for_any_text 30 'Kernel:' '[Extensions]' 'Press ctrl+o to show full startup help and loaded resources.' printf -- 'Opening /termdraw...\n' tmux send-keys -t "${PANE_TARGET}" '/termdraw' Enter - wait_for_text 'termDRAW!' 30 - wait_for_text 'B Brush • A Select • U Box • P Line • T Text' 30 + wait_for_any_text 30 'Inserted drawing into editor.' 'termDRAW!' - tmux send-keys -t "${PANE_TARGET}" Enter - wait_for_text 'Inserted drawing into editor.' 30 + if ! capture_pane | grep -Fq -- 'Inserted drawing into editor.'; then + wait_for_text 'B Brush • A Select • U Box • P Line • T Text' 30 + tmux send-keys -t "${PANE_TARGET}" Enter + wait_for_text 'Inserted drawing into editor.' 30 + fi assert_contains '```text' assert_contains '```' diff --git a/types/opentui-react-shim.d.ts b/types/opentui-react-shim.d.ts index 8e3b887..333a9d1 100644 --- a/types/opentui-react-shim.d.ts +++ b/types/opentui-react-shim.d.ts @@ -15,6 +15,9 @@ declare function queueMicrotask(callback: () => void): void; declare const Bun: { argv: string[]; write(destination: string, data: string): Promise; + file(path: string): { + text(): Promise; + }; }; declare module "bun:test" { @@ -40,12 +43,21 @@ declare module "@opentui/react" { declare module "@opentui/react/test-utils" { import type * as React from "react"; + interface MockKeyModifiers { + shift?: boolean; + ctrl?: boolean; + meta?: boolean; + super?: boolean; + hyper?: boolean; + } + export interface OpenTUITestRenderResult { captureCharFrame(): string; renderOnce(): Promise; mockInput: { - pressEnter(): void; - pressKey(key: string): void; + pressEnter(modifiers?: MockKeyModifiers): void; + pressKey(key: string, modifiers?: MockKeyModifiers): void; + pressKeys(keys: string[], delayMs?: number): Promise; }; }