Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-05 02:33:35 +11:00
parent 7f5ae16dea
commit b79cbf0545
11 changed files with 1953 additions and 227 deletions

BIN
website/bulge-map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

238
website/crt-bulge.js Normal file
View File

@@ -0,0 +1,238 @@
/**
* CRT Bulge Coordinate Transformer
* Intercepts mouse events and applies inverse distortion to match the visual bulge effect
*/
class CRTBulgeTransformer {
constructor(containerSelector, strength = 0.15) {
this.container = document.querySelector(containerSelector);
this.strength = strength;
this.setupEventListeners();
}
/**
* Apply inverse barrel distortion to transform visual coords to DOM coords
* @param {number} x - Visual X coordinate relative to container
* @param {number} y - Visual Y coordinate relative to container
* @param {number} width - Container width
* @param {number} height - Container height
* @returns {{x: number, y: number}} - Transformed coordinates
*/
inverseDistort(x, y, width, height) {
// Normalize to -1 to 1 (center is 0,0)
const nx = (x / width) * 2 - 1;
const ny = (y / height) * 2 - 1;
// The visual filter pushes pixels outward based on distance squared
// To invert: we need to find where this pixel came FROM
// Using Newton-Raphson iteration to solve the inverse
let ux = nx;
let uy = ny;
// Iterate to find the original position
for (let i = 0; i < 5; i++) {
const distSq = ux * ux + uy * uy;
const factor = 1 + this.strength * distSq;
// Forward distortion: visual = original * factor
// Inverse: original = visual / factor (approximately)
ux = nx / factor;
uy = ny / factor;
}
// Convert back to pixel coordinates
const newX = ((ux + 1) / 2) * width;
const newY = ((uy + 1) / 2) * height;
return { x: newX, y: newY };
}
/**
* Transform mouse event coordinates
* @param {MouseEvent} e - Original mouse event
* @returns {{x: number, y: number}} - Transformed page coordinates
*/
transformEvent(e) {
const rect = this.container.getBoundingClientRect();
// Get position relative to container
const relX = e.clientX - rect.left;
const relY = e.clientY - rect.top;
// Apply inverse distortion
const transformed = this.inverseDistort(relX, relY, rect.width, rect.height);
// Convert back to page coordinates
return {
x: rect.left + transformed.x,
y: rect.top + transformed.y
};
}
/**
* Set up event listeners for mouse interaction
*/
setupEventListeners() {
// Create invisible overlay to capture events
const overlay = document.createElement('div');
overlay.id = 'crt-event-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
cursor: inherit;
`;
document.body.appendChild(overlay);
// Track currently hovered element for hover states
let lastHoveredElement = null;
// Handle mouse movement
overlay.addEventListener('mousemove', (e) => {
const transformed = this.transformEvent(e);
// Temporarily hide overlay to find element underneath
overlay.style.pointerEvents = 'none';
const elementBelow = document.elementFromPoint(transformed.x, transformed.y);
overlay.style.pointerEvents = 'auto';
// Update cursor based on element below
if (elementBelow) {
const computedStyle = window.getComputedStyle(elementBelow);
overlay.style.cursor = computedStyle.cursor;
// Handle hover state changes
if (elementBelow !== lastHoveredElement) {
if (lastHoveredElement) {
lastHoveredElement.dispatchEvent(new MouseEvent('mouseleave', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y
}));
}
elementBelow.dispatchEvent(new MouseEvent('mouseenter', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y
}));
lastHoveredElement = elementBelow;
}
// Dispatch mousemove to element
elementBelow.dispatchEvent(new MouseEvent('mousemove', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y
}));
}
});
// Handle clicks
overlay.addEventListener('click', (e) => {
e.preventDefault();
const transformed = this.transformEvent(e);
overlay.style.pointerEvents = 'none';
const elementBelow = document.elementFromPoint(transformed.x, transformed.y);
overlay.style.pointerEvents = 'auto';
if (elementBelow) {
elementBelow.dispatchEvent(new MouseEvent('click', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y
}));
// Focus if it's an interactive element
if (elementBelow.tagName === 'INPUT' || elementBelow.tagName === 'TEXTAREA' || elementBelow.tabIndex >= 0) {
elementBelow.focus();
}
}
});
// Handle mousedown
overlay.addEventListener('mousedown', (e) => {
const transformed = this.transformEvent(e);
overlay.style.pointerEvents = 'none';
const elementBelow = document.elementFromPoint(transformed.x, transformed.y);
overlay.style.pointerEvents = 'auto';
if (elementBelow) {
elementBelow.dispatchEvent(new MouseEvent('mousedown', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y,
button: e.button
}));
}
});
// Handle mouseup
overlay.addEventListener('mouseup', (e) => {
const transformed = this.transformEvent(e);
overlay.style.pointerEvents = 'none';
const elementBelow = document.elementFromPoint(transformed.x, transformed.y);
overlay.style.pointerEvents = 'auto';
if (elementBelow) {
elementBelow.dispatchEvent(new MouseEvent('mouseup', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y,
button: e.button
}));
}
});
// Handle wheel/scroll
overlay.addEventListener('wheel', (e) => {
const transformed = this.transformEvent(e);
overlay.style.pointerEvents = 'none';
const elementBelow = document.elementFromPoint(transformed.x, transformed.y);
overlay.style.pointerEvents = 'auto';
if (elementBelow) {
elementBelow.dispatchEvent(new WheelEvent('wheel', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y,
deltaX: e.deltaX,
deltaY: e.deltaY,
deltaMode: e.deltaMode
}));
}
}, { passive: true });
// Handle context menu
overlay.addEventListener('contextmenu', (e) => {
const transformed = this.transformEvent(e);
overlay.style.pointerEvents = 'none';
const elementBelow = document.elementFromPoint(transformed.x, transformed.y);
overlay.style.pointerEvents = 'auto';
if (elementBelow) {
const event = new MouseEvent('contextmenu', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y
});
if (!elementBelow.dispatchEvent(event)) {
e.preventDefault();
}
}
});
}
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Match the strength value from create-bulge-map.js
new CRTBulgeTransformer('.container', 0.15);
});

459
website/crt-canvas.js Normal file
View File

@@ -0,0 +1,459 @@
/**
* CRT Canvas Renderer
* Renders content to a canvas with WebGL barrel distortion shader
* Handles input coordinate transformation automatically
*/
class CRTCanvasRenderer {
/**
* @param {HTMLElement} sourceElement - Element to render (will be hidden)
* @param {Object} options - Configuration options
*/
constructor(sourceElement, options = {}) {
this.source = sourceElement;
this.options = {
bulgeStrength: options.bulgeStrength ?? 0.08,
scanlineIntensity: options.scanlineIntensity ?? 0.1,
vignetteStrength: options.vignetteStrength ?? 0.2,
fps: options.fps ?? 60,
...options
};
this.canvas = null;
this.gl = null;
this.program = null;
this.animationId = null;
this.init();
}
/**
* Initialize the canvas and WebGL context
*/
init() {
// Create canvas
this.canvas = document.createElement('canvas');
this.canvas.id = 'crt-canvas';
this.canvas.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
`;
// Insert canvas before source
this.source.parentNode.insertBefore(this.canvas, this.source);
// Hide source but keep it functional
this.source.style.position = 'absolute';
this.source.style.opacity = '0';
this.source.style.pointerEvents = 'none';
// Get WebGL context
this.gl = this.canvas.getContext('webgl', {
alpha: true,
antialias: false,
preserveDrawingBuffer: true
});
if (!this.gl) {
console.error('WebGL not supported, falling back to source element');
this.source.style.opacity = '1';
this.source.style.pointerEvents = 'auto';
this.canvas.remove();
return;
}
this.setupShaders();
this.setupGeometry();
this.setupTexture();
this.setupEventListeners();
this.resize();
window.addEventListener('resize', () => this.resize());
this.startRenderLoop();
}
/**
* Create and compile WebGL shaders
*/
setupShaders() {
const gl = this.gl;
// Vertex shader
const vertexSource = `
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
}
`;
// Fragment shader with barrel distortion
const fragmentSource = `
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_bulgeStrength;
uniform float u_scanlineIntensity;
uniform float u_vignetteStrength;
uniform float u_time;
varying vec2 v_texCoord;
vec2 barrelDistort(vec2 uv) {
// Center UV around origin
vec2 centered = uv * 2.0 - 1.0;
// Calculate distance from center
float distSq = dot(centered, centered);
// Apply barrel distortion
float distortion = 1.0 + u_bulgeStrength * distSq;
centered *= distortion;
// Convert back to 0-1 range
return centered * 0.5 + 0.5;
}
void main() {
// Apply barrel distortion
vec2 distortedUV = barrelDistort(v_texCoord);
// Check if UV is out of bounds
if (distortedUV.x < 0.0 || distortedUV.x > 1.0 ||
distortedUV.y < 0.0 || distortedUV.y > 1.0) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
// Sample texture
vec4 color = texture2D(u_texture, distortedUV);
// Scanlines
float scanline = sin(distortedUV.y * u_resolution.y * 1.5) * 0.5 + 0.5;
color.rgb *= 1.0 - (u_scanlineIntensity * (1.0 - scanline));
// Subtle RGB shift for chromatic aberration
float shift = u_bulgeStrength * 0.002;
float r = texture2D(u_texture, distortedUV + vec2(shift, 0.0)).r;
float b = texture2D(u_texture, distortedUV - vec2(shift, 0.0)).b;
color.r = mix(color.r, r, 0.5);
color.b = mix(color.b, b, 0.5);
// Vignette
vec2 vignetteUV = v_texCoord * 2.0 - 1.0;
float vignette = 1.0 - dot(vignetteUV, vignetteUV) * u_vignetteStrength;
color.rgb *= vignette;
gl_FragColor = color;
}
`;
// Compile shaders
const vertexShader = this.compileShader(gl.VERTEX_SHADER, vertexSource);
const fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, fragmentSource);
// Create program
this.program = gl.createProgram();
gl.attachShader(this.program, vertexShader);
gl.attachShader(this.program, fragmentShader);
gl.linkProgram(this.program);
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
console.error('Shader program failed to link:', gl.getProgramInfoLog(this.program));
}
gl.useProgram(this.program);
// Get uniform locations
this.uniforms = {
texture: gl.getUniformLocation(this.program, 'u_texture'),
resolution: gl.getUniformLocation(this.program, 'u_resolution'),
bulgeStrength: gl.getUniformLocation(this.program, 'u_bulgeStrength'),
scanlineIntensity: gl.getUniformLocation(this.program, 'u_scanlineIntensity'),
vignetteStrength: gl.getUniformLocation(this.program, 'u_vignetteStrength'),
time: gl.getUniformLocation(this.program, 'u_time')
};
}
/**
* Compile a shader
*/
compileShader(type, source) {
const gl = this.gl;
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
/**
* Set up geometry (full-screen quad)
*/
setupGeometry() {
const gl = this.gl;
// Position attribute
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1
]), gl.STATIC_DRAW);
const positionLoc = gl.getAttribLocation(this.program, 'a_position');
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
// Texture coordinate attribute
const texCoordBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
0, 1, 1, 1, 0, 0,
0, 0, 1, 1, 1, 0
]), gl.STATIC_DRAW);
const texCoordLoc = gl.getAttribLocation(this.program, 'a_texCoord');
gl.enableVertexAttribArray(texCoordLoc);
gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, 0, 0);
}
/**
* Set up texture for rendering source element
*/
setupTexture() {
const gl = this.gl;
this.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.texture);
// Set texture parameters
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
}
/**
* Handle canvas resize
*/
resize() {
const rect = this.source.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.canvas.style.width = rect.width + 'px';
this.canvas.style.height = rect.height + 'px';
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
}
/**
* Apply inverse barrel distortion to get source coordinates from canvas coordinates
*/
inverseDistort(canvasX, canvasY) {
const rect = this.canvas.getBoundingClientRect();
// Normalize to 0-1
let u = canvasX / rect.width;
let v = canvasY / rect.height;
// Center around 0
let x = u * 2 - 1;
let y = v * 2 - 1;
// Iteratively solve inverse (Newton-Raphson)
for (let i = 0; i < 10; i++) {
const distSq = x * x + y * y;
const distortion = 1 + this.options.bulgeStrength * distSq;
// The forward transform is: distorted = original * distortion
// So inverse is approximately: original = distorted / distortion
x = (u * 2 - 1) / distortion;
y = (v * 2 - 1) / distortion;
}
// Convert back to pixel coordinates
const sourceX = ((x + 1) / 2) * rect.width;
const sourceY = ((y + 1) / 2) * rect.height;
return { x: sourceX, y: sourceY };
}
/**
* Set up event listeners for mouse/touch input
*/
setupEventListeners() {
const forwardEvent = (e, type) => {
const rect = this.canvas.getBoundingClientRect();
const canvasX = e.clientX - rect.left;
const canvasY = e.clientY - rect.top;
const sourceCoords = this.inverseDistort(canvasX, canvasY);
const sourceX = rect.left + sourceCoords.x;
const sourceY = rect.top + sourceCoords.y;
// Find element at transformed position in source
this.source.style.pointerEvents = 'auto';
this.source.style.opacity = '0.001'; // Nearly invisible but still there
const element = document.elementFromPoint(sourceX, sourceY);
this.source.style.pointerEvents = 'none';
this.source.style.opacity = '0';
if (element && this.source.contains(element)) {
const newEvent = new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX: sourceX,
clientY: sourceY,
button: e.button,
buttons: e.buttons
});
element.dispatchEvent(newEvent);
// Update cursor
this.canvas.style.cursor = window.getComputedStyle(element).cursor;
}
};
// Mouse events
this.canvas.addEventListener('click', (e) => forwardEvent(e, 'click'));
this.canvas.addEventListener('mousedown', (e) => forwardEvent(e, 'mousedown'));
this.canvas.addEventListener('mouseup', (e) => forwardEvent(e, 'mouseup'));
this.canvas.addEventListener('mousemove', (e) => forwardEvent(e, 'mousemove'));
this.canvas.addEventListener('dblclick', (e) => forwardEvent(e, 'dblclick'));
this.canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
forwardEvent(e, 'contextmenu');
});
// Wheel events
this.canvas.addEventListener('wheel', (e) => {
const rect = this.canvas.getBoundingClientRect();
const canvasX = e.clientX - rect.left;
const canvasY = e.clientY - rect.top;
const sourceCoords = this.inverseDistort(canvasX, canvasY);
const sourceX = rect.left + sourceCoords.x;
const sourceY = rect.top + sourceCoords.y;
this.source.style.pointerEvents = 'auto';
const element = document.elementFromPoint(sourceX, sourceY);
this.source.style.pointerEvents = 'none';
if (element && this.source.contains(element)) {
element.dispatchEvent(new WheelEvent('wheel', {
bubbles: true,
clientX: sourceX,
clientY: sourceY,
deltaX: e.deltaX,
deltaY: e.deltaY,
deltaMode: e.deltaMode
}));
}
}, { passive: true });
// Keyboard events should go to focused elements naturally
// Focus management
this.canvas.addEventListener('mousedown', () => {
// Focus the terminal when clicking canvas
const terminal = this.source.querySelector('.xterm-helper-textarea');
if (terminal) {
terminal.focus();
}
});
}
/**
* Capture source element to texture using html2canvas
*/
async captureSource() {
const gl = this.gl;
// Use html2canvas to render the source element
try {
const canvas = await html2canvas(this.source, {
backgroundColor: null,
scale: window.devicePixelRatio || 1,
logging: false,
useCORS: true
});
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
} catch (err) {
console.error('Failed to capture source:', err);
}
}
/**
* Render frame
*/
render(time) {
const gl = this.gl;
gl.clear(gl.COLOR_BUFFER_BIT);
// Set uniforms
gl.uniform1i(this.uniforms.texture, 0);
gl.uniform2f(this.uniforms.resolution, this.canvas.width, this.canvas.height);
gl.uniform1f(this.uniforms.bulgeStrength, this.options.bulgeStrength);
gl.uniform1f(this.uniforms.scanlineIntensity, this.options.scanlineIntensity);
gl.uniform1f(this.uniforms.vignetteStrength, this.options.vignetteStrength);
gl.uniform1f(this.uniforms.time, time * 0.001);
// Draw
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
/**
* Start the render loop
*/
startRenderLoop() {
const frameInterval = 1000 / this.options.fps;
let lastCapture = 0;
const loop = async (time) => {
// Capture at reduced rate to save performance
if (time - lastCapture > frameInterval) {
await this.captureSource();
lastCapture = time;
}
this.render(time);
this.animationId = requestAnimationFrame(loop);
};
this.animationId = requestAnimationFrame(loop);
}
/**
* Stop rendering
*/
destroy() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
this.canvas.remove();
this.source.style.opacity = '1';
this.source.style.pointerEvents = 'auto';
}
}
// Export for use
window.CRTCanvasRenderer = CRTCanvasRenderer;

View File

@@ -30,6 +30,7 @@
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="crt-border-overlay"></div>
<div class="container">
<div class="terminal-header">
<div class="header-left">

BIN
website/screenborder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

View File

@@ -10,16 +10,31 @@ body {
color: #0f0;
overflow: hidden;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.crt-border-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 80px solid transparent;
border-image-source: url('screenborder.png');
border-image-slice: 200 95 fill;
border-image-width: 80px;
border-image-repeat: stretch;
pointer-events: none;
z-index: 9999;
}
.container {
width: 95vw;
max-width: 1400px;
height: 90vh;
background: #001800;
position: absolute;
top: 80px;
left: 80px;
right: 80px;
bottom:100px;
background: transparent;
border: 3px solid #0f0;
border-radius: 8px;
box-shadow:
@@ -28,10 +43,11 @@ body {
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 1;
}
.terminal-header {
background: #002200;
background: transparent;
border-bottom: 2px solid #0f0;
padding: 8px 16px;
display: flex;
@@ -99,8 +115,30 @@ body {
cursor: text;
}
/* Inline terminal input */
.terminal-inline-input {
position: absolute;
background: transparent;
border: none;
outline: none;
color: #0f0;
font-family: 'Courier New', Courier, monospace;
font-size: inherit;
padding: 0;
margin: 0;
caret-color: #0f0;
text-shadow: 0 0 3px #0f0;
z-index: 10;
min-width: 50%;
max-width: 80%;
}
.terminal-inline-input::placeholder {
color: rgba(0, 255, 0, 0.3);
}
.status-bar {
background: #002200;
background: transparent;
border-top: 2px solid #0f0;
padding: 6px 16px;
display: flex;
@@ -117,7 +155,7 @@ body {
opacity: 0.8;
}
/* CRT screen effect */
/* CRT scanlines effect */
.container::before {
content: " ";
display: block;

View File

@@ -1,16 +1,25 @@
// Version
const VERSION = '1.0.0';
// Create inline input element
const inlineInput = document.createElement('input');
inlineInput.type = 'text';
inlineInput.className = 'terminal-inline-input';
inlineInput.autocomplete = 'off';
inlineInput.autocorrect = 'on';
inlineInput.autocapitalize = 'off';
inlineInput.spellcheck = false;
// Initialize xterm.js terminal
const term = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
cursorBlink: false,
cursorStyle: 'bar',
fontFamily: '"Courier New", Courier, monospace',
fontSize: 14,
theme: {
background: '#001800',
foreground: '#00ff00',
cursor: '#00ff00',
cursor: 'transparent',
cursorAccent: '#001800',
selection: 'rgba(0, 255, 0, 0.3)',
black: '#000000',
@@ -31,7 +40,8 @@ const term = new Terminal({
brightWhite: '#ffffff'
},
allowTransparency: true,
scrollback: 1000
scrollback: 1000,
disableStdin: true
});
// Add addons
@@ -211,7 +221,8 @@ async function enterChatMode() {
// Add separator and initial prompt
term.writeln('');
term.writeln('─'.repeat(term.cols || 60));
term.write(`\x1b[1;32m>\x1b[0m `);
term.write('\x1b[1;32m>\x1b[0m ');
showInlineInput();
}
// Exit chat mode
@@ -224,7 +235,8 @@ function exitChatMode() {
chatMode.displayNames = {}; // Clear display name cache
term.clear();
term.writeln(' Exited chat mode.\r\n');
term.write(prompt);
term.write(promptColored);
showInlineInput();
}
// Sync messages from Matrix
@@ -889,238 +901,212 @@ function getWelcomeBanner() {
}
}
// Prompt
const prompt = '\r\n\x1b[1;32muser@lit.ruv.wtf\x1b[0m $ ';
// Prompt (plain version for display, we handle colors separately)
const promptText = 'user@lit.ruv.wtf $ ';
const promptColored = '\r\n\x1b[1;32muser@lit.ruv.wtf\x1b[0m $ ';
// Initialize terminal
function init() {
term.writeln(getWelcomeBanner());
term.write(prompt);
/**
* Position the inline input at the cursor location
*/
function positionInlineInput() {
const terminalEl = document.getElementById('terminal');
const xtermEl = terminalEl.querySelector('.xterm-screen');
// Handle input
term.onData(data => {
handleInput(data);
});
if (!xtermEl) return;
// Get terminal dimensions
const charWidth = term._core._renderService.dimensions.css.cell.width;
const charHeight = term._core._renderService.dimensions.css.cell.height;
// Get cursor position (rows from bottom for scrollback)
const cursorY = term.buffer.active.cursorY;
const cursorX = term.buffer.active.cursorX;
// Position input
inlineInput.style.left = (cursorX * charWidth) + 'px';
inlineInput.style.top = (cursorY * charHeight) + 'px';
inlineInput.style.height = charHeight + 'px';
inlineInput.style.fontSize = term.options.fontSize + 'px';
inlineInput.style.lineHeight = charHeight + 'px';
}
// Handle terminal input
async function handleInput(data) {
const code = data.charCodeAt(0);
/**
* Show the inline input and position it
*/
function showInlineInput() {
const terminalEl = document.getElementById('terminal');
const xtermEl = terminalEl.querySelector('.xterm-screen');
// Chat mode input handling
if (!xtermEl.contains(inlineInput)) {
xtermEl.appendChild(inlineInput);
}
inlineInput.style.display = 'block';
positionInlineInput();
inlineInput.focus();
}
/**
* Hide the inline input
*/
function hideInlineInput() {
inlineInput.style.display = 'none';
inlineInput.value = '';
}
/**
* Submit current input
*/
async function submitInlineInput() {
const cmd = inlineInput.value.trim();
const rawValue = inlineInput.value;
inlineInput.value = '';
// Handle chat mode
if (chatMode.active) {
if (code === 13) { // Enter
const msg = chatMode.inputLine.trim();
chatMode.inputLine = '';
// Check for quit command
if (msg === '/quit' || msg === '/exit') {
exitChatMode();
return;
}
// Check for help command
if (msg === '/help') {
// Insert help above separator
term.write('\x1b[1A\x1b[2K\r'); // Move up 1 line, clear
term.writeln('\x1b[33mChat Commands:\x1b[0m');
term.writeln(' /help - Show this help message');
term.writeln(' /nick [name] - Change your display name');
term.writeln(' /quit - Exit chat mode');
term.writeln('─'.repeat(term.cols || 60));
term.write(`\x1b[1;32m>\x1b[0m `);
return;
}
// Check for nick command
if (msg.startsWith('/nick ')) {
const newNick = msg.substring(6).trim();
term.write('\x1b[1A\x1b[2K\r'); // Move up 1 line, clear
if (!newNick) {
term.writeln('\x1b[31mError: /nick [name]\x1b[0m');
} else {
try {
// Check if nickname is already taken
const isTaken = await isDisplayNameTaken(newNick);
if (isTaken) {
term.writeln('\x1b[31mError: Nickname already in use\x1b[0m');
if (cmd === '/quit' || cmd === '/exit') {
hideInlineInput();
exitChatMode();
return;
}
// Write what user typed
term.write(rawValue + '\r\n');
if (cmd === '/help') {
term.writeln('\x1b[33mChat Commands:\x1b[0m');
term.writeln(' /help - Show this help message');
term.writeln(' /nick [name] - Change your display name');
term.writeln(' /quit - Exit chat mode');
term.writeln('─'.repeat(term.cols || 60));
term.write('\x1b[1;32m>\x1b[0m ');
positionInlineInput();
return;
}
if (cmd.startsWith('/nick ')) {
const newNick = cmd.substring(6).trim();
if (!newNick) {
term.writeln('\x1b[31mError: /nick [name]\x1b[0m');
} else {
try {
const isTaken = await isDisplayNameTaken(newNick);
if (isTaken) {
term.writeln('\x1b[31mError: Nickname already in use\x1b[0m');
} else {
const encodedUserId = encodeURIComponent(window.matrixSession.userId);
const putResponse = await matrixApi(`/profile/${encodedUserId}/displayname`, 'PUT', {
displayname: newNick
});
if (putResponse && putResponse.errcode) {
term.writeln(`\x1b[31mError: ${putResponse.error || 'Nickname rejected by server'}\x1b[0m`);
} else {
// Attempt to change nickname
const encodedUserId = encodeURIComponent(window.matrixSession.userId);
const putResponse = await matrixApi(`/profile/${encodedUserId}/displayname`, 'PUT', {
displayname: newNick
});
const verifyData = await matrixApi(`/profile/${encodedUserId}/displayname`, 'GET');
const actualName = verifyData && verifyData.displayname;
// Check if PUT request returned an error
if (putResponse && putResponse.errcode) {
term.writeln(`\x1b[31mError: ${putResponse.error || 'Nickname rejected by server'}\x1b[0m`);
if (actualName === newNick) {
chatMode.displayNames[window.matrixSession.userId] = newNick;
term.writeln(`\x1b[32mNickname changed to: ${newNick}\x1b[0m`);
} else {
// Verify the change was accepted by fetching it back
const verifyData = await matrixApi(`/profile/${encodedUserId}/displayname`, 'GET');
const actualName = verifyData && verifyData.displayname;
if (actualName === newNick) {
// Success - update local cache
chatMode.displayNames[window.matrixSession.userId] = newNick;
term.writeln(`\x1b[32mNickname changed to: ${newNick}\x1b[0m`);
} else {
// Backend silently changed or rejected it
if (actualName) {
term.writeln(`\x1b[31mError: Server changed nickname to: ${actualName}\x1b[0m`);
} else {
term.writeln(`\x1b[31mError: Nickname rejected by server (invalid format)\x1b[0m`);
}
}
term.writeln(`\x1b[31mError: Nickname rejected by server\x1b[0m`);
}
}
} catch (error) {
term.writeln(`\x1b[31mError: Failed to change nickname - ${error.message}\x1b[0m`);
}
} catch (error) {
term.writeln(`\x1b[31mError: Failed to change nickname\x1b[0m`);
}
term.writeln('─'.repeat(term.cols || 60));
term.write(`\x1b[1;32m>\x1b[0m `);
return;
}
// Send message
if (msg && !msg.startsWith('/')) {
await sendChatMessage(msg);
} else if (msg.startsWith('/')) {
term.write('\x1b[1A\x1b[2K\r'); // Move up 1 line, clear
term.writeln(`\x1b[31mUnknown command: ${msg.split(' ')[0]}. Type /help\x1b[0m`);
term.writeln('─'.repeat(term.cols || 60));
term.write(`\x1b[1;32m>\x1b[0m `);
} else {
// Empty input, just redraw prompt
renderChatPrompt();
}
} else if (code === 127) { // Backspace
if (chatMode.inputLine.length > 0) {
chatMode.inputLine = chatMode.inputLine.slice(0, -1);
term.write('\b \b'); // Move back, write space, move back again
}
} else if (code === 4) { // Ctrl+D
exitChatMode();
} else if (code >= 32 && code < 127) { // Printable characters
chatMode.inputLine += data;
term.write(data); // Just write the character directly
term.writeln('─'.repeat(term.cols || 60));
term.write('\x1b[1;32m>\x1b[0m ');
positionInlineInput();
return;
}
if (cmd && !cmd.startsWith('/')) {
await sendChatMessage(cmd);
positionInlineInput();
} else if (cmd.startsWith('/')) {
term.writeln(`\x1b[31mUnknown command: ${cmd.split(' ')[0]}. Type /help\x1b[0m`);
term.writeln('─'.repeat(term.cols || 60));
term.write('\x1b[1;32m>\x1b[0m ');
positionInlineInput();
} else {
term.write('\x1b[1;32m>\x1b[0m ');
positionInlineInput();
}
return;
}
// Normal mode input handling
// Handle special keys
if (code === 13) { // Enter
term.write('\r\n');
await executeCommand(currentLine.trim());
currentLine = '';
cursorPosition = 0;
// Only show prompt if not in chat mode (chat command enters chat mode)
if (!chatMode.active) {
term.write(prompt);
}
} else if (code === 127) { // Backspace
if (cursorPosition > 0) {
currentLine = currentLine.slice(0, cursorPosition - 1) + currentLine.slice(cursorPosition);
cursorPosition--;
term.write('\b \b');
// Redraw rest of line if needed
if (cursorPosition < currentLine.length) {
const remaining = currentLine.slice(cursorPosition);
term.write(remaining + ' ');
for (let i = 0; i <= remaining.length; i++) {
term.write('\b');
// Normal command mode
term.write(rawValue + '\r\n');
if (cmd) {
await executeCommand(cmd);
}
// Show prompt if not in chat mode
if (!chatMode.active) {
term.write(promptColored);
showInlineInput();
}
term.scrollToBottom();
}
// Initialize terminal
function init() {
term.writeln(getWelcomeBanner());
term.write(promptColored);
// Add inline input after a short delay to ensure terminal is rendered
setTimeout(() => {
showInlineInput();
}, 100);
// Handle inline input events
inlineInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitInlineInput();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (commandHistory.length > 0) {
if (historyIndex === -1 || historyIndex >= commandHistory.length) {
historyIndex = commandHistory.length - 1;
} else if (historyIndex > 0) {
historyIndex--;
}
inlineInput.value = commandHistory[historyIndex] || '';
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex < commandHistory.length - 1) {
historyIndex++;
inlineInput.value = commandHistory[historyIndex] || '';
} else {
historyIndex = commandHistory.length;
inlineInput.value = '';
}
}
} else if (code === 27) { // Escape sequences (arrows)
if (data === '\x1b[A') { // Up arrow
navigateHistory(-1);
} else if (data === '\x1b[B') { // Down arrow
navigateHistory(1);
} else if (data === '\x1b[C') { // Right arrow
if (cursorPosition < currentLine.length) {
cursorPosition++;
term.write('\x1b[C');
}
} else if (data === '\x1b[D') { // Left arrow
if (cursorPosition > 0) {
cursorPosition--;
term.write('\x1b[D');
}
} else if (data === '\x1b[H') { // Home
while (cursorPosition > 0) {
cursorPosition--;
term.write('\x1b[D');
}
} else if (data === '\x1b[F') { // End
while (cursorPosition < currentLine.length) {
cursorPosition++;
term.write('\x1b[C');
}
}
} else if (code === 3) { // Ctrl+C
term.write('^C\r\n');
currentLine = '';
cursorPosition = 0;
term.write(prompt);
} else if (code === 12) { // Ctrl+L
term.clear();
term.write(prompt + currentLine);
const backspaces = currentLine.length - cursorPosition;
for (let i = 0; i < backspaces; i++) {
term.write('\b');
}
} else if (code >= 32 && code < 127) { // Printable characters
currentLine = currentLine.slice(0, cursorPosition) + data + currentLine.slice(cursorPosition);
cursorPosition++;
term.write(data);
// Redraw rest of line if in middle
if (cursorPosition < currentLine.length) {
const remaining = currentLine.slice(cursorPosition);
term.write(remaining);
for (let i = 0; i < remaining.length; i++) {
term.write('\b');
}
}
}
}
// Navigate command history
function navigateHistory(direction) {
if (commandHistory.length === 0) return;
});
historyIndex += direction;
// Click on terminal focuses input
document.getElementById('terminal').addEventListener('click', () => {
if (inlineInput.style.display !== 'none') {
inlineInput.focus();
}
});
if (historyIndex < 0) {
historyIndex = -1;
clearCurrentLine();
currentLine = '';
cursorPosition = 0;
} else if (historyIndex >= commandHistory.length) {
historyIndex = commandHistory.length - 1;
} else {
clearCurrentLine();
currentLine = commandHistory[historyIndex];
cursorPosition = currentLine.length;
term.write(currentLine);
}
}
// Clear current line
function clearCurrentLine() {
// Move to start of input
for (let i = 0; i < cursorPosition; i++) {
term.write('\b');
}
// Clear line
for (let i = 0; i < currentLine.length; i++) {
term.write(' ');
}
// Move back to start
for (let i = 0; i < currentLine.length; i++) {
term.write('\b');
}
// Reposition on resize
window.addEventListener('resize', () => {
setTimeout(positionInlineInput, 50);
});
// Handle scroll to keep input positioned
term.onScroll(() => {
positionInlineInput();
});
}
// Execute command