asciitorium
Version:
an ASCII CLUI framework
251 lines (250 loc) • 9.18 kB
JavaScript
import { setRenderCallback } from './RenderScheduler.js';
export function isState(v) {
return typeof v === 'object' && typeof v.subscribe === 'function';
}
export function isNodeEnvironment() {
return (typeof process !== 'undefined' &&
typeof process.cpuUsage === 'function' &&
typeof process.memoryUsage === 'function');
}
export function isWebEnvironment() {
return typeof window !== 'undefined' && typeof document !== 'undefined';
}
export function isMobileDevice() {
if (!isWebEnvironment())
return false;
// Check for touch support (works with Chrome DevTools device emulation)
const hasTouchSupport = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const isMobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Also check for small screen size as additional mobile indicator
const isSmallScreen = window.innerWidth <= 768;
// Return true if touch support is present (catches DevTools emulation)
// OR if both mobile UA and small screen (catches real mobile devices)
return hasTouchSupport || (isMobileUA && isSmallScreen);
}
export function isCPUSupported() {
return isNodeEnvironment();
}
export function isMemorySupported() {
return (isNodeEnvironment() ||
(typeof performance !== 'undefined' && performance.memory));
}
export async function setupKeyboardHandling(handleKey, handleMobileButton) {
if (isWebEnvironment()) {
// Web environment
window.addEventListener('keydown', (event) => {
// Prevent default behavior for F1 to avoid browser help
if (event.key === 'F1') {
event.preventDefault();
}
// Handle Shift+Tab as a special case
if (event.key === 'Tab' && event.shiftKey) {
handleKey('Shift+Tab', event);
}
else {
handleKey(event.key, event);
}
});
// Setup mobile controls if on mobile device
if (isMobileDevice() && handleMobileButton) {
setupMobileControls(handleMobileButton);
setupSwipeDetection(handleKey);
}
}
else {
// Terminal environment
const { default: readline } = await import('readline');
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.on('keypress', (_str, key) => {
const k = normalizeKey(key);
handleKey(k);
});
}
}
function setupMobileControls(handleMobileButton) {
const mobileControls = document.getElementById('mobile-controls');
const menuButton = document.getElementById('btn-menu');
if (!mobileControls)
return;
// Show mobile controls and menu button
mobileControls.classList.add('visible');
if (menuButton) {
menuButton.classList.add('visible');
}
// All button IDs - no hardcoded key mappings!
const buttonIds = [
'btn-up',
'btn-down',
'btn-left',
'btn-right',
'btn-a',
'btn-b',
'btn-x',
'btn-y',
'btn-menu',
];
// Add touch event listeners to each button
buttonIds.forEach((buttonId) => {
const button = document.getElementById(buttonId);
if (!button)
return;
// Use touchstart for immediate response
button.addEventListener('touchstart', (event) => {
event.preventDefault(); // Prevent default touch behavior
handleMobileButton(buttonId);
});
// Also support click for testing in desktop browsers
button.addEventListener('click', (event) => {
event.preventDefault();
handleMobileButton(buttonId);
});
});
}
function setupSwipeDetection(handleKey) {
const screen = document.getElementById('screen');
if (!screen)
return;
let touchStartX = 0;
let touchStartY = 0;
let touchStartTime = 0;
screen.addEventListener('touchstart', (event) => {
touchStartX = event.touches[0].clientX;
touchStartY = event.touches[0].clientY;
touchStartTime = Date.now();
});
screen.addEventListener('touchend', (event) => {
const touchEndX = event.changedTouches[0].clientX;
const touchEndY = event.changedTouches[0].clientY;
const touchEndTime = Date.now();
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
const deltaTime = touchEndTime - touchStartTime;
// Require minimum distance and maximum time for swipe
const minSwipeDistance = 50;
const maxSwipeTime = 500;
if (deltaTime > maxSwipeTime)
return;
// Check if horizontal swipe (more horizontal than vertical)
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > minSwipeDistance) {
if (deltaX > 0) {
// Swipe right = Tab forward
handleKey('Tab');
}
else {
// Swipe left = Tab backward
handleKey('Shift+Tab');
}
}
});
}
function normalizeKey(key) {
if (!key)
return '';
if (key.ctrl && key.name === 'c')
process.exit(); // Ctrl+C = quit
switch (key.name) {
case 'return':
return 'Enter';
case 'escape':
return 'Escape';
case 'backspace':
return 'Backspace';
case 'tab':
return key.shift ? 'Shift+Tab' : 'Tab';
case 'up':
return 'ArrowUp';
case 'down':
return 'ArrowDown';
case 'left':
return 'ArrowLeft';
case 'right':
return 'ArrowRight';
default:
return key.sequence || '';
}
}
export function validateWebEnvironment() {
if (isWebEnvironment()) {
const screen = document.getElementById('screen');
if (!screen)
throw new Error('Missing #screen element for DOM renderer');
}
}
export async function loadArt(relativePath) {
if (typeof window !== 'undefined' && typeof fetch !== 'undefined') {
// Web mode: Try project assets first, then library assets
let projectError = null;
let libError = null;
try {
const response = await fetch(relativePath);
if (response.ok) {
const text = await response.text();
// Check if we got HTML instead of the actual asset (Vite SPA fallback)
if (text.trim().startsWith('<!doctype') || text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) {
projectError = new Error('HTML fallback (file not found)');
}
else {
return text;
}
}
else {
projectError = new Error(`${response.status} ${response.statusText}`);
}
}
catch (err) {
projectError = err;
}
// Fallback to library assets (via Vite alias)
try {
const libPath = `/lib-assets/${relativePath}`;
const response = await fetch(libPath);
if (response.ok) {
const text = await response.text();
// Double check we didn't get HTML here too
if (text.trim().startsWith('<!doctype') || text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) {
libError = new Error('HTML fallback (file not found)');
}
else {
return text;
}
}
else {
libError = new Error(`${response.status} ${response.statusText}`);
}
}
catch (err) {
libError = err;
}
throw new Error(`Failed to load ${relativePath} - not found in project (${projectError?.message}) or library (${libError?.message})`);
}
else {
// CLI mode: Try project assets first, then library assets
const { readFile } = await import('fs/promises');
const { resolve } = await import('path');
// Try project public directory first
const projectPath = resolve(process.cwd(), 'public', relativePath);
try {
return await readFile(projectPath, 'utf-8');
}
catch {
// Project asset not found, try library assets
}
// Try library assets in node_modules
const libPath = resolve(process.cwd(), 'node_modules', 'asciitorium', 'public', relativePath);
try {
return await readFile(libPath, 'utf-8');
}
catch {
// Both failed
}
throw new Error(`Failed to load ${relativePath} - not found in project (${projectPath}) or library (${libPath})`);
}
}
export async function start(app) {
validateWebEnvironment();
await setupKeyboardHandling((key) => app.handleKey(key));
setRenderCallback(() => app.render());
}