asciitorium
Version:
an ASCII ui framework for web + cli
227 lines (226 loc) • 8.13 kB
JavaScript
import { Component } from '../core/Component';
import { requestRender } from '../core/RenderScheduler';
export class AsciiArt extends Component {
constructor(options) {
// Parse content (supports §/¶ JSON; falls back to single still)
const parsed = parseSprite(options.content);
// Compute width/height from max of all frames (unless provided)
const { maxW, maxH } = measureFrames(parsed.frames);
const borderPadding = options.border ? 2 : 0;
super({
...options,
width: options.width ?? Math.max(1, maxW + borderPadding),
height: options.height ?? Math.max(1, maxH + borderPadding),
});
this.frames = [];
this.frameIndex = 0;
this.loop = false;
this.timer = null;
this.frames = parsed.frames;
this.loop = parsed.defaults.loop || false;
// If we have animation (2+ frames), start it
if (this.frames.length > 1) {
this.startAnimation();
}
}
startAnimation() {
// Kick the very first frame sound (if any)
this.maybePlaySound(this.frames[this.frameIndex]?.meta.sound);
this.scheduleNext();
}
scheduleNext() {
const current = this.frames[this.frameIndex];
const dur = Math.max(0, current?.meta.duration ?? 0);
// Safety: if duration is 0 or missing, render next microtask to avoid tight loops
const delay = Number.isFinite(dur) && dur > 0 ? dur : 0;
this.clearTimer();
this.timer = setTimeout(() => {
this.advanceFrame();
}, delay);
}
advanceFrame() {
if (this.frames.length <= 1)
return;
this.frameIndex++;
if (this.frameIndex >= this.frames.length) {
if (this.loop) {
this.frameIndex = 0;
}
else {
this.frameIndex = this.frames.length - 1; // stick on last
this.clearTimer();
requestRender();
return;
}
}
// Sound for the new frame
this.maybePlaySound(this.frames[this.frameIndex]?.meta.sound);
requestRender();
this.scheduleNext();
}
maybePlaySound(id) {
if (!id)
return;
// Sound system not implemented yet - just log for now
}
clearTimer() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
destroy() {
super.destroy();
this.clearTimer();
}
draw() {
const buffer = super.draw();
const xOffset = this.border ? 1 : 0;
const yOffset = this.border ? 1 : 0;
const innerWidth = this.width - (this.border ? 2 : 0);
const innerHeight = this.height - (this.border ? 2 : 0);
const frame = this.frames[this.frameIndex];
if (!frame)
return buffer;
const lines = frame.lines;
for (let y = 0; y < Math.min(lines.length, innerHeight); y++) {
const line = lines[y];
for (let x = 0; x < Math.min(line.length, innerWidth); x++) {
buffer[y + yOffset][x + xOffset] = line[x];
}
}
this.buffer = buffer;
return buffer;
}
}
/* =========================
Parsing & utilities
========================= */
/**
* parseSprite — supports:
* - Defaults: first non-empty line starting with § {json}
* - Frames: blocks terminated by ¶ {json} (meta for previous block)
* - Stills: if no §/¶ present, entire input is a single frame using fallbacks
* - Graceful JSON error handling (collects errors; uses fallbacks)
* - Trims CRLF; preserves leading/trailing spaces inside art lines
*/
function parseSprite(text) {
const errors = [];
const lines = text.replace(/\r\n?/g, '\n').split('\n');
let defaults = {};
let sawAnyArt = false;
let firstNonEmptySeen = false;
// Buffers
let currentBlock = [];
const rawFrames = [];
const flush = (meta) => {
// Allow empty frames (rare), but usually there is content
rawFrames.push({ art: currentBlock.slice(), meta });
currentBlock = [];
};
const tryParseJSON = (s, where) => {
try {
return JSON.parse(s);
}
catch (e) {
errors.push(`JSON parse error ${where}: ${e?.message ?? String(e)}`);
return {};
}
};
// Scan lines
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const trimmedStart = raw.trimStart();
// First non-empty line special-cases defaults
if (!firstNonEmptySeen && trimmedStart.length > 0) {
firstNonEmptySeen = true;
if (trimmedStart.startsWith('§')) {
const payload = raw.slice(raw.indexOf('§') + 1).trim();
if (payload) {
const d = tryParseJSON(payload, `in defaults at line ${i + 1}`);
// Only pick the keys we support for now
defaults = {
duration: asNum(d?.duration),
loop: asBool(d?.loop),
};
}
// continue to next line; defaults line itself is not art
continue;
}
}
// Frame separator
if (trimmedStart.startsWith('¶')) {
const payload = raw.slice(raw.indexOf('¶') + 1).trim();
const metaRaw = payload ? tryParseJSON(payload, `in frame meta at line ${i + 1}`) : {};
const meta = {
duration: asNum(metaRaw?.duration),
sound: typeof metaRaw?.sound === 'string' ? metaRaw.sound : undefined,
};
flush(meta);
sawAnyArt = true;
continue;
}
// Otherwise, this is art content (preserve exactly; only strip trailing \r earlier)
currentBlock.push(raw);
if (raw.length > 0)
sawAnyArt = true;
}
// If file didn't end with a separator, flush the trailing block
if (currentBlock.length) {
flush({});
}
// If we never saw § or ¶ and we have art, treat entire input as a still
const usedSpriteFormat = lines.some(l => l.trimStart().startsWith('§') || l.trimStart().startsWith('¶'));
if (!usedSpriteFormat && sawAnyArt) {
const still = {
lines: normalizeBlock(lines),
meta: { duration: 0 }, // irrelevant; there’s only one frame
};
return {
defaults: { duration: 0, loop: false },
frames: [still],
errors,
};
}
const frames = rawFrames.map(({ art, meta }) => {
const merged = {
duration: meta?.duration ?? defaults.duration ?? 100,
sound: meta?.sound,
};
return { lines: normalizeBlock(art), meta: merged };
});
// Edge case: if no frames collected (e.g., empty file), produce a single blank frame
if (frames.length === 0) {
frames.push({ lines: [[]], meta: { duration: defaults.duration ?? 100 } });
}
return { defaults, frames, errors };
}
/** Normalize a block of lines into a 2D char array (ragged-right preserved) */
function normalizeBlock(blockLines) {
// Drop a single leading empty line if present (authoring convenience)
const lines = blockLines.slice();
if (lines.length && lines[0] === '') {
lines.shift();
}
const result = lines.map(line => [...line]);
return result;
}
/** Measure max width/height across frames to size component surface */
function measureFrames(frames) {
let maxW = 1;
let maxH = 1;
for (const f of frames) {
maxH = Math.max(maxH, f.lines.length || 1);
for (const ln of f.lines) {
maxW = Math.max(maxW, ln.length || 0);
}
}
return { maxW, maxH };
}
/* Small helpers */
function asNum(v) {
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
}
function asBool(v) {
return typeof v === 'boolean' ? v : false;
}