pdf-tools/cli/TerminalLayout.ts
Emmaline 7eb7197a1c 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
2025-06-09 12:54:52 -06:00

332 lines
8.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Cursor } from "./cursor.ts";
import { InputManager } from "./InputManager.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;
}
for (const name of this.layoutOrder) {
const block = this.blocks[name];
block.runPostRenderAction?.();
}
}
clearAll() {
for (const name of this.layoutOrder) {
this.blocks[name].clear();
}
Deno.stdout.writeSync(
new TextEncoder().encode(
TerminalLayout.ALT_BUFFER_DISABLE,
),
);
Cursor.show();
}
clear() {
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 lastRendered: string[] = [];
private preserveHistory = false;
constructor(private prepend: string = "") {
}
setPreserveHistory(preserveHistory: boolean) {
this.preserveHistory = preserveHistory;
}
setLayout(layout: TerminalLayout) {
this.layout = layout;
}
setLines(lines: string[]) {
this.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();
}
}
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 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[]) {
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[] {
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[]) {
this.renderLines = lines;
}
setRenderHeight(height: number) {
this.renderHeight = height;
}
getRenderHeight(): number {
return this.renderHeight;
}
renderInternal(startRow?: number) {
const outputLines: string[] = [];
for (let i = 0; i < this.renderLines.length; i++) {
const line = `${this.prepend}${this.renderLines[i]}`;
const previous = this.lastRendered[i];
if (line !== previous) {
const moveToLine = `\x1b[${(startRow ?? this.lastRenderRow) + i};1H`;
outputLines.push(moveToLine + line + "\x1b[K");
}
}
if (this.lastRendered.length > this.renderLines.length) {
const baseRow = startRow ?? this.lastRenderRow;
for (let i = this.renderLines.length; i < this.lastRendered.length; i++) {
const moveToLine = `\x1b[${baseRow + i};1H\x1b[2K`;
Deno.stdout.writeSync(new TextEncoder().encode(moveToLine));
}
}
const baseRow = startRow ?? this.lastRenderRow;
const excessLines = this.renderHeight - this.renderLines.length;
for (let i = 0; i < excessLines; i++) {
const moveToLine = `\x1b[${
baseRow + this.renderLines.length + i
};1H\x1b[2K`;
Deno.stdout.writeSync(new TextEncoder().encode(moveToLine));
}
this.lastRendered = [...this.renderLines];
this.renderedLineCount = this.renderHeight;
this.lastRenderRow = baseRow;
const output = outputLines.join("\n");
Deno.stdout.writeSync(new TextEncoder().encode(output));
}
clear() {
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`));
}
}
clearAll() {
this.clear();
this.lines = [];
}
setFixedHeight(height: number) {
this.fixedHeight = height;
}
isFixedHeight(): boolean {
return this.fixedHeight !== undefined;
}
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;
}
}
get lineCount() {
return this.renderLines.length;
}
}