Full-stack implementation: FastAPI backend with PAM auth, WebSocket stats/terminal, and vanilla JS frontend with tiling desktop shell. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
290 lines
9.8 KiB
JavaScript
290 lines
9.8 KiB
JavaScript
/* Atlus — Custom on-screen keyboard for terminal */
|
||
(function () {
|
||
'use strict';
|
||
|
||
const MODES = {
|
||
keys: 'Keys',
|
||
fn: 'Fn',
|
||
nav: 'Nav',
|
||
sym: 'Sym',
|
||
};
|
||
|
||
const QUICK_KEYS = ['$', '#', '>', '-', '_', '.', '/', '|', '~'];
|
||
|
||
const QWERTY_ROWS = [
|
||
['1','2','3','4','5','6','7','8','9','0'],
|
||
['q','w','e','r','t','y','u','i','o','p'],
|
||
['a','s','d','f','g','h','j','k','l'],
|
||
['z','x','c','v','b','n','m'],
|
||
];
|
||
|
||
const SYM_ROWS = [
|
||
['!','@','#','$','%','^','&','*','(',')'],
|
||
['-','_','=','+','[',']','{','}','\\','|'],
|
||
[';',':','\'','"',',','.','/','?','`','~'],
|
||
['<','>','©','®','…','§','±','×'],
|
||
];
|
||
|
||
const FN_KEYS = ['F1','F2','F3','F4','F5','F6','F7','F8','F9','F10','F11','F12'];
|
||
|
||
const NAV_KEYS = [
|
||
{label: '↑', seq: '\x1b[A'},
|
||
{label: '↓', seq: '\x1b[B'},
|
||
{label: '←', seq: '\x1b[D'},
|
||
{label: '→', seq: '\x1b[C'},
|
||
{label: 'Home', seq: '\x1b[H'},
|
||
{label: 'End', seq: '\x1b[F'},
|
||
{label: 'PgUp', seq: '\x1b[5~'},
|
||
{label: 'PgDn', seq: '\x1b[6~'},
|
||
{label: 'Del', seq: '\x1b[3~'},
|
||
{label: 'Ins', seq: '\x1b[2~'},
|
||
];
|
||
|
||
class OnScreenKeyboard {
|
||
constructor() {
|
||
this.el = null;
|
||
this.terminal = null; // set by terminal app
|
||
this.mode = 'keys';
|
||
this.shift = false;
|
||
this.ctrl = false;
|
||
this.alt = false;
|
||
// States: null=off, 'armed'=one-shot, 'locked'=persistent
|
||
this.ctrlState = null;
|
||
this.altState = null;
|
||
this.shiftState = null;
|
||
this.visible = true;
|
||
}
|
||
|
||
create() {
|
||
this.el = document.createElement('div');
|
||
this.el.className = 'osk';
|
||
this._render();
|
||
return this.el;
|
||
}
|
||
|
||
toggle() {
|
||
this.visible = !this.visible;
|
||
if (this.el) this.el.classList.toggle('hidden', !this.visible);
|
||
}
|
||
|
||
setTerminal(term) {
|
||
this.terminal = term;
|
||
}
|
||
|
||
_send(data) {
|
||
if (!this.terminal) return;
|
||
|
||
let out = data;
|
||
|
||
// Apply modifiers
|
||
if (this.ctrlState && data.length === 1) {
|
||
const code = data.toLowerCase().charCodeAt(0);
|
||
if (code >= 97 && code <= 122) {
|
||
out = String.fromCharCode(code - 96);
|
||
}
|
||
} else if (this.altState && data.length === 1) {
|
||
out = '\x1b' + data;
|
||
}
|
||
|
||
if (this.shiftState && data.length === 1 && data >= 'a' && data <= 'z') {
|
||
out = data.toUpperCase();
|
||
}
|
||
|
||
this.terminal.input(out);
|
||
|
||
// Clear armed (one-shot) modifiers
|
||
if (this.ctrlState === 'armed') { this.ctrlState = null; }
|
||
if (this.altState === 'armed') { this.altState = null; }
|
||
if (this.shiftState === 'armed') { this.shiftState = null; }
|
||
this._updateModifiers();
|
||
}
|
||
|
||
_toggleMod(mod) {
|
||
const key = mod + 'State';
|
||
if (this[key] === null) {
|
||
this[key] = 'armed';
|
||
} else if (this[key] === 'armed') {
|
||
this[key] = 'locked';
|
||
} else {
|
||
this[key] = null;
|
||
}
|
||
this._updateModifiers();
|
||
}
|
||
|
||
_updateModifiers() {
|
||
if (!this.el) return;
|
||
this.el.querySelectorAll('.osk-key.mod').forEach(key => {
|
||
const mod = key.dataset.mod;
|
||
if (!mod) return;
|
||
const state = this[mod + 'State'];
|
||
key.classList.toggle('armed', state === 'armed');
|
||
key.classList.toggle('locked', state === 'locked');
|
||
});
|
||
}
|
||
|
||
_render() {
|
||
this.el.innerHTML = '';
|
||
|
||
// Quick row
|
||
const quick = document.createElement('div');
|
||
quick.className = 'osk-quick';
|
||
QUICK_KEYS.forEach(k => {
|
||
const btn = this._makeKey(k, () => this._send(k));
|
||
quick.appendChild(btn);
|
||
});
|
||
this.el.appendChild(quick);
|
||
|
||
// Mode tabs
|
||
const modes = document.createElement('div');
|
||
modes.className = 'osk-modes';
|
||
for (const [id, label] of Object.entries(MODES)) {
|
||
const tab = document.createElement('button');
|
||
tab.className = 'osk-mode-tab' + (id === this.mode ? ' active' : '');
|
||
tab.textContent = label;
|
||
tab.addEventListener('click', () => {
|
||
this.mode = id;
|
||
this._render();
|
||
});
|
||
modes.appendChild(tab);
|
||
}
|
||
this.el.appendChild(modes);
|
||
|
||
// Mode content
|
||
if (this.mode === 'keys') this._renderQwerty();
|
||
else if (this.mode === 'fn') this._renderFn();
|
||
else if (this.mode === 'nav') this._renderNav();
|
||
else if (this.mode === 'sym') this._renderSym();
|
||
}
|
||
|
||
_makeKey(label, handler, className = '') {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'osk-key ' + className;
|
||
btn.textContent = label;
|
||
btn.addEventListener('touchstart', (e) => {
|
||
e.preventDefault();
|
||
handler();
|
||
});
|
||
btn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
handler();
|
||
});
|
||
return btn;
|
||
}
|
||
|
||
_renderQwerty() {
|
||
// Modifier row
|
||
const modRow = document.createElement('div');
|
||
modRow.className = 'osk-row';
|
||
modRow.appendChild(this._makeKey('Esc', () => this._send('\x1b'), 'mod'));
|
||
modRow.appendChild(this._makeKey('Tab', () => this._send('\t'), 'mod'));
|
||
|
||
const ctrlKey = this._makeKey('Ctrl', () => this._toggleMod('ctrl'), 'mod');
|
||
ctrlKey.dataset.mod = 'ctrl';
|
||
modRow.appendChild(ctrlKey);
|
||
|
||
const altKey = this._makeKey('Alt', () => this._toggleMod('alt'), 'mod');
|
||
altKey.dataset.mod = 'alt';
|
||
modRow.appendChild(altKey);
|
||
|
||
this.el.appendChild(modRow);
|
||
|
||
// QWERTY rows
|
||
QWERTY_ROWS.forEach((row, i) => {
|
||
const rowEl = document.createElement('div');
|
||
rowEl.className = 'osk-row';
|
||
|
||
if (i === 3) {
|
||
// Shift key
|
||
const shiftKey = this._makeKey('Shift', () => this._toggleMod('shift'), 'mod wide');
|
||
shiftKey.dataset.mod = 'shift';
|
||
rowEl.appendChild(shiftKey);
|
||
}
|
||
|
||
row.forEach(k => {
|
||
rowEl.appendChild(this._makeKey(k, () => this._send(k)));
|
||
});
|
||
|
||
if (i === 3) {
|
||
rowEl.appendChild(this._makeKey('⌫', () => this._send('\x7f'), 'wide'));
|
||
}
|
||
|
||
this.el.appendChild(rowEl);
|
||
});
|
||
|
||
// Space row
|
||
const spaceRow = document.createElement('div');
|
||
spaceRow.className = 'osk-row';
|
||
spaceRow.appendChild(this._makeKey('Paste', () => this._paste(), 'mod'));
|
||
spaceRow.appendChild(this._makeKey('Space', () => this._send(' '), 'space'));
|
||
spaceRow.appendChild(this._makeKey('Enter', () => this._send('\r'), 'wide'));
|
||
this.el.appendChild(spaceRow);
|
||
|
||
this._updateModifiers();
|
||
}
|
||
|
||
_renderSym() {
|
||
SYM_ROWS.forEach(row => {
|
||
const rowEl = document.createElement('div');
|
||
rowEl.className = 'osk-row';
|
||
row.forEach(k => {
|
||
rowEl.appendChild(this._makeKey(k, () => this._send(k)));
|
||
});
|
||
this.el.appendChild(rowEl);
|
||
});
|
||
}
|
||
|
||
_renderFn() {
|
||
const scroll = document.createElement('div');
|
||
scroll.className = 'osk-fn-scroll';
|
||
FN_KEYS.forEach((k, i) => {
|
||
const seq = `\x1bO${String.fromCharCode(80 + i)}`;
|
||
// F1-F4 use \x1bOP-S, F5+ use \x1b[15~, etc.
|
||
const seqs = [
|
||
'\x1bOP','\x1bOQ','\x1bOR','\x1bOS',
|
||
'\x1b[15~','\x1b[17~','\x1b[18~','\x1b[19~',
|
||
'\x1b[20~','\x1b[21~','\x1b[23~','\x1b[24~',
|
||
];
|
||
scroll.appendChild(this._makeKey(k, () => this._send(seqs[i])));
|
||
});
|
||
this.el.appendChild(scroll);
|
||
|
||
// Common ctrl combos
|
||
const combos = document.createElement('div');
|
||
combos.className = 'osk-row';
|
||
const ctrlKeys = [
|
||
{label: 'Ctrl+C', seq: '\x03'},
|
||
{label: 'Ctrl+D', seq: '\x04'},
|
||
{label: 'Ctrl+Z', seq: '\x1a'},
|
||
{label: 'Ctrl+A', seq: '\x01'},
|
||
{label: 'Ctrl+L', seq: '\x0c'},
|
||
{label: 'Ctrl+R', seq: '\x12'},
|
||
];
|
||
ctrlKeys.forEach(k => {
|
||
combos.appendChild(this._makeKey(k.label, () => this._send(k.seq), 'mod'));
|
||
});
|
||
this.el.appendChild(combos);
|
||
}
|
||
|
||
_renderNav() {
|
||
const nav = document.createElement('div');
|
||
nav.className = 'osk-nav';
|
||
NAV_KEYS.forEach(k => {
|
||
nav.appendChild(this._makeKey(k.label, () => this._send(k.seq)));
|
||
});
|
||
this.el.appendChild(nav);
|
||
}
|
||
|
||
async _paste() {
|
||
try {
|
||
const text = await navigator.clipboard.readText();
|
||
if (text && this.terminal) {
|
||
this.terminal.input(text);
|
||
}
|
||
} catch (e) {
|
||
// Clipboard access denied
|
||
}
|
||
}
|
||
}
|
||
|
||
window.Atlus.keyboard = new OnScreenKeyboard();
|
||
})();
|