forked from genewildish/Mainline
- Replace estimate_block_height (PIL-based) with estimate_simple_height (word wrap) - Update viewport filter tests to match new height-based filtering (~4 items vs 24) - Fix CI task duplication in mise.toml (remove redundant depends) Closes #38 Closes #36
370 lines
13 KiB
HTML
370 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Mainline Terminal</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
background: #0a0a0a;
|
|
color: #ccc;
|
|
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}
|
|
body.fullscreen {
|
|
padding: 0;
|
|
}
|
|
body.fullscreen #controls {
|
|
display: none;
|
|
}
|
|
#container {
|
|
position: relative;
|
|
}
|
|
canvas {
|
|
background: #000;
|
|
border: 1px solid #333;
|
|
image-rendering: pixelated;
|
|
image-rendering: crisp-edges;
|
|
}
|
|
body.fullscreen canvas {
|
|
border: none;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
max-width: 100vw;
|
|
max-height: 100vh;
|
|
}
|
|
#controls {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
align-items: center;
|
|
}
|
|
#controls button {
|
|
background: #333;
|
|
color: #ccc;
|
|
border: 1px solid #555;
|
|
padding: 5px 12px;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
font-size: 12px;
|
|
}
|
|
#controls button:hover {
|
|
background: #444;
|
|
}
|
|
#controls input {
|
|
width: 60px;
|
|
background: #222;
|
|
color: #ccc;
|
|
border: 1px solid #444;
|
|
padding: 4px 8px;
|
|
font-family: inherit;
|
|
text-align: center;
|
|
}
|
|
#status {
|
|
margin-top: 10px;
|
|
font-size: 12px;
|
|
color: #666;
|
|
}
|
|
#status.connected {
|
|
color: #4f4;
|
|
}
|
|
#status.disconnected {
|
|
color: #f44;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="container">
|
|
<canvas id="terminal"></canvas>
|
|
</div>
|
|
<div id="controls">
|
|
<label>Cols: <input type="number" id="cols" value="80" min="20" max="200"></label>
|
|
<label>Rows: <input type="number" id="rows" value="24" min="10" max="60"></label>
|
|
<button id="apply">Apply</button>
|
|
<button id="fullscreen">Fullscreen</button>
|
|
</div>
|
|
<div id="status" class="disconnected">Connecting...</div>
|
|
|
|
<script>
|
|
const canvas = document.getElementById('terminal');
|
|
const ctx = canvas.getContext('2d');
|
|
const status = document.getElementById('status');
|
|
const colsInput = document.getElementById('cols');
|
|
const rowsInput = document.getElementById('rows');
|
|
const applyBtn = document.getElementById('apply');
|
|
const fullscreenBtn = document.getElementById('fullscreen');
|
|
|
|
const CHAR_WIDTH = 9;
|
|
const CHAR_HEIGHT = 16;
|
|
|
|
const ANSI_COLORS = {
|
|
0: '#000000', 1: '#cd3131', 2: '#0dbc79', 3: '#e5e510',
|
|
4: '#2472c8', 5: '#bc3fbc', 6: '#11a8cd', 7: '#e5e5e5',
|
|
8: '#666666', 9: '#f14c4c', 10: '#23d18b', 11: '#f5f543',
|
|
12: '#3b8eea', 13: '#d670d6', 14: '#29b8db', 15: '#ffffff',
|
|
};
|
|
|
|
let cols = 80;
|
|
let rows = 24;
|
|
let ws = null;
|
|
|
|
function resizeCanvas() {
|
|
canvas.width = cols * CHAR_WIDTH;
|
|
canvas.height = rows * CHAR_HEIGHT;
|
|
}
|
|
|
|
function parseAnsi(text) {
|
|
if (!text) return [];
|
|
|
|
const tokens = [];
|
|
let currentText = '';
|
|
let fg = '#cccccc';
|
|
let bg = '#000000';
|
|
let bold = false;
|
|
let i = 0;
|
|
let inEscape = false;
|
|
let escapeCode = '';
|
|
|
|
while (i < text.length) {
|
|
const char = text[i];
|
|
|
|
if (inEscape) {
|
|
if (char >= '0' && char <= '9' || char === ';' || char === '[') {
|
|
escapeCode += char;
|
|
}
|
|
|
|
if (char === 'm') {
|
|
const codes = escapeCode.replace('\x1b[', '').split(';');
|
|
|
|
for (const code of codes) {
|
|
const num = parseInt(code) || 0;
|
|
|
|
if (num === 0) {
|
|
fg = '#cccccc';
|
|
bg = '#000000';
|
|
bold = false;
|
|
} else if (num === 1) {
|
|
bold = true;
|
|
} else if (num === 22) {
|
|
bold = false;
|
|
} else if (num === 39) {
|
|
fg = '#cccccc';
|
|
} else if (num === 49) {
|
|
bg = '#000000';
|
|
} else if (num >= 30 && num <= 37) {
|
|
fg = ANSI_COLORS[num - 30 + (bold ? 8 : 0)] || '#cccccc';
|
|
} else if (num >= 40 && num <= 47) {
|
|
bg = ANSI_COLORS[num - 40] || '#000000';
|
|
} else if (num >= 90 && num <= 97) {
|
|
fg = ANSI_COLORS[num - 90 + 8] || '#cccccc';
|
|
} else if (num >= 100 && num <= 107) {
|
|
bg = ANSI_COLORS[num - 100 + 8] || '#000000';
|
|
} else if (num >= 1 && num <= 256) {
|
|
// 256 colors
|
|
if (num < 16) {
|
|
fg = ANSI_COLORS[num] || '#cccccc';
|
|
} else if (num < 232) {
|
|
const c = num - 16;
|
|
const r = Math.floor(c / 36) * 51;
|
|
const g = Math.floor((c % 36) / 6) * 51;
|
|
const b = (c % 6) * 51;
|
|
fg = `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
|
|
} else {
|
|
const gray = (num - 232) * 10 + 8;
|
|
fg = `#${gray.toString(16).repeat(2)}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (currentText) {
|
|
tokens.push({ text: currentText, fg, bg, bold });
|
|
currentText = '';
|
|
}
|
|
inEscape = false;
|
|
escapeCode = '';
|
|
}
|
|
} else if (char === '\x1b' && text[i + 1] === '[') {
|
|
if (currentText) {
|
|
tokens.push({ text: currentText, fg, bg, bold });
|
|
currentText = '';
|
|
}
|
|
inEscape = true;
|
|
escapeCode = '';
|
|
i++;
|
|
} else {
|
|
currentText += char;
|
|
}
|
|
i++;
|
|
}
|
|
|
|
if (currentText) {
|
|
tokens.push({ text: currentText, fg, bg, bold });
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
function renderLine(text, x, y, lineHeight) {
|
|
const tokens = parseAnsi(text);
|
|
let xOffset = x;
|
|
|
|
for (const token of tokens) {
|
|
if (token.text) {
|
|
if (token.bold) {
|
|
ctx.font = 'bold 16px monospace';
|
|
} else {
|
|
ctx.font = '16px monospace';
|
|
}
|
|
|
|
const metrics = ctx.measureText(token.text);
|
|
|
|
if (token.bg !== '#000000') {
|
|
ctx.fillStyle = token.bg;
|
|
ctx.fillRect(xOffset, y - 2, metrics.width + 1, lineHeight);
|
|
}
|
|
|
|
ctx.fillStyle = token.fg;
|
|
ctx.fillText(token.text, xOffset, y);
|
|
xOffset += metrics.width;
|
|
}
|
|
}
|
|
}
|
|
|
|
function connect() {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${window.location.hostname}:8765`;
|
|
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = () => {
|
|
status.textContent = 'Connected';
|
|
status.className = 'connected';
|
|
sendSize();
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
status.textContent = 'Disconnected - Reconnecting...';
|
|
status.className = 'disconnected';
|
|
setTimeout(connect, 1000);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
status.textContent = 'Connection error';
|
|
status.className = 'disconnected';
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
|
|
if (data.type === 'frame') {
|
|
cols = data.width || 80;
|
|
rows = data.height || 24;
|
|
colsInput.value = cols;
|
|
rowsInput.value = rows;
|
|
resizeCanvas();
|
|
render(data.lines || []);
|
|
} else if (data.type === 'clear') {
|
|
ctx.fillStyle = '#000';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
} else if (data.type === 'state') {
|
|
// Log state updates for debugging (can be extended for UI)
|
|
console.log('State update:', data.state);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to parse message:', e);
|
|
}
|
|
};
|
|
}
|
|
|
|
function sendSize() {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'resize',
|
|
width: parseInt(colsInput.value),
|
|
height: parseInt(rowsInput.value)
|
|
}));
|
|
}
|
|
}
|
|
|
|
function render(lines) {
|
|
ctx.fillStyle = '#000';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
ctx.font = '16px monospace';
|
|
ctx.textBaseline = 'top';
|
|
|
|
const lineHeight = CHAR_HEIGHT;
|
|
const maxLines = Math.min(lines.length, rows);
|
|
|
|
for (let i = 0; i < maxLines; i++) {
|
|
const line = lines[i] || '';
|
|
renderLine(line, 0, i * lineHeight, lineHeight);
|
|
}
|
|
}
|
|
|
|
function calculateViewportSize() {
|
|
const isFullscreen = document.fullscreenElement !== null;
|
|
const padding = isFullscreen ? 0 : 40;
|
|
const controlsHeight = isFullscreen ? 0 : 60;
|
|
const availableWidth = window.innerWidth - padding;
|
|
const availableHeight = window.innerHeight - controlsHeight;
|
|
cols = Math.max(20, Math.floor(availableWidth / CHAR_WIDTH));
|
|
rows = Math.max(10, Math.floor(availableHeight / CHAR_HEIGHT));
|
|
colsInput.value = cols;
|
|
rowsInput.value = rows;
|
|
resizeCanvas();
|
|
console.log('Fullscreen:', isFullscreen, 'Size:', cols, 'x', rows);
|
|
sendSize();
|
|
}
|
|
|
|
applyBtn.addEventListener('click', () => {
|
|
cols = parseInt(colsInput.value);
|
|
rows = parseInt(rowsInput.value);
|
|
resizeCanvas();
|
|
sendSize();
|
|
});
|
|
|
|
fullscreenBtn.addEventListener('click', () => {
|
|
if (!document.fullscreenElement) {
|
|
document.body.classList.add('fullscreen');
|
|
document.documentElement.requestFullscreen().then(() => {
|
|
calculateViewportSize();
|
|
});
|
|
} else {
|
|
document.exitFullscreen().then(() => {
|
|
calculateViewportSize();
|
|
});
|
|
}
|
|
});
|
|
|
|
document.addEventListener('fullscreenchange', () => {
|
|
if (!document.fullscreenElement) {
|
|
document.body.classList.remove('fullscreen');
|
|
calculateViewportSize();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('resize', () => {
|
|
if (document.fullscreenElement) {
|
|
calculateViewportSize();
|
|
}
|
|
});
|
|
|
|
// Initial setup
|
|
resizeCanvas();
|
|
connect();
|
|
</script>
|
|
</body>
|
|
</html>
|