diff --git a/cli/InputManager.ts b/cli/InputManager.ts index 026480a..5818e2d 100644 --- a/cli/InputManager.ts +++ b/cli/InputManager.ts @@ -72,6 +72,14 @@ export class CLIKeypressEvent extends CustomEvent { } } +type bounds = { + top?: number; + left?: number; + right?: number; + bottom?: number; + boundMode?: "relative" | "absolute"; +}; + export class InputManager extends ManagerEventTarget { private static instance = new InputManager(); private active = false; @@ -144,6 +152,7 @@ export class InputManager extends ManagerEventTarget { if (byte === 127 || byte === 8) { this.dispatchEvent(new Event("backspace")); i++; + this.moveCursor(-1, 0); continue; } @@ -157,18 +166,23 @@ export class InputManager extends ManagerEventTarget { if (byte === 27 && i + 1 < n && buf[i + 1] === 91) { const code = buf[i + 2]; switch (code) { - case 65: + case 65: // Up + this.moveCursor(0, -1); this.dispatchEvent(new Event("arrow-up")); break; - case 66: + case 66: // Down + this.moveCursor(0, 1); this.dispatchEvent(new Event("arrow-down")); break; - case 67: + case 67: // Right + this.moveCursor(1, 0); this.dispatchEvent(new Event("arrow-right")); break; - case 68: + case 68: // Left + this.moveCursor(-1, 0); this.dispatchEvent(new Event("arrow-left")); break; + case 51: if (i + 3 < n && buf[i + 3] === 126) { this.dispatchEvent(new Event("delete")); @@ -186,6 +200,7 @@ export class InputManager extends ManagerEventTarget { this.dispatchEvent( new CLICharEvent({ key: byte, char: String.fromCharCode(byte) }), ); + this.moveCursor(1, 0); i++; continue; } @@ -219,4 +234,90 @@ export class InputManager extends ManagerEventTarget { ); } } + + // Cursor management + private cursor = { row: 0, col: 0 }; + private bounds: bounds = {}; + + getCursor() { + return { ...this.cursor }; + } + + getRelativeCursor() { + const boundStart = (this.bounds.top ?? 0) * Deno.consoleSize().columns + + (this.bounds.left ?? 0); + const cursorPos = (this.cursor.row * Deno.consoleSize().columns) + + this.cursor.col; + return cursorPos - boundStart; + } + + setCursor(row: number, col: number) { + const { columns, rows } = Deno.consoleSize(); + const { top, bottom, left, right, boundMode } = { + top: 0, + bottom: rows - 1, + left: 0, + right: columns, + boundMode: "relative", + ...this.bounds, + } as bounds; + + switch (boundMode) { + case "absolute": + this.cursor.row = Math.max( + top ?? -Infinity, + Math.min(bottom ?? Infinity, row), + ); + this.cursor.col = Math.max( + left ?? -Infinity, + Math.min(right ?? Infinity, col), + ); + break; + case "relative": { + const boundStart = (top! * columns) + left!; + const boundEnd = (bottom! * columns) + right!; + let proposedPosition = (row * columns) + col; + if (proposedPosition < boundStart) proposedPosition = boundStart; + if (proposedPosition > boundEnd) proposedPosition = boundEnd; + col = proposedPosition % columns; + row = (proposedPosition - col) / columns; + this.cursor.row = row; + this.cursor.col = col; + } + } + + this.applyCursor(); + } + + moveCursor(dx: number, dy: number) { + this.setCursor(this.cursor.row + dy, this.cursor.col + dx); + } + + setBounds( + bounds: { top?: number; left?: number; right?: number; bottom?: number }, + ) { + this.bounds = bounds; + this.setCursor(this.cursor.row, this.cursor.col); // enforce bounds immediately + } + getBounds() { + return { ...this.bounds }; + } + updateBounds( + bounds: { top?: number; left?: number; right?: number; bottom?: number }, + ) { + this.bounds = { ...this.bounds, ...bounds }; + this.setCursor(this.cursor.row, this.cursor.col); + } + resetBounds() { + this.bounds = {}; + this.setCursor(this.cursor.row, this.cursor.col); + } + + private applyCursor() { + Deno.stdout.writeSync( + new TextEncoder().encode( + `\x1b[${this.cursor.row + 1};${this.cursor.col + 1}H`, + ), + ); + } } diff --git a/cli/TerminalLayout.ts b/cli/TerminalLayout.ts index 5be729d..4820bce 100644 --- a/cli/TerminalLayout.ts +++ b/cli/TerminalLayout.ts @@ -1,4 +1,5 @@ import { Cursor } from "./cursor.ts"; +import { InputManager } from "./InputManager.ts"; export class TerminalLayout { private static ALT_BUFFER_ENABLE = "\x1b[?1049h"; @@ -132,12 +133,8 @@ export class TerminalBlock { this.layout = layout; } - setLines(lines: string[], range?: [number, number]) { - if (range && this.preserveHistory) { - this.lines.splice(range[0], range[1], ...lines); - } else { - this.lines = this.preserveHistory ? this.lines.concat(lines) : lines; - } + setLines(lines: string[]) { + this.lines = lines; if (this.scrollOffset > lines.length - 1) { this.scrollOffset = Math.max(0, lines.length - 1); } @@ -149,11 +146,47 @@ export class TerminalBlock { ); this.renderInternal(); } - range = [ - range?.[0] ?? this.lines.length - lines.length, - range ? range[0] + lines.length : this.lines.length, - ]; - return range; + } + + wrapLines(maxWidth: number): string[] { + const wrapped: string[] = []; + const inputManager = InputManager.getInstance(); + const cursor = inputManager.getCursor(); + const bounds = inputManager.getBounds(); + + const blockStart = this.lastRenderRow; + let visualRow = blockStart; + + let maxCursorRow = cursor.row; + + for (const line of this.lines) { + const chunks: string[] = []; + + for (let start = 0; start < line.length; start += maxWidth) { + const chunk = line.slice(start, start + maxWidth); + chunks.push(chunk); + wrapped.push(this.prepend + chunk); + } + + const visualLines = chunks.length; + const visualEnd = visualRow + visualLines - 1; + + // Check if the cursor is within this wrapped line’s visual range + if (cursor.row >= visualRow && cursor.row <= visualEnd) { + maxCursorRow = visualEnd; // this becomes the new bottom bound + } + + visualRow = visualEnd + 1; + } + + if (maxCursorRow !== cursor.row) { + inputManager.setBounds({ + ...bounds, + bottom: maxCursorRow - blockStart, + }); + } + + return wrapped; } append(lines: string[]) { @@ -186,7 +219,16 @@ export class TerminalBlock { } getRenderedLines(maxHeight: number): string[] { - return this.lines.slice(this.scrollOffset, this.scrollOffset + maxHeight); + const width = Deno.consoleSize().columns - this.prepend.length; + const wrapped = this.wrapLines(width); + return wrapped.slice(this.scrollOffset, this.scrollOffset + maxHeight); + } + getStartRow(): number { + return this.lastRenderRow; + } + + getEndRow(): number { + return this.lastRenderRow + this.renderedLineCount - 1; } setRenderLines(lines: string[]) { @@ -262,12 +304,21 @@ export class TerminalBlock { getFixedHeight(): number { return this.fixedHeight ?? 0; } + requestCursorAt( + lineOffsetFromStart = 0, + col = 0, + ): [row: number, col: number] { + return [this.lastRenderRow + lineOffsetFromStart, col]; + } private _postRenderAction?: () => void; setPostRenderAction(action: (this: TerminalBlock) => void) { this._postRenderAction = action; } runPostRenderAction() { + const im = InputManager.getInstance(); + im.moveCursor(0, 0); + if (this._postRenderAction) { this._postRenderAction.call(this); this._postRenderAction = undefined; diff --git a/cli/prompts.ts b/cli/prompts.ts index 6976ba1..49f90b9 100644 --- a/cli/prompts.ts +++ b/cli/prompts.ts @@ -4,115 +4,6 @@ import { colorize } from "./style.ts"; import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts"; import { type CLICharEvent, InputManager } from "./InputManager.ts"; -// export async function cliPrompt( -// message: string, -// block?: TerminalBlock, -// ): Promise { -// const encoder = new TextEncoder(); -// const input: string[] = []; -// let cursorPos = 0; - -// await Deno.stdin.setRaw(true); - -// const cursorVisible = Cursor["visible"]; -// Cursor.show(); - -// let range: [number, number] = [0, 1]; -// if (block) { -// range = block.setLines([message + " "]); -// } else { -// Deno.stdout.writeSync(encoder.encode(message + " ")); -// } - -// const render = () => { -// const line = message + " " + input.join(""); -// const moveTo = `\x1b[${message.length + 2 + cursorPos}G`; - -// if (block) { -// block.setPostRenderAction(function () { -// Deno.stdout.writeSync( -// encoder.encode(`\x1b[${this["lastRenderRow"]};1H`), -// ); -// Deno.stdout.writeSync(encoder.encode(moveTo)); -// }); -// range = block.setLines([line], range); -// } else { -// Deno.stdout.writeSync(encoder.encode("\x1b[K" + line + moveTo)); -// } -// }; - -// render(); - -// const buf = new Uint8Array(64); // large enough for most pastes -// inputLoop: -// while (true) { -// const n = await Deno.stdin.read(buf); -// if (n === null) break; - -// for (let i = 0; i < n; i++) { -// const byte = buf[i]; - -// // Ctrl+C -// if (byte === 3) { -// block?.clear(); -// block?.["layout"]?.clearAll(); -// await Deno.stdin.setRaw(false); -// Deno.exit(130); -// } - -// if (byte === 13) { // Enter -// break inputLoop; -// } - -// // Escape sequence? -// if (byte === 27 && i + 1 < n && buf[i + 1] === 91) { -// const third = buf[i + 2]; -// if (third === 68 && cursorPos > 0) cursorPos--; // Left -// else if (third === 67 && cursorPos < input.length) cursorPos++; // Right -// else if (third === 51 && i + 3 < n && buf[i + 3] === 126) { // Delete -// if (cursorPos < input.length) input.splice(cursorPos, 1); -// i += 1; // consume tilde -// } -// i += 2; // consume ESC [ X -// continue; -// } - -// // Backspace -// if (byte === 127 || byte === 8) { -// if (cursorPos > 0) { -// input.splice(cursorPos - 1, 1); -// cursorPos--; -// } -// continue; -// } - -// // Delete (ASCII 46) -// if (byte === 46 && cursorPos < input.length) { -// input.splice(cursorPos, 1); -// continue; -// } - -// // Printable -// if (byte >= 32 && byte <= 126) { -// input.splice(cursorPos, 0, String.fromCharCode(byte)); -// cursorPos++; -// } - -// // Other cases: ignore -// } - -// render(); -// } - -// await Deno.stdin.setRaw(false); -// if (!cursorVisible) { -// Cursor.hide(); -// } -// Deno.stdout.writeSync(encoder.encode("\n")); - -// return input.join(""); -// } - export async function cliPrompt( message: string, block?: TerminalBlock, @@ -120,7 +11,6 @@ export async function cliPrompt( const encoder = new TextEncoder(); const input: string[] = []; let cursorPos = 0; - let range: [number, number] | undefined; Cursor.show(); @@ -132,15 +22,21 @@ export async function cliPrompt( const moveTo = `\x1b[${message.length + 2 + cursorPos}G`; if (block) { - block.setPostRenderAction(() => { - Deno.stdout.writeSync(encoder.encode(moveTo)); - }); - range = block.setLines([line], range); + block.setLines([line]); } else { Deno.stdout.writeSync(encoder.encode("\r\x1b[K" + line + moveTo)); } }; + render(); + const cPos = block?.requestCursorAt(0, message.length + 1); + if (cPos) { + const [row, column] = cPos; + + im.setCursor(row, column); + im.setBounds({ top: row, left: column, right: column, bottom: row }); + } + const exit = () => { im.removeEventListener("enter", onEnter); im.removeEventListener("backspace", onBackspace); @@ -189,13 +85,12 @@ export async function cliPrompt( const onKey = (e: Event) => { const ke = (e as CLICharEvent).detail; + cursorPos = im.getRelativeCursor(); input.splice(cursorPos, 0, ke.char); - cursorPos++; + im.updateBounds({ right: input.length + message.length + 1 }); render(); }; - render(); - return await new Promise((res) => { resolve = res; im.addEventListener("enter", onEnter); diff --git a/cli/selectMenu.ts b/cli/selectMenu.ts index 737b95c..dd2d3d8 100644 --- a/cli/selectMenu.ts +++ b/cli/selectMenu.ts @@ -30,7 +30,6 @@ export async function selectMenuInteractive( terminalBlock.setRenderHeight(Deno.consoleSize().rows); } - let range: [number, number] = [terminalBlock.lineCount, 1]; function renderMenu() { const { rows } = Deno.consoleSize(); const terminalHeight = terminalBlock.getRenderHeight() || rows; @@ -52,7 +51,7 @@ export async function selectMenuInteractive( } } - range = terminalBlock.setLines(lines, range); + terminalBlock.setLines(lines); } function numberAndPadding(i: number, prefix?: string) { @@ -132,7 +131,7 @@ export async function selectMenuInteractive( im.addEventListener("escape", onEscape); }); - terminalBlock.setLines(["Selected: " + final], range); + // terminalBlock.setLines(["Selected: " + final], range); return final; } @@ -173,7 +172,6 @@ export async function multiSelectMenuInteractive( if (!allPresent) selectedOptions = selectedOptions.filter((e) => e != 0); }; - let range: [number, number] = [terminalBlock.lineCount, 1]; function renderMenu() { const { rows } = Deno.consoleSize(); const terminalHeight = terminalBlock.getRenderHeight() || rows; @@ -198,7 +196,7 @@ export async function multiSelectMenuInteractive( } } - range = terminalBlock.setLines(lines, range); + terminalBlock.setLines(lines); } const im = InputManager.getInstance(); @@ -269,7 +267,7 @@ export async function multiSelectMenuInteractive( } } const final = selectedOptions.map((i) => rawValues[i]); - terminalBlock.setLines(["Selected: " + final.join(", ")], range); + terminalBlock.setLines(["Selected: " + final.join(", ")]); return final; } diff --git a/deno.json b/deno.json index 269c573..90f540f 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@bearmetal/pdf-tools", - "version": "1.0.8-n", + "version": "1.0.8-p", "license": "GPL 3.0", "tasks": { "dev": "deno run -A main.ts", diff --git a/tools/fieldVisibility.ts b/tools/fieldVisibility.ts index 4c3c7b1..782f5a9 100644 --- a/tools/fieldVisibility.ts +++ b/tools/fieldVisibility.ts @@ -92,6 +92,7 @@ export class FieldVisibility implements ITool { ); return visibility; }), + { terminalBlock: this.block }, ); if (!fieldAndVisibility) break; const visibility = await selectMenuInteractive( @@ -102,6 +103,7 @@ export class FieldVisibility implements ITool { "HiddenButPrintable", "VisibleButDoesNotPrint", ] as AcrobatVisibility[], + { terminalBlock: this.block }, ) as AcrobatVisibility | null; if (!visibility) continue; diff --git a/tools/listFormFields.ts b/tools/listFormFields.ts index 090cb5c..e7f1836 100644 --- a/tools/listFormFields.ts +++ b/tools/listFormFields.ts @@ -28,7 +28,7 @@ export class ListFormFields implements ITool { const buildRLines = () => { rLines = fieldNames.slice(offset, this.block!.getRenderHeight()); - this.block!.setLines(lines.concat(rLines), [0, 1]); + this.block!.setLines(lines.concat(rLines)); }; buildRLines();