298 lines
8.5 KiB
JavaScript
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();
|
|
}
|
|
}
|