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 = {}; 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; } }