Split terminal into flat command, achievement, and vim modules
Move canned command data (terminal-commands.js), the achievement system (terminal-achievements.js), and the vim simulator (terminal-vim.js) out of terminal.js, which keeps the core terminal loop. Load order is fixed in index.html with the modules before terminal.js. Also share expand/collapse helpers with the hero click handler and drop the real fetch I/O in favour of simulated curl/wget output.
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
// Simulated vim editor for the fake terminal.
|
||||
// The overlay is appended to <body> while active; on exit the terminal's
|
||||
// content is reset and focus returns to it.
|
||||
//
|
||||
// Parameters:
|
||||
// content .terminal-content element (reset on exit)
|
||||
// terminal .terminal-display element (refocused on exit)
|
||||
// mobileInput the hidden <input>; hidden while vim owns the keyboard
|
||||
// filename argument to `vim <file>` (may be '' for the splash)
|
||||
// setVimMode callback(active) so the host keeps its own state flag
|
||||
|
||||
function launchVimSimulator({ content, terminal, mobileInput, filename, setVimMode }) {
|
||||
let vimBuffer = ['Welcome to vim (simulated)!'];
|
||||
let vimLineNum = 0;
|
||||
let vimStatus = '~';
|
||||
let vimCol = 0;
|
||||
let showSplash = false;
|
||||
setVimMode(true);
|
||||
// Hide the mobile input so it doesn't intercept vim keystrokes
|
||||
mobileInput.style.display = 'none';
|
||||
|
||||
// Create a full-screen vim overlay
|
||||
const vimOverlay = document.createElement('div');
|
||||
vimOverlay.style.position = 'fixed';
|
||||
vimOverlay.style.inset = '0';
|
||||
vimOverlay.style.background = '#0a0a0c';
|
||||
vimOverlay.style.fontFamily = "'SF Mono', 'Fira Code', 'Cascadia Code', monospace";
|
||||
vimOverlay.style.fontSize = '0.85rem';
|
||||
vimOverlay.style.lineHeight = '1.5';
|
||||
vimOverlay.style.color = '#22c55e';
|
||||
vimOverlay.style.display = 'flex';
|
||||
vimOverlay.style.flexDirection = 'column';
|
||||
vimOverlay.style.zIndex = '10000';
|
||||
vimOverlay.style.padding = '12px 24px';
|
||||
vimOverlay.style.overflow = 'hidden';
|
||||
|
||||
document.body.appendChild(vimOverlay);
|
||||
|
||||
const lineNums = [];
|
||||
const lineTexts = [];
|
||||
const vimLines = [];
|
||||
const linesContainer = document.createElement('div');
|
||||
linesContainer.style.flexGrow = '1';
|
||||
linesContainer.style.minHeight = '0';
|
||||
linesContainer.style.overflowY = 'auto';
|
||||
linesContainer.style.display = 'flex';
|
||||
linesContainer.style.flexDirection = 'column';
|
||||
linesContainer.style.scrollbarWidth = 'none';
|
||||
linesContainer.style.msOverflowStyle = 'none';
|
||||
|
||||
function createLineElement() {
|
||||
const line = document.createElement('div');
|
||||
line.style.whiteSpace = 'pre';
|
||||
line.style.flexShrink = '0';
|
||||
const numSpan = document.createElement('span');
|
||||
numSpan.style.color = '#666';
|
||||
numSpan.style.marginRight = '8px';
|
||||
numSpan.style.userSelect = 'none';
|
||||
numSpan.style.display = 'inline-block';
|
||||
numSpan.style.width = '3ch';
|
||||
const textSpan = document.createElement('span');
|
||||
line.appendChild(numSpan);
|
||||
line.appendChild(textSpan);
|
||||
return line;
|
||||
}
|
||||
|
||||
function createSplashLine(text) {
|
||||
const line = document.createElement('div');
|
||||
line.style.whiteSpace = 'pre';
|
||||
line.style.flexShrink = '0';
|
||||
const textSpan = document.createElement('span');
|
||||
line.appendChild(textSpan);
|
||||
return { line, textSpan };
|
||||
}
|
||||
|
||||
if (!filename) {
|
||||
showSplash = true;
|
||||
const splashText = [
|
||||
'',
|
||||
' VIM - Vi sIgnificantly worse M',
|
||||
'',
|
||||
' version 0.0.0-alpha',
|
||||
' by disappointed programmers et al.',
|
||||
' Modified by https://www.reddit.com/r/vim/',
|
||||
' Vim is open source and you will figure it out',
|
||||
'',
|
||||
' Please send help.',
|
||||
'',
|
||||
' type :q<Enter> to escape this regret',
|
||||
' type :help<Enter> or <F1> for on-line help',
|
||||
' type :help version0<Enter> for version info',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const text of splashText) {
|
||||
const { line, textSpan } = createSplashLine(text);
|
||||
linesContainer.appendChild(line);
|
||||
lineTexts.push(textSpan);
|
||||
vimLines.push(line);
|
||||
}
|
||||
|
||||
vimBuffer = splashText;
|
||||
vimLineNum = 0;
|
||||
vimCol = 0;
|
||||
} else {
|
||||
const line = createLineElement();
|
||||
linesContainer.appendChild(line);
|
||||
lineNums.push(line.children[0]);
|
||||
lineTexts.push(line.children[1]);
|
||||
vimLines.push(line);
|
||||
vimBuffer = ['Welcome to ' + filename + '!'];
|
||||
vimLineNum = 0;
|
||||
vimCol = 0;
|
||||
}
|
||||
|
||||
vimOverlay.appendChild(linesContainer);
|
||||
|
||||
// Status bar at the bottom
|
||||
const statusLine = document.createElement('div');
|
||||
statusLine.style.marginTop = 'auto';
|
||||
statusLine.style.borderTop = '1px solid #444';
|
||||
statusLine.style.paddingTop = '4px';
|
||||
statusLine.style.color = '#ccc';
|
||||
statusLine.style.flexShrink = '0';
|
||||
vimOverlay.appendChild(statusLine);
|
||||
|
||||
// File info at the very bottom
|
||||
const fileInfo = document.createElement('div');
|
||||
fileInfo.style.color = '#888';
|
||||
fileInfo.style.flexShrink = '0';
|
||||
vimOverlay.appendChild(fileInfo);
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function vimRender() {
|
||||
// Ensure we have enough line elements
|
||||
while (vimLines.length < vimBuffer.length) {
|
||||
let newLine;
|
||||
if (showSplash) {
|
||||
const { line, textSpan } = createSplashLine('');
|
||||
newLine = line;
|
||||
linesContainer.appendChild(line);
|
||||
lineTexts.push(textSpan);
|
||||
} else {
|
||||
newLine = createLineElement();
|
||||
linesContainer.appendChild(newLine);
|
||||
lineNums.push(newLine.children[0]);
|
||||
lineTexts.push(newLine.children[1]);
|
||||
}
|
||||
vimLines.push(newLine);
|
||||
}
|
||||
|
||||
// Update all visible lines
|
||||
for (let i = 0; i < vimBuffer.length; i++) {
|
||||
if (showSplash) {
|
||||
lineTexts[i].textContent = vimBuffer[i] || '';
|
||||
} else {
|
||||
lineNums[i].textContent = (i + 1);
|
||||
const lineText = vimBuffer[i] || '';
|
||||
if (i === vimLineNum && (vimStatus === '-- INSERT --' || vimStatus === '~')) {
|
||||
const col = vimCol;
|
||||
const before = lineText.slice(0, col);
|
||||
const after = lineText.slice(col);
|
||||
if (after.length > 0) {
|
||||
const cursorChar = after.charAt(0);
|
||||
lineTexts[i].innerHTML = escapeHtml(before) + '<span class="vim-block-cursor">' + escapeHtml(cursorChar) + '</span>' + escapeHtml(after.slice(1));
|
||||
} else {
|
||||
lineTexts[i].innerHTML = escapeHtml(before) + '<span class="vim-block-cursor"> </span>';
|
||||
}
|
||||
} else {
|
||||
lineTexts[i].textContent = lineText;
|
||||
}
|
||||
}
|
||||
vimLines[i].style.display = '';
|
||||
}
|
||||
|
||||
// Hide unused line elements
|
||||
for (let i = vimBuffer.length; i < vimLines.length; i++) {
|
||||
vimLines[i].style.display = 'none';
|
||||
}
|
||||
|
||||
// Auto-scroll when line is above or below the viewport
|
||||
const lineEl = vimLines[vimLineNum];
|
||||
if (lineEl && lineEl.style.display !== 'none') {
|
||||
const lineBottom = lineEl.offsetTop + lineEl.offsetHeight;
|
||||
const lineTop = lineEl.offsetTop;
|
||||
if (lineBottom > linesContainer.scrollTop + linesContainer.clientHeight) {
|
||||
linesContainer.scrollTop = lineBottom - linesContainer.clientHeight;
|
||||
} else if (lineTop < linesContainer.scrollTop + 12) {
|
||||
linesContainer.scrollTop = lineTop - 12;
|
||||
}
|
||||
}
|
||||
|
||||
if (showSplash) {
|
||||
statusLine.textContent = '';
|
||||
fileInfo.textContent = '"VIM - Vi sIgnificantly worse M"';
|
||||
} else {
|
||||
statusLine.textContent = vimStatus;
|
||||
fileInfo.textContent = '"' + filename + '"';
|
||||
}
|
||||
}
|
||||
|
||||
function vimExit(message) {
|
||||
document.removeEventListener('keydown', vimHandler);
|
||||
setVimMode(false);
|
||||
mobileInput.style.display = '';
|
||||
vimOverlay.remove();
|
||||
content.innerHTML = '';
|
||||
if (message) {
|
||||
const msgLine = document.createElement('div');
|
||||
msgLine.style.whiteSpace = 'pre-wrap';
|
||||
msgLine.style.color = '#ccc';
|
||||
msgLine.textContent = message;
|
||||
content.appendChild(msgLine);
|
||||
}
|
||||
const newLine = document.createElement('div');
|
||||
newLine.innerHTML = '<span class="terminal-prompt">$</span> ';
|
||||
content.appendChild(newLine);
|
||||
const newCursor = document.createElement('span');
|
||||
newCursor.className = 'terminal-cursor';
|
||||
newLine.appendChild(newCursor);
|
||||
content.scrollTop = content.scrollHeight;
|
||||
terminal.focus();
|
||||
}
|
||||
|
||||
const vimHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (showSplash) {
|
||||
if (e.key === 'q' && e.shiftKey) {
|
||||
vimExit();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' || e.key === 'q') {
|
||||
vimExit();
|
||||
return;
|
||||
}
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (vimStatus.startsWith(':')) {
|
||||
if (e.key === 'Enter') {
|
||||
const cmd = vimStatus.slice(1).trim();
|
||||
if (cmd === 'q' || cmd === 'q!') {
|
||||
vimExit();
|
||||
return;
|
||||
}
|
||||
if (cmd === 'wq' || cmd === 'x') {
|
||||
vimExit('File saved (simulated).');
|
||||
return;
|
||||
}
|
||||
if (cmd === 'w') {
|
||||
vimStatus = 'File written (simulated).';
|
||||
vimRender();
|
||||
setTimeout(() => { vimStatus = ':'; vimRender(); }, 1000);
|
||||
return;
|
||||
}
|
||||
if (cmd.startsWith('%s/')) {
|
||||
vimStatus = 'Pattern not found (simulated).';
|
||||
vimRender();
|
||||
setTimeout(() => { vimStatus = ':'; vimRender(); }, 1000);
|
||||
return;
|
||||
}
|
||||
if (cmd === 'help') {
|
||||
vimStatus = ':q quit :w save :wq save & quit :q! force quit dd delete line i insert o open line';
|
||||
vimRender();
|
||||
setTimeout(() => { vimStatus = ':'; vimRender(); }, 3000);
|
||||
return;
|
||||
}
|
||||
vimStatus = 'Unknown command: ' + cmd;
|
||||
vimRender();
|
||||
setTimeout(() => { vimStatus = ':'; vimRender(); }, 2000);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
vimStatus = '~';
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace') {
|
||||
vimStatus = vimStatus.slice(0, -1);
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key.length === 1) {
|
||||
vimStatus += e.key;
|
||||
vimRender();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (vimStatus === '-- INSERT --') {
|
||||
if (e.key === 'Escape') {
|
||||
vimStatus = '~';
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, vimCol) + e.key + vimBuffer[vimLineNum].slice(vimCol);
|
||||
vimCol++;
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
vimBuffer.splice(vimLineNum + 1, 0, '');
|
||||
vimLineNum++;
|
||||
vimCol = 0;
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace') {
|
||||
if (vimCol > 0) {
|
||||
vimCol--;
|
||||
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, vimCol) + vimBuffer[vimLineNum].slice(vimCol + 1);
|
||||
}
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (vimLineNum > 0) { vimLineNum--; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (vimLineNum < vimBuffer.length - 1) { vimLineNum++; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
if (vimCol > 0) { vimCol--; vimRender(); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
const maxCol = (vimBuffer[vimLineNum] || '').length;
|
||||
if (vimCol < maxCol) { vimCol++; vimRender(); }
|
||||
return;
|
||||
}
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal mode (~)
|
||||
if (e.key === 'Escape') {
|
||||
vimStatus = '~';
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'i') {
|
||||
vimStatus = '-- INSERT --';
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'I') {
|
||||
vimCol = 0;
|
||||
vimStatus = '-- INSERT --';
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'a') {
|
||||
vimCol = Math.min(vimCol + 1, (vimBuffer[vimLineNum] || '').length);
|
||||
vimStatus = '-- INSERT --';
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'A') {
|
||||
vimCol = (vimBuffer[vimLineNum] || '').length;
|
||||
vimStatus = '-- INSERT --';
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'o') {
|
||||
vimBuffer.splice(vimLineNum + 1, 0, '');
|
||||
vimLineNum++;
|
||||
vimCol = 0;
|
||||
vimStatus = '-- INSERT --';
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'O') {
|
||||
vimBuffer.splice(vimLineNum, 0, '');
|
||||
vimLineNum++;
|
||||
vimCol = 0;
|
||||
vimStatus = '-- INSERT --';
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'x') {
|
||||
if (vimBuffer[vimLineNum]) {
|
||||
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, -1);
|
||||
}
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
// "dd" — delete the current line (Shift+D, or Alt+D as a fallback).
|
||||
// The buffer always keeps at least one (empty) line so insert
|
||||
// mode can never index a missing line.
|
||||
if ((e.key === 'D' && e.shiftKey) || (e.key === 'd' && e.altKey)) {
|
||||
vimBuffer.splice(vimLineNum, 1);
|
||||
if (vimBuffer.length === 0) vimBuffer.push('');
|
||||
if (vimLineNum >= vimBuffer.length) vimLineNum = vimBuffer.length - 1;
|
||||
vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length);
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (vimLineNum > 0) { vimLineNum--; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (vimLineNum < vimBuffer.length - 1) { vimLineNum++; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
if (vimCol > 0) { vimCol--; vimRender(); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
const maxCol = (vimBuffer[vimLineNum] || '').length;
|
||||
if (vimCol < maxCol) { vimCol++; vimRender(); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'G') {
|
||||
vimLineNum = vimBuffer.length - 1;
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'g') {
|
||||
vimLineNum = 0;
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
if (e.key === ':') {
|
||||
vimStatus = ':';
|
||||
vimRender();
|
||||
return;
|
||||
}
|
||||
vimRender();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', vimHandler);
|
||||
}
|
||||
Reference in New Issue
Block a user