donau2space-dev/js/terminal.js

298 lines
8.5 KiB
JavaScript

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// DONAU2SPACE // DEV ENTITY — Terminal Engine
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
import { $, esc, sleep, bus, store, pick } from './utils.js';
import { keyClick } from './audio.js';
// Helper: create a <span> with class and text
function span(cls, text) {
const s = document.createElement('span');
if (cls) s.className = cls;
s.textContent = text;
return s;
}
export class Terminal {
constructor(screenEl, inputEl) {
this.screen = screenEl;
this.input = inputEl;
this.commands = {};
this.history = store.get('history', []);
this.hIndex = this.history.length;
this.locked = false;
this.commandCount = 0;
this.unknownAttempts = 0;
this._bindInput();
}
// ── Output methods ───────────────────────────
print(text, cls = 'line') {
const div = document.createElement('div');
div.className = cls;
div.textContent = text;
this.screen.appendChild(div);
this._scroll();
}
// Print a line containing multiple styled spans.
// segments: array of { text, cls } or plain strings
printStyled(segments, lineCls = 'line') {
const div = document.createElement('div');
div.className = lineCls;
for (const seg of segments) {
if (typeof seg === 'string') {
div.appendChild(document.createTextNode(seg));
} else {
div.appendChild(span(seg.cls || '', seg.text));
}
}
this.screen.appendChild(div);
this._scroll();
}
hr() {
const div = document.createElement('div');
div.className = 'line c-muted dim';
div.textContent = '\u2500'.repeat(58);
this.screen.appendChild(div);
this._scroll();
}
blank() {
this.print('');
}
_buildPS1() {
const frag = document.createDocumentFragment();
frag.appendChild(span('c-green', 'mika'));
frag.appendChild(span('c-muted', '@'));
frag.appendChild(span('c-muted', 'dev'));
frag.appendChild(span('c-muted', ':'));
frag.appendChild(span('c-green', '~'));
frag.appendChild(span('c-muted', '$ '));
return frag;
}
clear() {
this.screen.replaceChildren();
}
// ── Typing effect ────────────────────────────
async typing(lines, speed = 12, cls = 'line') {
for (const line of lines) {
const div = document.createElement('div');
div.className = cls;
this.screen.appendChild(div);
let buf = '';
for (const ch of line) {
buf += ch;
div.textContent = buf;
this._scroll();
await sleep(speed + (Math.random() * 10 | 0));
}
}
}
async typingSlow(text, speed = 35, cls = 'line c-muted') {
const div = document.createElement('div');
div.className = cls;
this.screen.appendChild(div);
let buf = '';
for (const ch of text) {
buf += ch;
div.textContent = buf;
this._scroll();
await sleep(speed + (Math.random() * 20 | 0));
}
}
// ── Command registration ─────────────────────
register(name, fn, meta = {}) {
this.commands[name] = { fn, meta };
}
alias(newName, existingName) {
if (this.commands[existingName]) {
this.commands[newName] = this.commands[existingName];
}
}
listCommands() {
return Object.keys(this.commands).sort();
}
// ── Command execution ────────────────────────
async run(raw) {
const trimmed = (raw || '').trim();
if (!trimmed) return;
// Save history
this.history.push(trimmed);
if (this.history.length > 200) this.history = this.history.slice(-200);
store.set('history', this.history);
this.hIndex = this.history.length;
// Print command line with PS1
const cmdLine = document.createElement('div');
cmdLine.className = 'line';
cmdLine.appendChild(this._buildPS1());
cmdLine.appendChild(span('dim', trimmed));
this.screen.appendChild(cmdLine);
this._scroll();
// Parse
const parts = this._parseArgs(trimmed);
const name = (parts[0] || '').toLowerCase();
const args = parts.slice(1);
this.commandCount++;
bus.emit('command', { name, args, raw: trimmed, count: this.commandCount });
// Route
const entry = this.commands[name];
if (!entry) {
this.unknownAttempts++;
bus.emit('unknown-command', { name, count: this.unknownAttempts });
if (this.unknownAttempts <= 2) {
this.printStyled([
{ text: 'command not found: ', cls: 'c-red' },
{ text: name, cls: 'c-muted' }
]);
this.printStyled([
{ text: 'try: ', cls: 'c-muted' },
{ text: 'help', cls: 'c-blue' }
]);
} else if (this.unknownAttempts <= 5) {
this.print("I don't know that command.", 'line c-muted');
this.print("But I like that you tried.", 'line c-muted');
} else {
const responses = [
"Still guessing? Respect.",
"You're persistent. I'll give you that.",
"That's not a command. But it could be.",
"Unknown. Like most things here.",
"Nope. But keep going.",
];
this.print(pick(responses), 'line c-muted');
}
return;
}
try {
this.locked = true;
await entry.fn(args, this);
this.locked = false;
} catch (e) {
this.locked = false;
this.printStyled([
{ text: 'error: ', cls: 'c-red' },
{ text: String(e), cls: 'c-muted' }
]);
}
}
// ── System message (no prompt) ───────────────
systemMessage(text, cls = 'c-muted') {
const div = document.createElement('div');
div.className = `line ${cls} system-msg`;
div.textContent = text;
this.screen.appendChild(div);
this._scroll();
}
// ── Input binding ────────────────────────────
_bindInput() {
this.input.addEventListener('keydown', (e) => {
if (this.locked && e.key === 'Enter') {
e.preventDefault();
return;
}
if (e.key === 'Enter') {
const val = this.input.value;
this.input.value = '';
this.run(val);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (this.history.length) {
this.hIndex = Math.max(0, this.hIndex - 1);
this.input.value = this.history[this.hIndex] || '';
setTimeout(() => this.input.setSelectionRange(this.input.value.length, this.input.value.length), 0);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (this.history.length) {
this.hIndex = Math.min(this.history.length, this.hIndex + 1);
this.input.value = this.history[this.hIndex] || '';
setTimeout(() => this.input.setSelectionRange(this.input.value.length, this.input.value.length), 0);
}
return;
}
if (e.key === 'Tab') {
e.preventDefault();
const v = this.input.value;
const prefix = v.trim().split(/\s+/)[0] || '';
if (!prefix) return;
const opts = this.listCommands().filter(c => c.startsWith(prefix.toLowerCase()));
if (opts.length === 1) {
this.input.value = opts[0] + (v.includes(' ') ? v.slice(v.indexOf(' ')) : ' ');
} else if (opts.length > 1 && opts.length <= 12) {
this.print(opts.join(' '), 'line c-muted');
}
return;
}
// Subtle key click sound
if (e.key.length === 1) keyClick();
});
// Focus management
this.screen.addEventListener('click', () => this.input.focus());
}
// ── Arg parsing ──────────────────────────────
_parseArgs(raw) {
const out = [];
let cur = '', q = null;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
if (q) {
if (ch === q) { q = null; continue; }
cur += ch; continue;
}
if (ch === '"' || ch === "'") { q = ch; continue; }
if (/\s/.test(ch)) {
if (cur) { out.push(cur); cur = ''; }
continue;
}
cur += ch;
}
if (cur) out.push(cur);
return out;
}
_scroll() {
this.screen.scrollTop = this.screen.scrollHeight;
}
focus() {
this.input.focus();
}
}