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 = {}; 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() { 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[], 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) { 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 = `[${baseRow + this.renderLines.length + i};1H`; 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; } get lineCount() { return this.renderLines.length; } }