pdf-tools/cli/TerminalLayout.ts
Emma 9535222fb7 improves block functionality
adds cli compatible prompts/logs
adds logfile function for debug
adds multiselect support
new fieldRename
adds listFieldNames
2025-04-30 01:17:45 -06:00

254 lines
6.3 KiB
TypeScript

import { log } from "util/logfile.ts";
import { Cursor } from "./cursor.ts";
export class TerminalLayout {
private static ALT_BUFFER_ENABLE = "\x1b[?1049h";
private static ALT_BUFFER_DISABLE = "\x1b[?1049l";
private static CURSOR_HIDE = "\x1b[?25l";
private static CURSOR_SHOW = "\x1b[?25h";
private blocks: Record<string, TerminalBlock> = {};
private layoutOrder: string[] = [];
private height: number;
private debounceTimer: number | null = null;
private debounceDelay = 10; // ms
constructor() {
Deno.stdout.writeSync(
new TextEncoder().encode(
TerminalLayout.ALT_BUFFER_ENABLE,
),
);
Cursor.hide();
this.height = Deno.consoleSize().rows;
Deno.addSignalListener("SIGINT", () => {
this.clearAll();
Deno.exit(0);
});
}
register(name: string, block: TerminalBlock, fixedHeight?: number) {
this.blocks[name] = block;
this.layoutOrder.push(name);
block.setLayout(this);
if (fixedHeight !== undefined) {
block.setFixedHeight(fixedHeight);
}
}
getBlock(name: string) {
return this.blocks[name];
}
requestRender() {
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(
() => this.renderLayout(),
this.debounceDelay,
);
}
renderLayout() {
let usedLines = 0;
const totalHeight = this.height;
const flexBlocks = this.layoutOrder.filter((name) =>
!this.blocks[name].isFixedHeight()
);
const remainingHeight = totalHeight -
this.layoutOrder.reduce((sum, name) => {
const b = this.blocks[name];
return sum + (b.isFixedHeight() ? b.getFixedHeight() : 0);
}, 0);
const flexHeight = Math.max(
0,
Math.floor(remainingHeight / flexBlocks.length),
);
for (const name of this.layoutOrder) {
const block = this.blocks[name];
const height = block.isFixedHeight()
? block.getFixedHeight()
: flexHeight;
const lines = block.getRenderedLines(height);
block.setRenderHeight(height);
block.setRenderLines(lines);
block.renderInternal(usedLines + 1);
usedLines += lines.length;
}
}
clearAll() {
log("clearAll");
Deno.stdout.writeSync(
new TextEncoder().encode(
TerminalLayout.ALT_BUFFER_DISABLE,
),
);
Cursor.show();
for (const name of this.layoutOrder) {
this.blocks[name].clear();
}
}
clear() {
log("clear " + this.height);
for (let i = 0; i < this.height; i++) {
Deno.stdout.writeSync(new TextEncoder().encode("\x1b[2K\x1b[1E"));
}
}
get availableHeight() {
return this.height;
}
}
export class TerminalBlock {
private lines: string[] = [];
private renderLines: string[] = [];
private renderedLineCount = 0;
private layout?: TerminalLayout;
private fixedHeight?: number;
private scrollOffset = 0;
private renderHeight: number = 0;
private lastRenderRow = 1;
private preserveHistory = false;
constructor(private prepend: string = "") {}
setPreserveHistory(preserveHistory: boolean) {
this.preserveHistory = preserveHistory;
}
setLayout(layout: TerminalLayout) {
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;
}
if (this.scrollOffset > lines.length - 1) {
this.scrollOffset = Math.max(0, lines.length - 1);
}
if (this.layout) {
this.layout.requestRender();
} else {
this.setRenderLines(
this.getRenderedLines(this.fixedHeight || this.renderHeight),
);
this.renderInternal();
}
range = [
range?.[0] ?? this.lines.length - lines.length,
range ? range[0] + lines.length : this.lines.length,
];
return range;
}
append(lines: string[]) {
this.lines.push(...lines);
this.scrollTo(this.lines.length - 1);
if (this.layout) {
this.layout.requestRender();
}
}
scrollTo(offset: number) {
this.scrollOffset = Math.max(0, Math.min(offset, this.lines.length - 1));
if (this.layout) {
this.layout.requestRender();
}
}
scrollBy(delta: number) {
this.scrollTo(this.scrollOffset + delta);
}
atTop(): boolean {
return this.scrollOffset === 0;
}
atBottom(): boolean {
const visibleHeight = this.renderedLineCount || this.fixedHeight ||
this.lines.length;
return this.scrollOffset + visibleHeight >= this.lines.length;
}
getRenderedLines(maxHeight: number): string[] {
return this.lines.slice(this.scrollOffset, this.scrollOffset + maxHeight);
}
setRenderLines(lines: string[]) {
this.renderLines = lines;
}
setRenderHeight(height: number) {
this.renderHeight = height;
}
getRenderHeight(): number {
return this.renderHeight;
}
renderInternal(startRow?: number) {
this.lastRenderRow = startRow ?? this.lastRenderRow;
this.clear(); // uses old renderedLineCount
const outputLines = this.renderLines.map((line) =>
`${this.prepend}${line}\x1b[K`
);
const output = outputLines.join("\n");
if (startRow !== undefined) {
const moveCursor = `\x1b[${startRow};1H`;
Deno.stdout.writeSync(new TextEncoder().encode(moveCursor + output));
} else {
Deno.stdout.writeSync(new TextEncoder().encode(output));
}
// update rendered line count *after* rendering
this.renderedLineCount = outputLines.reduce(
(count, line) =>
count +
Math.ceil((line.length) / (Deno.consoleSize().columns || 80)),
0,
);
}
clear() {
log(this.renderedLineCount);
if (this.renderedLineCount === 0) return;
const moveCursor = `\x1b[${this.lastRenderRow};1H`;
Deno.stdout.writeSync(new TextEncoder().encode(moveCursor));
for (let i = 0; i < this.renderedLineCount; i++) {
Deno.stdout.writeSync(new TextEncoder().encode(`\x1b[2K\x1b[1E`));
}
this.renderedLineCount = 0;
}
clearAll() {
this.clear();
this.lines = [];
}
setFixedHeight(height: number) {
this.fixedHeight = height;
}
isFixedHeight(): boolean {
return this.fixedHeight !== undefined;
}
getFixedHeight(): number {
return this.fixedHeight ?? 0;
}
get lineCount() {
return this.renderLines.length;
}
}