@teaui/core
Version:
A high-level terminal UI library for Node
230 lines • 7.18 kB
JavaScript
import { View } from '../View.js';
// Braille bit mapping for (px % 2, py % 4)
// Each terminal cell is a 2×4 pixel grid
const BRAILLE_BITS = [
[0x01, 0x02, 0x04, 0x40], // dx=0: dots 1,2,3,7
[0x08, 0x10, 0x20, 0x80], // dx=1: dots 4,5,6,8
];
export class Canvas extends View {
#cellCols = 0;
#cellRows = 0;
#pixels = new Uint8Array(0);
#draw;
constructor(props = {}) {
super(props);
this.#update(props);
}
update(props) {
this.#update(props);
super.update(props);
}
#update({ draw }) {
this.#draw = draw;
}
get pixelWidth() {
return this.#cellCols * 2;
}
get pixelHeight() {
return this.#cellRows * 4;
}
naturalSize(available) {
return available;
}
/**
* Render the canvas into the viewport. If a `draw` callback is set, the pixel
* buffer is cleared and the callback is invoked after sizing. External callers
* (e.g. LineChart) can also use `withContext` to draw into the canvas at a
* specific size without needing a full render cycle.
*/
render(viewport) {
if (viewport.isEmpty) {
return;
}
const cols = viewport.contentSize.width;
const rows = viewport.contentSize.height;
this.#ensureSize(cols, rows);
if (this.#draw) {
this.#pixels.fill(0);
this.#draw(this);
}
const style = this.purpose.text();
viewport.visibleRect.forEachPoint(pt => {
const cellIndex = pt.y * this.#cellCols + pt.x;
const bits = this.#pixels[cellIndex] ?? 0;
const char = String.fromCharCode(0x2800 | bits);
viewport.write(char, pt, style);
});
}
/**
* Size the pixel buffer, clear it, and invoke the callback. This is the safe way
* to draw into a Canvas programmatically — the buffer is guaranteed to be sized
* before any drawing occurs.
*
* @param cols Terminal columns (pixelWidth will be cols * 2)
* @param rows Terminal rows (pixelHeight will be rows * 4)
* @param fn Drawing function — call set/line/rect/circle/etc. on the canvas
*/
withContext(cols, rows, fn) {
this.#ensureSize(cols, rows);
this.#pixels.fill(0);
fn(this);
}
#ensureSize(cols, rows) {
if (cols !== this.#cellCols || rows !== this.#cellRows) {
const newPixels = new Uint8Array(cols * rows);
// Copy existing data
const minCols = Math.min(cols, this.#cellCols);
const minRows = Math.min(rows, this.#cellRows);
for (let y = 0; y < minRows; y++) {
for (let x = 0; x < minCols; x++) {
newPixels[y * cols + x] = this.#pixels[y * this.#cellCols + x];
}
}
this.#pixels = newPixels;
this.#cellCols = cols;
this.#cellRows = rows;
}
}
#setPixel(px, py) {
if (px < 0 || py < 0)
return;
const cellX = Math.floor(px / 2);
const cellY = Math.floor(py / 4);
if (cellX >= this.#cellCols || cellY >= this.#cellRows)
return;
const bit = BRAILLE_BITS[px % 2][py % 4];
this.#pixels[cellY * this.#cellCols + cellX] |= bit;
}
#unsetPixel(px, py) {
if (px < 0 || py < 0)
return;
const cellX = Math.floor(px / 2);
const cellY = Math.floor(py / 4);
if (cellX >= this.#cellCols || cellY >= this.#cellRows)
return;
const bit = BRAILLE_BITS[px % 2][py % 4];
this.#pixels[cellY * this.#cellCols + cellX] &= ~bit;
}
set(px, py) {
this.#setPixel(px, py);
this.invalidateRender();
}
unset(px, py) {
this.#unsetPixel(px, py);
this.invalidateRender();
}
toggle(px, py) {
if (this.isSet(px, py)) {
this.#unsetPixel(px, py);
}
else {
this.#setPixel(px, py);
}
this.invalidateRender();
}
isSet(px, py) {
if (px < 0 || py < 0)
return false;
const cellX = Math.floor(px / 2);
const cellY = Math.floor(py / 4);
if (cellX >= this.#cellCols || cellY >= this.#cellRows)
return false;
const bit = BRAILLE_BITS[px % 2][py % 4];
return (this.#pixels[cellY * this.#cellCols + cellX] & bit) !== 0;
}
clear() {
this.#pixels.fill(0);
this.invalidateRender();
}
line(x0, y0, x1, y1) {
this.#drawLine(x0, y0, x1, y1);
this.invalidateRender();
}
rect(x, y, w, h) {
if (w <= 0 || h <= 0)
return;
this.#drawLine(x, y, x + w - 1, y); // top
this.#drawLine(x, y + h - 1, x + w - 1, y + h - 1); // bottom
this.#drawLine(x, y, x, y + h - 1); // left
this.#drawLine(x + w - 1, y, x + w - 1, y + h - 1); // right
this.invalidateRender();
}
fillRect(x, y, w, h) {
for (let py = y; py < y + h; py++) {
for (let px = x; px < x + w; px++) {
this.#setPixel(px, py);
}
}
this.invalidateRender();
}
circle(cx, cy, r) {
if (r <= 0)
return;
this.#drawCircle(cx, cy, r);
this.invalidateRender();
}
fillCircle(cx, cy, r) {
if (r <= 0)
return;
for (let dy = -r; dy <= r; dy++) {
const dx = Math.floor(Math.sqrt(r * r - dy * dy));
for (let px = cx - dx; px <= cx + dx; px++) {
this.#setPixel(px, cy + dy);
}
}
this.invalidateRender();
}
// Bresenham's line algorithm
#drawLine(x0, y0, x1, y1) {
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
let x = x0;
let y = y0;
while (true) {
this.#setPixel(x, y);
if (x === x1 && y === y1)
break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x += sx;
}
if (e2 < dx) {
err += dx;
y += sy;
}
}
}
// Midpoint circle algorithm
#drawCircle(cx, cy, r) {
let x = 0;
let y = r;
let d = 1 - r;
this.#plotCircle8(cx, cy, x, y);
while (x < y) {
x++;
if (d < 0) {
d += 2 * x + 1;
}
else {
y--;
d += 2 * (x - y) + 1;
}
this.#plotCircle8(cx, cy, x, y);
}
}
#plotCircle8(cx, cy, x, y) {
this.#setPixel(cx + x, cy + y);
this.#setPixel(cx - x, cy + y);
this.#setPixel(cx + x, cy - y);
this.#setPixel(cx - x, cy - y);
this.#setPixel(cx + y, cy + x);
this.#setPixel(cx - y, cy + x);
this.#setPixel(cx + y, cy - x);
this.#setPixel(cx - y, cy - x);
}
}
//# sourceMappingURL=Canvas.js.map