Compare commits

...

3 Commits

Author SHA1 Message Date
cedbab4989 Add preliminary canvas module (doesn't work) 2025-05-19 15:31:58 -05:00
9ea2787ba2 Add dynamic ANSI buffer codes 2025-05-19 15:31:32 -05:00
2eb65f7c55 Extend ANSI lib 2025-05-19 12:59:43 -05:00
3 changed files with 154 additions and 2 deletions

View File

@ -0,0 +1,125 @@
import { ANSI_BUFFERS, ANSI_DYNAMIC, writeAnsi } from '../util/ansi';
import { tryCatch } from '@maxmorozoff/try-catch-tuple';
type CanvasAPI = {
clear: () => Promise<void>;
moveToOrigin: () => Promise<void>;
availableHeight: number;
availableWidth: number;
};
type CleanupHandler = (api: {
clear: () => Promise<void>;
endPosition: { x: number; y: number };
}) => Promise<void>;
export function createCanvas(
paint: (api: CanvasAPI) => Promise<void>,
cleanup?: CleanupHandler
) {
let isActive = true;
let origin = { x: 0, y: 0 };
let currentEnd = { x: 0, y: 0 };
const canvasAPI: CanvasAPI = {
clear: async () => {
await writeAnsi([
ANSI_BUFFERS.SAVE_CURSOR,
ANSI_DYNAMIC.CURSOR_TO(origin.x, origin.y),
]);
// Clear each line in the canvas area
for (let line = 0; line <= currentEnd.y - origin.y; line++) {
await writeAnsi([
ANSI_BUFFERS.CLEAR_LINE,
...(line < currentEnd.y - origin.y
? [ANSI_DYNAMIC.CURSOR_DOWN(1)]
: []),
]);
}
await writeAnsi([ANSI_BUFFERS.RESTORE_CURSOR]);
},
moveToOrigin: async () => {
await writeAnsi([ANSI_DYNAMIC.CURSOR_TO(origin.x, origin.y)]);
},
get availableHeight() {
return currentEnd.y - origin.y;
},
get availableWidth() {
return currentEnd.x - origin.x;
},
};
async function trackPaintArea(fn: () => Promise<void>) {
// Save initial cursor position
const [_, saveError] = await writeAnsi([ANSI_BUFFERS.SAVE_CURSOR]);
if (saveError) throw saveError;
const [initialPos, initialPosError] = await tryCatch(getCursorPosition);
if (initialPosError) {
await writeAnsi([ANSI_BUFFERS.RESTORE_CURSOR]);
throw initialPosError;
}
if (initialPos) origin = initialPos;
// Execute painting
await fn();
// Record new end position
const [endPos, endPosError] = await tryCatch(getCursorPosition);
if (endPosError) {
await writeAnsi([ANSI_BUFFERS.RESTORE_CURSOR]);
throw endPosError;
}
if (endPos) currentEnd = endPos;
// Restore original cursor
await writeAnsi([ANSI_BUFFERS.RESTORE_CURSOR]);
}
return {
async paint() {
if (!isActive) return;
await trackPaintArea(async () => {
await canvasAPI.moveToOrigin();
await paint(canvasAPI);
});
},
async destroy() {
if (!isActive) return;
isActive = false;
if (cleanup) {
await cleanup({
clear: canvasAPI.clear,
endPosition: currentEnd,
});
}
},
};
}
async function getCursorPosition(): Promise<{ x: number; y: number }> {
const [pos, posError] = await tryCatch(async () => {
await Bun.write(Bun.stdout, ANSI_BUFFERS.GET_CURSOR_POSITION);
let response = '';
const decoder = new TextDecoder();
for await (const chunk of Bun.stdin.stream()) {
response += decoder.decode(chunk);
if (response.includes('R')) break;
}
const [_, y, x] = response.match(/\[(\d+);(\d+)R/) || [];
if (!x) throw TypeError("The 'x' position was unset.");
if (!y) throw TypeError("The 'y' position was unset.");
return { x: parseInt(x), y: parseInt(y) };
});
return pos || { x: 0, y: 0 };
}

View File

@ -0,0 +1,6 @@
export const createCanvas = (
paint: () => Promise<number, Error>,
cleanup?: () => Promise<number, Error>
) => {
return [1];
};

View File

@ -34,6 +34,7 @@ export const ANSI = {
BG_WHITE: '\x1B[47m', BG_WHITE: '\x1B[47m',
// Cursor Control // Cursor Control
GET_CURSOR_POSITION: '\x1B[6n',
SAVE_CURSOR: '\x1B[s', SAVE_CURSOR: '\x1B[s',
RESTORE_CURSOR: '\x1B[u', RESTORE_CURSOR: '\x1B[u',
CURSOR_TO: (x: number, y: number) => `\x1B[${y};${x}H`, CURSOR_TO: (x: number, y: number) => `\x1B[${y};${x}H`,
@ -47,6 +48,8 @@ export const ANSI = {
CLEAR_LINE: '\x1B[2K', CLEAR_LINE: '\x1B[2K',
CLEAR_END_LINE: '\x1B[0K', CLEAR_END_LINE: '\x1B[0K',
CLEAR_START_LINE: '\x1B[1K', CLEAR_START_LINE: '\x1B[1K',
CLEAR_TO_END: '\x1B[0J', // From cursor to end of screen
CLEAR_TO_START: '\x1B[1J', // From cursor to beginning of screen
CLEAR_BELOW: '\x1B[J', CLEAR_BELOW: '\x1B[J',
}; };
@ -82,17 +85,31 @@ export const ANSI_BUFFERS = {
BG_WHITE: encoder.encode(ANSI.BG_WHITE), BG_WHITE: encoder.encode(ANSI.BG_WHITE),
// Cursor Control // Cursor Control
GET_CURSOR_POSITION: encoder.encode(ANSI.GET_CURSOR_POSITION),
SAVE_CURSOR: encoder.encode(ANSI.SAVE_CURSOR), SAVE_CURSOR: encoder.encode(ANSI.SAVE_CURSOR),
RESTORE_CURSOR: encoder.encode(ANSI.RESTORE_CURSOR), RESTORE_CURSOR: encoder.encode(ANSI.RESTORE_CURSOR),
// Screen Control
CLEAR_SCREEN: encoder.encode(ANSI.CLEAR_SCREEN), CLEAR_SCREEN: encoder.encode(ANSI.CLEAR_SCREEN),
CLEAR_LINE: encoder.encode(ANSI.CLEAR_LINE), CLEAR_LINE: encoder.encode(ANSI.CLEAR_LINE),
CLEAR_TO_END: encoder.encode(ANSI.CLEAR_TO_END),
CLEAR_TO_START: encoder.encode(ANSI.CLEAR_TO_START),
CLEAR_BELOW: encoder.encode(ANSI.CLEAR_BELOW), CLEAR_BELOW: encoder.encode(ANSI.CLEAR_BELOW),
}; };
export const ANSI_DYNAMIC = {
// Cursor Control
CURSOR_TO: (x: number, y: number) => encoder.encode(ANSI.CURSOR_TO(x, y)),
CURSOR_UP: (x = 1) => encoder.encode(ANSI.CURSOR_UP(x)),
CURSOR_DOWN: (x = 1) => encoder.encode(ANSI.CURSOR_DOWN(x)),
CURSOR_FORWARD: (x = 1) => encoder.encode(ANSI.CURSOR_FORWARD(x)),
CURSOR_BACK: (x = 1) => encoder.encode(ANSI.CURSOR_BACK(x)),
};
type AnsiBufferKey = keyof typeof ANSI_BUFFERS; type AnsiBufferKey = keyof typeof ANSI_BUFFERS;
export async function writeAnsi( export async function writeAnsi(
codes: AnsiBufferKey[], codes: (AnsiBufferKey | Uint8Array)[],
text?: string, text?: string,
autoReset: boolean = true autoReset: boolean = true
): Promise<Result<number, Error>> { ): Promise<Result<number, Error>> {
@ -100,7 +117,11 @@ export async function writeAnsi(
// Add requested ANSI codes // Add requested ANSI codes
for (const code of codes) { for (const code of codes) {
buffers.push(ANSI_BUFFERS[code]); if (code instanceof Uint8Array) {
buffers.push(code);
} else {
buffers.push(ANSI_BUFFERS[code]);
}
} }
// Add optional text // Add optional text