initial cli api, some movement on tool selection
This commit is contained in:
201
cli/TerminalLayout.ts
Normal file
201
cli/TerminalLayout.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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 + TerminalLayout.CURSOR_HIDE,
|
||||
),
|
||||
);
|
||||
this.height = Deno.consoleSize().rows;
|
||||
}
|
||||
|
||||
register(name: string, block: TerminalBlock, fixedHeight?: number) {
|
||||
this.blocks[name] = block;
|
||||
this.layoutOrder.push(name);
|
||||
block.setLayout(this);
|
||||
if (fixedHeight !== undefined) {
|
||||
block.setFixedHeight(fixedHeight);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
Deno.stdout.writeSync(
|
||||
new TextEncoder().encode(
|
||||
TerminalLayout.ALT_BUFFER_DISABLE + TerminalLayout.CURSOR_SHOW,
|
||||
),
|
||||
);
|
||||
for (const name of this.layoutOrder) {
|
||||
this.blocks[name].clear();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
constructor(private prepend: string = "") {}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
this.renderedLineCount = lines.reduce(
|
||||
(count, line) =>
|
||||
count +
|
||||
Math.ceil(
|
||||
(this.prepend.length + line.length) /
|
||||
(Deno.consoleSize().columns || 80),
|
||||
),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
setRenderHeight(height: number) {
|
||||
this.renderHeight = height;
|
||||
}
|
||||
|
||||
getRenderHeight(): number {
|
||||
return this.renderHeight;
|
||||
}
|
||||
|
||||
renderInternal(startRow?: number) {
|
||||
this.lastRenderRow = startRow ?? this.lastRenderRow;
|
||||
this.clear();
|
||||
let output = this.renderLines.map((line) => `${this.prepend}${line}\x1b[K`)
|
||||
.join("\n");
|
||||
if (startRow !== undefined) {
|
||||
const moveCursor = `\x1b[${startRow};1H`;
|
||||
output = moveCursor + output;
|
||||
}
|
||||
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`));
|
||||
}
|
||||
this.renderedLineCount = 0;
|
||||
}
|
||||
|
||||
setFixedHeight(height: number) {
|
||||
this.fixedHeight = height;
|
||||
}
|
||||
|
||||
isFixedHeight(): boolean {
|
||||
return this.fixedHeight !== undefined;
|
||||
}
|
||||
|
||||
getFixedHeight(): number {
|
||||
return this.fixedHeight ?? 0;
|
||||
}
|
||||
|
||||
get lineCount() {
|
||||
return this.renderLines.length;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user