atlus/frontend/js/keyboard.js
roberts 342dc0f0cf Fix robustness issues across backend and frontend
- Add shutil.which guard to _run() in settings, asi_bridge routers
- Catch RuntimeError on WebSocket disconnect in services, asi_bridge
- Make file listing resilient to individual entry errors
- Fix keyboard double-fire on touch devices (touchstart + click)
- Update install.sh with correct Gitea repo URL
- Add six to requirements.txt (python-pam dependency)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:41:43 -05:00

293 lines
9.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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;
let touchFired = false;
btn.addEventListener('touchstart', (e) => {
e.preventDefault();
touchFired = true;
handler();
});
btn.addEventListener('click', (e) => {
e.preventDefault();
if (touchFired) { touchFired = false; return; }
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();
})();