removed: block level preserve history removed until accurate reporting of render heights is available fix: fixes block multiline rendering
332 lines
8.5 KiB
TypeScript
332 lines
8.5 KiB
TypeScript
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 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[]) {
|
||
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;
|
||
}
|
||
}
|