fix: unifies cursor positioning through input manager

removed: block level preserve history removed until accurate reporting of render heights is available
fix: fixes block multiline rendering
This commit is contained in:
Emmaline Autumn 2025-06-09 12:54:52 -06:00
parent 04d5044c43
commit 7eb7197a1c
7 changed files with 188 additions and 141 deletions

View File

@ -72,6 +72,14 @@ export class CLIKeypressEvent extends CustomEvent<EventDetailMap["keypress"]> {
} }
} }
type bounds = {
top?: number;
left?: number;
right?: number;
bottom?: number;
boundMode?: "relative" | "absolute";
};
export class InputManager extends ManagerEventTarget { export class InputManager extends ManagerEventTarget {
private static instance = new InputManager(); private static instance = new InputManager();
private active = false; private active = false;
@ -144,6 +152,7 @@ export class InputManager extends ManagerEventTarget {
if (byte === 127 || byte === 8) { if (byte === 127 || byte === 8) {
this.dispatchEvent(new Event("backspace")); this.dispatchEvent(new Event("backspace"));
i++; i++;
this.moveCursor(-1, 0);
continue; continue;
} }
@ -157,18 +166,23 @@ export class InputManager extends ManagerEventTarget {
if (byte === 27 && i + 1 < n && buf[i + 1] === 91) { if (byte === 27 && i + 1 < n && buf[i + 1] === 91) {
const code = buf[i + 2]; const code = buf[i + 2];
switch (code) { switch (code) {
case 65: case 65: // Up
this.moveCursor(0, -1);
this.dispatchEvent(new Event("arrow-up")); this.dispatchEvent(new Event("arrow-up"));
break; break;
case 66: case 66: // Down
this.moveCursor(0, 1);
this.dispatchEvent(new Event("arrow-down")); this.dispatchEvent(new Event("arrow-down"));
break; break;
case 67: case 67: // Right
this.moveCursor(1, 0);
this.dispatchEvent(new Event("arrow-right")); this.dispatchEvent(new Event("arrow-right"));
break; break;
case 68: case 68: // Left
this.moveCursor(-1, 0);
this.dispatchEvent(new Event("arrow-left")); this.dispatchEvent(new Event("arrow-left"));
break; break;
case 51: case 51:
if (i + 3 < n && buf[i + 3] === 126) { if (i + 3 < n && buf[i + 3] === 126) {
this.dispatchEvent(new Event("delete")); this.dispatchEvent(new Event("delete"));
@ -186,6 +200,7 @@ export class InputManager extends ManagerEventTarget {
this.dispatchEvent( this.dispatchEvent(
new CLICharEvent({ key: byte, char: String.fromCharCode(byte) }), new CLICharEvent({ key: byte, char: String.fromCharCode(byte) }),
); );
this.moveCursor(1, 0);
i++; i++;
continue; 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`,
),
);
}
} }

View File

@ -1,4 +1,5 @@
import { Cursor } from "./cursor.ts"; import { Cursor } from "./cursor.ts";
import { InputManager } from "./InputManager.ts";
export class TerminalLayout { export class TerminalLayout {
private static ALT_BUFFER_ENABLE = "\x1b[?1049h"; private static ALT_BUFFER_ENABLE = "\x1b[?1049h";
@ -132,12 +133,8 @@ export class TerminalBlock {
this.layout = layout; this.layout = layout;
} }
setLines(lines: string[], range?: [number, number]) { setLines(lines: string[]) {
if (range && this.preserveHistory) { this.lines = lines;
this.lines.splice(range[0], range[1], ...lines);
} else {
this.lines = this.preserveHistory ? this.lines.concat(lines) : lines;
}
if (this.scrollOffset > lines.length - 1) { if (this.scrollOffset > lines.length - 1) {
this.scrollOffset = Math.max(0, lines.length - 1); this.scrollOffset = Math.max(0, lines.length - 1);
} }
@ -149,11 +146,47 @@ export class TerminalBlock {
); );
this.renderInternal(); this.renderInternal();
} }
range = [ }
range?.[0] ?? this.lines.length - lines.length,
range ? range[0] + lines.length : this.lines.length, wrapLines(maxWidth: number): string[] {
]; const wrapped: string[] = [];
return range; 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 lines 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[]) { append(lines: string[]) {
@ -186,7 +219,16 @@ export class TerminalBlock {
} }
getRenderedLines(maxHeight: number): string[] { 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[]) { setRenderLines(lines: string[]) {
@ -262,12 +304,21 @@ export class TerminalBlock {
getFixedHeight(): number { getFixedHeight(): number {
return this.fixedHeight ?? 0; return this.fixedHeight ?? 0;
} }
requestCursorAt(
lineOffsetFromStart = 0,
col = 0,
): [row: number, col: number] {
return [this.lastRenderRow + lineOffsetFromStart, col];
}
private _postRenderAction?: () => void; private _postRenderAction?: () => void;
setPostRenderAction(action: (this: TerminalBlock) => void) { setPostRenderAction(action: (this: TerminalBlock) => void) {
this._postRenderAction = action; this._postRenderAction = action;
} }
runPostRenderAction() { runPostRenderAction() {
const im = InputManager.getInstance();
im.moveCursor(0, 0);
if (this._postRenderAction) { if (this._postRenderAction) {
this._postRenderAction.call(this); this._postRenderAction.call(this);
this._postRenderAction = undefined; this._postRenderAction = undefined;

View File

@ -4,115 +4,6 @@ import { colorize } from "./style.ts";
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts"; import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
import { type CLICharEvent, InputManager } from "./InputManager.ts"; import { type CLICharEvent, InputManager } from "./InputManager.ts";
// export async function cliPrompt(
// message: string,
// block?: TerminalBlock,
// ): Promise<string> {
// 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( export async function cliPrompt(
message: string, message: string,
block?: TerminalBlock, block?: TerminalBlock,
@ -120,7 +11,6 @@ export async function cliPrompt(
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const input: string[] = []; const input: string[] = [];
let cursorPos = 0; let cursorPos = 0;
let range: [number, number] | undefined;
Cursor.show(); Cursor.show();
@ -132,15 +22,21 @@ export async function cliPrompt(
const moveTo = `\x1b[${message.length + 2 + cursorPos}G`; const moveTo = `\x1b[${message.length + 2 + cursorPos}G`;
if (block) { if (block) {
block.setPostRenderAction(() => { block.setLines([line]);
Deno.stdout.writeSync(encoder.encode(moveTo));
});
range = block.setLines([line], range);
} else { } else {
Deno.stdout.writeSync(encoder.encode("\r\x1b[K" + line + moveTo)); 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 = () => { const exit = () => {
im.removeEventListener("enter", onEnter); im.removeEventListener("enter", onEnter);
im.removeEventListener("backspace", onBackspace); im.removeEventListener("backspace", onBackspace);
@ -189,13 +85,12 @@ export async function cliPrompt(
const onKey = (e: Event) => { const onKey = (e: Event) => {
const ke = (e as CLICharEvent).detail; const ke = (e as CLICharEvent).detail;
cursorPos = im.getRelativeCursor();
input.splice(cursorPos, 0, ke.char); input.splice(cursorPos, 0, ke.char);
cursorPos++; im.updateBounds({ right: input.length + message.length + 1 });
render(); render();
}; };
render();
return await new Promise<string>((res) => { return await new Promise<string>((res) => {
resolve = res; resolve = res;
im.addEventListener("enter", onEnter); im.addEventListener("enter", onEnter);

View File

@ -30,7 +30,6 @@ export async function selectMenuInteractive(
terminalBlock.setRenderHeight(Deno.consoleSize().rows); terminalBlock.setRenderHeight(Deno.consoleSize().rows);
} }
let range: [number, number] = [terminalBlock.lineCount, 1];
function renderMenu() { function renderMenu() {
const { rows } = Deno.consoleSize(); const { rows } = Deno.consoleSize();
const terminalHeight = terminalBlock.getRenderHeight() || rows; 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) { function numberAndPadding(i: number, prefix?: string) {
@ -132,7 +131,7 @@ export async function selectMenuInteractive(
im.addEventListener("escape", onEscape); im.addEventListener("escape", onEscape);
}); });
terminalBlock.setLines(["Selected: " + final], range); // terminalBlock.setLines(["Selected: " + final], range);
return final; return final;
} }
@ -173,7 +172,6 @@ export async function multiSelectMenuInteractive(
if (!allPresent) selectedOptions = selectedOptions.filter((e) => e != 0); if (!allPresent) selectedOptions = selectedOptions.filter((e) => e != 0);
}; };
let range: [number, number] = [terminalBlock.lineCount, 1];
function renderMenu() { function renderMenu() {
const { rows } = Deno.consoleSize(); const { rows } = Deno.consoleSize();
const terminalHeight = terminalBlock.getRenderHeight() || rows; 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(); const im = InputManager.getInstance();
@ -269,7 +267,7 @@ export async function multiSelectMenuInteractive(
} }
} }
const final = selectedOptions.map((i) => rawValues[i]); const final = selectedOptions.map((i) => rawValues[i]);
terminalBlock.setLines(["Selected: " + final.join(", ")], range); terminalBlock.setLines(["Selected: " + final.join(", ")]);
return final; return final;
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@bearmetal/pdf-tools", "name": "@bearmetal/pdf-tools",
"version": "1.0.8-n", "version": "1.0.8-p",
"license": "GPL 3.0", "license": "GPL 3.0",
"tasks": { "tasks": {
"dev": "deno run -A main.ts", "dev": "deno run -A main.ts",

View File

@ -92,6 +92,7 @@ export class FieldVisibility implements ITool {
); );
return visibility; return visibility;
}), }),
{ terminalBlock: this.block },
); );
if (!fieldAndVisibility) break; if (!fieldAndVisibility) break;
const visibility = await selectMenuInteractive( const visibility = await selectMenuInteractive(
@ -102,6 +103,7 @@ export class FieldVisibility implements ITool {
"HiddenButPrintable", "HiddenButPrintable",
"VisibleButDoesNotPrint", "VisibleButDoesNotPrint",
] as AcrobatVisibility[], ] as AcrobatVisibility[],
{ terminalBlock: this.block },
) as AcrobatVisibility | null; ) as AcrobatVisibility | null;
if (!visibility) continue; if (!visibility) continue;

View File

@ -28,7 +28,7 @@ export class ListFormFields implements ITool {
const buildRLines = () => { const buildRLines = () => {
rLines = fieldNames.slice(offset, this.block!.getRenderHeight()); rLines = fieldNames.slice(offset, this.block!.getRenderHeight());
this.block!.setLines(lines.concat(rLines), [0, 1]); this.block!.setLines(lines.concat(rLines));
}; };
buildRLines(); buildRLines();