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:
parent
04d5044c43
commit
7eb7197a1c
@ -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 {
|
||||
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`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
129
cli/prompts.ts
129
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<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(
|
||||
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<string>((res) => {
|
||||
resolve = res;
|
||||
im.addEventListener("enter", onEnter);
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user