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;
|
||||
}
|
||||
}
|
33
cli/argParser.ts
Normal file
33
cli/argParser.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export class ArgParser {
|
||||
private args: string[];
|
||||
|
||||
constructor(args: string[]) {
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
public get(key: string) {
|
||||
const index = this.args.indexOf(key);
|
||||
if (index === -1) return null;
|
||||
return this.args[index + 1];
|
||||
}
|
||||
|
||||
get flags() {
|
||||
return this.args.filter((arg) => arg.startsWith("-"));
|
||||
}
|
||||
|
||||
get nonFlags() {
|
||||
return this.args.filter((arg) => !arg.startsWith("-"));
|
||||
}
|
||||
|
||||
get namedArgs() {
|
||||
return this.args.filter((arg) => arg.startsWith("--"));
|
||||
}
|
||||
|
||||
get task() {
|
||||
return this.nonFlags[0];
|
||||
}
|
||||
|
||||
static parse(args: string[]) {
|
||||
return new ArgParser(args);
|
||||
}
|
||||
}
|
21
cli/colorize.ts
Normal file
21
cli/colorize.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
const colorMap = {
|
||||
purple: "\x1b[35m",
|
||||
porple: "\x1b[38;2;150;0;200m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
gray: "\x1b[90m",
|
||||
get grey() {
|
||||
return this.gray;
|
||||
},
|
||||
};
|
||||
|
||||
export function colorize(text: string, color?: keyof typeof colorMap | string) {
|
||||
if (!color) return text;
|
||||
const c = colorMap[color as keyof typeof colorMap];
|
||||
if (!c) return text;
|
||||
return `${c}${text}\x1b[0m`;
|
||||
}
|
64
cli/index.ts
Normal file
64
cli/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { getAsciiArt } from "../util/asciiArt.ts";
|
||||
import { toCase } from "util/caseManagement.ts";
|
||||
import { ArgParser } from "./argParser.ts";
|
||||
import { colorize } from "./colorize.ts";
|
||||
import { selectMenuInteractive } from "./selectMenu.ts";
|
||||
|
||||
export class PdfToolsCli {
|
||||
private tools: Map<string, ITool> = new Map();
|
||||
|
||||
async importTools(tools?: string) {
|
||||
tools = tools?.replace(/\/$/, "").replace(/^\.?\//, "") || "tools";
|
||||
for (const toolfile of Deno.readDirSync(tools)) {
|
||||
if (toolfile.isFile) {
|
||||
const tool = await import(
|
||||
Deno.cwd() + "/" + tools + "/" + toolfile.name
|
||||
);
|
||||
if (tool.default) {
|
||||
this.tools.set(
|
||||
toCase(toolfile.name.replace(".ts", ""), "title"),
|
||||
tool.default,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async banana() {
|
||||
const asciiArt = await getAsciiArt("banana");
|
||||
console.log(colorize(asciiArt, "yellow"));
|
||||
}
|
||||
|
||||
private help() {
|
||||
console.log("BearMetal PDF CLI");
|
||||
}
|
||||
|
||||
public async run() {
|
||||
console.clear();
|
||||
let lineCount = 0;
|
||||
for (const t of ["bearmetal:porple", "pdftools:cyan"]) {
|
||||
const [name, color] = t.split(":");
|
||||
const asciiArt = await getAsciiArt(name);
|
||||
console.log(colorize(asciiArt, color));
|
||||
lineCount += asciiArt.split("\n").length;
|
||||
}
|
||||
if (Deno.args.length === 0) {
|
||||
console.log(
|
||||
colorize("No tool specified. Importing all tools...", "gray"),
|
||||
);
|
||||
await this.importTools();
|
||||
}
|
||||
const args = ArgParser.parse(Deno.args);
|
||||
this.toolMenu(Deno.consoleSize().rows - lineCount);
|
||||
}
|
||||
|
||||
private async toolMenu(space?: number) {
|
||||
const tools = this.tools.keys().toArray();
|
||||
const selected = await selectMenuInteractive("Choose a tool", tools);
|
||||
if (!selected) return;
|
||||
const tool = this.tools.get(toCase(selected, "camel"));
|
||||
if (tool) {
|
||||
await tool.run();
|
||||
}
|
||||
}
|
||||
}
|
186
cli/selectMenu.ts
Normal file
186
cli/selectMenu.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { colorize } from "./colorize.ts";
|
||||
import { TerminalBlock, TerminalLayout } from "./TerminalLayout.ts";
|
||||
|
||||
interface ISelectMenuConfig {
|
||||
multiSelect?: boolean;
|
||||
terminalBlock?: TerminalBlock;
|
||||
}
|
||||
|
||||
export function selectMenu(items: string[]) {
|
||||
const menu = items.map((i, index) => `${index + 1}. ${i}`).join("\n");
|
||||
console.log(menu);
|
||||
const index = parseInt(prompt("Please select an option:") || "1") - 1;
|
||||
return items[index];
|
||||
}
|
||||
|
||||
export async function selectMenuInteractive(
|
||||
q: string,
|
||||
options: string[],
|
||||
config?: ISelectMenuConfig,
|
||||
): Promise<string | null> {
|
||||
Deno.stdin.setRaw(true);
|
||||
let selected = 0;
|
||||
|
||||
if (config?.multiSelect) {
|
||||
console.warn("Multi-select not implemented yet");
|
||||
return null;
|
||||
}
|
||||
|
||||
const terminalBlock = config?.terminalBlock || new TerminalBlock();
|
||||
if (!config?.terminalBlock) {
|
||||
terminalBlock.setRenderHeight(Deno.consoleSize().rows);
|
||||
}
|
||||
|
||||
console.log(terminalBlock.getRenderHeight());
|
||||
|
||||
function renderMenu() {
|
||||
const { rows } = Deno.consoleSize();
|
||||
const terminalHeight = terminalBlock.getRenderHeight() || rows;
|
||||
const maxHeight = Math.min(terminalHeight - 1, options.length);
|
||||
let startPoint = Math.max(0, selected - Math.floor(maxHeight / 2));
|
||||
const endPoint = Math.min(options.length, startPoint + maxHeight);
|
||||
if (endPoint - startPoint < maxHeight) {
|
||||
startPoint = Math.max(0, options.length - maxHeight);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(colorize(q, "green"));
|
||||
for (let i = startPoint; i < endPoint; i++) {
|
||||
const option = options[i];
|
||||
if (i === selected) {
|
||||
lines.push(`${numberAndPadding(i, ">")}${colorize(option, "porple")}`);
|
||||
} else {
|
||||
lines.push(`${numberAndPadding(i)}${option}`);
|
||||
}
|
||||
}
|
||||
|
||||
terminalBlock.setLines(lines);
|
||||
}
|
||||
|
||||
function numberAndPadding(i: number, prefix?: string) {
|
||||
const padded = `${i + 1}. `.padStart(
|
||||
options.length.toString().length + 4,
|
||||
);
|
||||
return prefix ? padded.replace(" ", prefix.substring(0, 1)) : padded;
|
||||
}
|
||||
|
||||
let inputBuffer = "";
|
||||
|
||||
// Function to handle input
|
||||
async function handleInput() {
|
||||
const buf = new Uint8Array(3); // arrow keys send 3 bytes
|
||||
while (true) {
|
||||
renderMenu();
|
||||
const n = await Deno.stdin.read(buf);
|
||||
if (n === null) break;
|
||||
|
||||
const [a, b, c] = buf;
|
||||
|
||||
if (a === 3) {
|
||||
Deno.stdin.setRaw(false);
|
||||
console.log("\nInterrupted\n");
|
||||
Deno.exit(130);
|
||||
}
|
||||
|
||||
if (a === 13) { // Enter key
|
||||
if (inputBuffer) {
|
||||
const parsed = parseInt(inputBuffer);
|
||||
if (!isNaN(parsed)) {
|
||||
selected = parsed - 1;
|
||||
}
|
||||
inputBuffer = "";
|
||||
}
|
||||
break;
|
||||
} else if (a === 27 && b === 91) { // Arrow keys
|
||||
inputBuffer = "";
|
||||
if (c === 65) { // Up
|
||||
selected = (selected - 1 + options.length) % options.length;
|
||||
} else if (c === 66) { // Down
|
||||
selected = (selected + 1) % options.length;
|
||||
}
|
||||
} else if (a >= 48 && a <= 57) {
|
||||
inputBuffer += String.fromCharCode(a);
|
||||
} else if (a === 8) {
|
||||
inputBuffer = inputBuffer.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
terminalBlock.clear();
|
||||
Deno.stdin.setRaw(false);
|
||||
return options[selected];
|
||||
}
|
||||
return await handleInput();
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
// const layout = new TerminalLayout();
|
||||
// const block = new TerminalBlock();
|
||||
// const titleBlock = new TerminalBlock();
|
||||
// const postBlock = new TerminalBlock();
|
||||
// titleBlock.setLines(["An incredible fruit menu!"]);
|
||||
// postBlock.setLines(["I'm here too!"]);
|
||||
// titleBlock.setFixedHeight(1);
|
||||
// postBlock.setFixedHeight(1);
|
||||
// layout.register("title", titleBlock);
|
||||
// layout.register("block", block);
|
||||
// layout.register("post", postBlock);
|
||||
|
||||
// const val = await selectMenuInteractive("choose a fruit", [
|
||||
// "apple",
|
||||
// "banana",
|
||||
// "cherry",
|
||||
// "date",
|
||||
// "elderberry",
|
||||
// "fig",
|
||||
// "grape",
|
||||
// "honeydew",
|
||||
// "ilama",
|
||||
// "jackfruit",
|
||||
// "kiwi",
|
||||
// "lemon",
|
||||
// "mango",
|
||||
// "nectarine",
|
||||
// "orange",
|
||||
// "papaya",
|
||||
// "peach",
|
||||
// "pineapple",
|
||||
// "pomegranate",
|
||||
// "quince",
|
||||
// "raspberry",
|
||||
// "strawberry",
|
||||
// "tangerine",
|
||||
// "watermelon",
|
||||
// ], { terminalBlock: block });
|
||||
// layout.clearAll();
|
||||
// console.log(val);
|
||||
|
||||
const val = await selectMenuInteractive("choose a fruit", [
|
||||
"apple",
|
||||
"banana",
|
||||
"cherry",
|
||||
"date",
|
||||
"elderberry",
|
||||
"fig",
|
||||
"grape",
|
||||
"honeydew",
|
||||
"ilama",
|
||||
"jackfruit",
|
||||
"kiwi",
|
||||
"lemon",
|
||||
"mango",
|
||||
"nectarine",
|
||||
"orange",
|
||||
"papaya",
|
||||
"quince",
|
||||
"raspberry",
|
||||
"strawberry",
|
||||
"tangerine",
|
||||
"udara",
|
||||
"vogelbeere",
|
||||
"watermelon",
|
||||
"ximenia",
|
||||
"yuzu",
|
||||
"zucchini",
|
||||
]);
|
||||
console.log(val);
|
||||
}
|
Reference in New Issue
Block a user