Merge remote-tracking branch 'origin/work'

pull/2/head
Ondřej Hruška 7 years ago
commit d59c0e876a
Signed by: MightyPork
GPG Key ID: 2C5FD5035250423D
  1. 4
      .eslintrc
  2. 24
      _build_js.sh
  3. 6
      _debug_replacements.php
  4. 2
      base.php
  5. 2
      dump_js_lang.php
  6. 52
      js/appcommon.js
  7. 16
      js/index.js
  8. 2
      js/lang.js
  9. 2
      js/lib/chibi.js
  10. 311
      js/lib/keymaster.js
  11. 74
      js/modal.js
  12. 106
      js/notif.js
  13. 190
      js/term/connection.js
  14. 78
      js/term/debug_screen.js
  15. 177
      js/term/demo.js
  16. 56
      js/term/index.js
  17. 468
      js/term/input.js
  18. 578
      js/term/screen.js
  19. 269
      js/term/screen_parser.js
  20. 690
      js/term/screen_renderer.js
  21. 83
      js/term/soft_keyboard.js
  22. 111
      js/term/themes.js
  23. 18
      js/term/upload.js
  24. 144
      js/term_conn.js
  25. 303
      js/term_input.js
  26. 1357
      js/term_screen.js
  27. 51
      js/utils.js
  28. 18
      js/wifi.js
  29. 93
      lang/en.php
  30. 9
      package.json
  31. 5
      pages/_cfg_menu.php
  32. 2
      pages/_head.php
  33. 34
      pages/about.php
  34. 8
      pages/cfg_network.php
  35. 98
      pages/cfg_system.php
  36. 325
      pages/cfg_term.php
  37. 8
      pages/cfg_wifi.php
  38. 3
      pages/help.php
  39. 105
      pages/help/sgr_colors.php
  40. 44
      pages/term.php
  41. 6
      sass/form/_form_elements.scss
  42. 14
      sass/form/_form_layout.scss
  43. 2
      sass/pages/_about.scss
  44. 130
      sass/pages/_term.scss
  45. 41
      webpack.config.js
  46. 892
      yarn.lock

@ -126,7 +126,7 @@
"no-this-before-super": "error",
"no-throw-literal": "error",
"no-trailing-spaces": "off",
"no-undef": "off",
"no-undef": "warn",
"no-undef-init": "error",
"no-unexpected-multiline": "error",
"no-unmodified-loop-condition": "error",
@ -135,7 +135,7 @@
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unused-expressions": ["warn", { "allowShortCircuit": true, "allowTernary": true, "allowTaggedTemplates": true }],
"no-unused-vars": ["off", { "vars": "local", "args": "none", "ignoreRestSiblings": true }],
"no-unused-vars": ["warn", { "vars": "local", "args": "none", "ignoreRestSiblings": true }],
"no-use-before-define": ["error", { "functions": false, "classes": false, "variables": false }],
"no-useless-call": "error",
"no-useless-computed-key": "error",

@ -6,26 +6,4 @@ echo 'Generating lang.js...'
php ./dump_js_lang.php
echo 'Processing JS...'
if [[ $ESP_PROD ]]; then
smarg=
demofile=
else
smarg=--source-maps
demofile=js/demo.js
fi
npm run babel -- -o "out/js/app.$FRONT_END_HASH.js" ${smarg} js/lib \
js/lib/chibi.js \
js/lib/keymaster.js \
js/lib/polyfills.js \
js/utils.js \
js/modal.js \
js/notif.js \
js/appcommon.js \
$demofile \
js/lang.js \
js/wifi.js \
js/term_* \
js/debug_screen.js \
js/soft_keyboard.js \
js/term.js
npm run webpack

@ -57,13 +57,13 @@ return [
'vers_fw' => $vers,
'date' => date('Y-m-d'),
'time' => date('G:i'),
'time' => date('G:i')." ".TIMEZONE,
'vers_httpd' => '0.4',
'vers_sdk' => '010502',
'githubrepo' => 'https://github.com/espterm/espterm-firmware',
'githubrepo_front' => 'https://github.com/espterm/espterm-front-end',
'hash_backend' => $fwHash,
'hash_frontend' => GIT_HASH, // TODO actual versions?
'hash_frontend' => GIT_HASH,
'ap_dhcp_time' => '120',
'ap_dhcp_start' => '192.168.4.100',
@ -91,4 +91,6 @@ return [
'uart_parity' => 2,
'theme' => 0,
'pwlock' => 0,
'access_name' => 'espterm',
];

@ -14,6 +14,8 @@ if (!empty($argv[1])) {
define('GIT_HASH', trim(shell_exec('git rev-parse --short HEAD')));
define('TIMEZONE', trim(shell_exec('date +%Z'))); // for replacements
$prod = defined('STDIN');
define('DEBUG', !$prod);

@ -18,5 +18,5 @@ foreach ($selected as $key) {
file_put_contents(__DIR__. '/js/lang.js',
"// Generated from PHP locale file\n" .
'let _tr = ' . json_encode($out, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) . ";\n\n" .
"function tr (key) { return _tr[key] || '?' + key + '?' }\n"
"module.exports = function tr (key) { return _tr[key] || '?' + key + '?' }\n"
);

@ -1,5 +1,18 @@
const $ = require('./lib/chibi')
const { mk, qs, qsa, cr } = require('./utils')
const modal = require('./modal')
const notify = require('./notif')
/** Global generic init */
$.ready(function () {
// Opening menu on mobile / narrow screen
function menuOpen () {
$('#menu').toggleClass('expanded')
}
$('#brand')
.on('click', menuOpen)
.on('keypress', cr(menuOpen))
// Checkbox UI (checkbox CSS and hidden input with int value)
$('.Row.checkbox').forEach(function (x) {
let inp = x.querySelector('input')
@ -58,8 +71,8 @@ $.ready(function () {
val -= step
}
if (undef(min)) val = Math.max(val, +min)
if (undef(max)) val = Math.min(val, +max)
if (!Number.isFinite(min)) val = Math.max(val, +min)
if (!Number.isFinite(max)) val = Math.min(val, +max)
$this.val(val)
if ('createEvent' in document) {
@ -75,15 +88,16 @@ $.ready(function () {
// populate the form errors box from GET arg ?err=...
// (a way to pass errors back from server via redirect)
let errAt = location.search.indexOf('err=')
let errAt = window.location.search.indexOf('err=')
if (errAt !== -1 && qs('.Box.errors')) {
let errs = location.search.substr(errAt + 4).split(',')
let errs = window.location.search.substr(errAt + 4).split(',')
let humanReadableErrors = []
errs.forEach(function (er) {
let lbl = qs('label[for="' + er + '"]')
if (lbl) {
let lbls = qsa('label[for="' + er + '"]')
for (let i = 0; i < lbls.length; i++) {
let lbl = lbls[i]
lbl.classList.add('error')
humanReadableErrors.push(lbl.childNodes[0].textContent.trim().replace(/: ?$/, ''))
if (i === 0) humanReadableErrors.push(lbl.childNodes[0].textContent.trim().replace(/: ?$/, ''))
}
// else {
// hres.push(er)
@ -94,8 +108,8 @@ $.ready(function () {
qs('.Box.errors').classList.remove('hidden')
}
Modal.init()
Notify.init()
modal.init()
notify.init()
// remove tabindices from h2 if wide
if (window.innerWidth > 550) {
@ -106,7 +120,7 @@ $.ready(function () {
// brand works as a link back to term in widescreen mode
let br = qs('#brand')
br && br.addEventListener('click', function () {
location.href = '/' // go to terminal
window.location.href = '/' // go to terminal
})
}
})
@ -116,14 +130,30 @@ $._loader = function (vis) {
$('#loader').toggleClass('show', vis)
}
let pageShown = false
// reveal content on load
function showPage () {
pageShown = true
$('#content').addClass('load')
}
// HACKITY HACK: fix this later
window.showPage = showPage
// Auto reveal pages other than the terminal (sets window.noAutoShow)
$.ready(function () {
if (window.noAutoShow !== true) {
if (window.noAutoShow === true) {
setTimeout(function () {
if (!pageShown) {
let bnr = mk('P')
bnr.id = 'load-failed'
bnr.innerHTML =
'Server connection failed! Trying again' +
'<span class="anim-dots" style="width:1.5em;text-align:left;display:inline-block">.</span>'
qs('#screen').appendChild(bnr)
showPage()
}
}, 2000)
} else {
setTimeout(function () {
showPage()
}, 1)

@ -0,0 +1,16 @@
require('./lib/polyfills')
require('./modal')
require('./notif')
require('./appcommon')
try { require('./term/demo') } catch (err) {}
require('./wifi')
const $ = require('./lib/chibi')
const { qs } = require('./utils')
/* Export stuff to the global scope for inline scripts */
window.termInit = require('./term')
window.$ = $
window.qs = qs
window.themes = require('./term/themes')

@ -5,4 +5,4 @@ let _tr = {
"wifi.enter_passwd": "Enter password for \":ssid:\""
};
function tr (key) { return _tr[key] || '?' + key + '?' }
module.exports = function tr (key) { return _tr[key] || '?' + key + '?' }

@ -699,5 +699,5 @@
};
// Set Chibi's global namespace here ($)
w.$ = chibi;
module.exports = chibi;
}());

@ -1,311 +0,0 @@
// keymaster.js
// (c) 2011-2013 Thomas Fuchs
// keymaster.js may be freely distributed under the MIT license.
;(function(global){
var k,
_handlers = {},
_mods = { 16: false, 18: false, 17: false, 91: false },
_scope = 'all',
// modifier keys
_MODIFIERS = {
'⇧': 16, shift: 16,
'⌥': 18, alt: 18, option: 18,
'⌃': 17, ctrl: 17, control: 17,
'⌘': 91, command: 91
},
// special keys
_MAP = {
backspace: 8, tab: 9, clear: 12,
enter: 13, 'return': 13,
esc: 27, escape: 27, space: 32,
left: 37, up: 38,
right: 39, down: 40,
del: 46, 'delete': 46,
home: 36, end: 35,
pageup: 33, pagedown: 34,
',': 188, '.': 190, '/': 191,
'`': 192, '-': 189, '=': 187,
';': 186, '\'': 222,
'[': 219, ']': 221, '\\': 220,
// added:
insert: 45,
np_0: 96, np_1: 97, np_2: 98, np_3: 99, np_4: 100, np_5: 101,
np_6: 102, np_7: 103, np_8: 104, np_9: 105, np_mul: 106,
np_add: 107, np_sub: 109, np_point: 110, np_div: 111, numlock: 144,
},
code = function(x){
return _MAP[x] || x.toUpperCase().charCodeAt(0);
},
_downKeys = [];
for(k=1;k<20;k++) _MAP['f'+k] = 111+k;
// IE doesn't support Array#indexOf, so have a simple replacement
function index(array, item){
var i = array.length;
while(i--) if(array[i]===item) return i;
return -1;
}
// for comparing mods before unassignment
function compareArray(a1, a2) {
if (a1.length != a2.length) return false;
for (var i = 0; i < a1.length; i++) {
if (a1[i] !== a2[i]) return false;
}
return true;
}
var modifierMap = {
16:'shiftKey',
18:'altKey',
17:'ctrlKey',
91:'metaKey'
};
function updateModifierKey(event) {
for(k in _mods) _mods[k] = event[modifierMap[k]];
};
function isModifierPressed(mod) {
if (mod=='control'||mod=='ctrl') return _mods[17];
if (mod=='shift') return _mods[16];
if (mod=='meta') return _mods[91];
if (mod=='alt') return _mods[18];
return false;
}
// handle keydown event
function dispatch(event) {
var key, handler, k, i, modifiersMatch, scope;
key = event.keyCode;
if (index(_downKeys, key) == -1) {
_downKeys.push(key);
}
// if a modifier key, set the key.<modifierkeyname> property to true and return
if(key == 93 || key == 224) key = 91; // right command on webkit, command on Gecko
if(key in _mods) {
_mods[key] = true;
// 'assignKey' from inside this closure is exported to window.key
for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = true;
return;
}
updateModifierKey(event);
// see if we need to ignore the keypress (filter() can can be overridden)
// by default ignore key presses if a select, textarea, or input is focused
if(!assignKey.filter.call(this, event)) return;
// abort if no potentially matching shortcuts found
if (!(key in _handlers)) return;
scope = getScope();
// for each potential shortcut
for (i = 0; i < _handlers[key].length; i++) {
handler = _handlers[key][i];
// see if it's in the current scope
if(handler.scope == scope || handler.scope == 'all'){
// check if modifiers match if any
modifiersMatch = handler.mods.length > 0;
for(k in _mods)
if((!_mods[k] && index(handler.mods, +k) > -1) ||
(_mods[k] && index(handler.mods, +k) == -1)) modifiersMatch = false;
// call the handler and stop the event if neccessary
if((handler.mods.length == 0 && !_mods[16] && !_mods[18] && !_mods[17] && !_mods[91]) || modifiersMatch){
if(handler.method(event, handler)===false){
if(event.preventDefault) event.preventDefault();
else event.returnValue = false;
if(event.stopPropagation) event.stopPropagation();
if(event.cancelBubble) event.cancelBubble = true;
}
}
}
}
};
// unset modifier keys on keyup
function clearModifier(event){
var key = event.keyCode, k,
i = index(_downKeys, key);
// remove key from _downKeys
if (i >= 0) {
_downKeys.splice(i, 1);
}
if(key == 93 || key == 224) key = 91;
if(key in _mods) {
_mods[key] = false;
for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = false;
}
};
function resetModifiers() {
for(k in _mods) _mods[k] = false;
for(k in _MODIFIERS) assignKey[k] = false;
};
// parse and assign shortcut
function assignKey(key, scope, method){
var keys, mods;
keys = getKeys(key);
if (method === undefined) {
method = scope;
scope = 'all';
}
// for each shortcut
for (var i = 0; i < keys.length; i++) {
// set modifier keys if any
mods = [];
key = keys[i].split('+');
if (key.length > 1){
mods = getMods(key);
key = [key[key.length-1]];
}
// convert to keycode and...
key = key[0]
key = code(key);
// ...store handler
if (!(key in _handlers)) _handlers[key] = [];
_handlers[key].push({ shortcut: keys[i], scope: scope, method: method, key: keys[i], mods: mods });
}
};
// unbind all handlers for given key in current scope
function unbindKey(key, scope) {
var multipleKeys, keys,
mods = [],
i, j, obj;
multipleKeys = getKeys(key);
for (j = 0; j < multipleKeys.length; j++) {
keys = multipleKeys[j].split('+');
if (keys.length > 1) {
mods = getMods(keys);
}
key = keys[keys.length - 1];
key = code(key);
if (scope === undefined) {
scope = getScope();
}
if (!_handlers[key]) {
return;
}
for (i = 0; i < _handlers[key].length; i++) {
obj = _handlers[key][i];
// only clear handlers if correct scope and mods match
if (obj.scope === scope && compareArray(obj.mods, mods)) {
_handlers[key][i] = {};
}
}
}
};
// Returns true if the key with code 'keyCode' is currently down
// Converts strings into key codes.
function isPressed(keyCode) {
if (typeof(keyCode)=='string') {
keyCode = code(keyCode);
}
return index(_downKeys, keyCode) != -1;
}
function getPressedKeyCodes() {
return _downKeys.slice(0);
}
function filter(event){
var tagName = (event.target || event.srcElement).tagName;
// ignore keypressed in any elements that support keyboard data input
return !(tagName == 'INPUT' || tagName == 'SELECT' || tagName == 'TEXTAREA');
}
// initialize key.<modifier> to false
for(k in _MODIFIERS) assignKey[k] = false;
// set current scope (default 'all')
function setScope(scope){ _scope = scope || 'all' };
function getScope(){ return _scope || 'all' };
// delete all handlers for a given scope
function deleteScope(scope){
var key, handlers, i;
for (key in _handlers) {
handlers = _handlers[key];
for (i = 0; i < handlers.length; ) {
if (handlers[i].scope === scope) handlers.splice(i, 1);
else i++;
}
}
};
// abstract key logic for assign and unassign
function getKeys(key) {
var keys;
key = key.replace(/\s/g, '');
keys = key.split(',');
if ((keys[keys.length - 1]) == '') {
keys[keys.length - 2] += ',';
}
return keys;
}
// abstract mods logic for assign and unassign
function getMods(key) {
var mods = key.slice(0, key.length - 1);
for (var mi = 0; mi < mods.length; mi++)
mods[mi] = _MODIFIERS[mods[mi]];
return mods;
}
// cross-browser events
function addEvent(object, event, method) {
if (object.addEventListener)
object.addEventListener(event, method, false);
else if(object.attachEvent)
object.attachEvent('on'+event, function(){ method(window.event) });
};
// set the handlers globally on document
addEvent(document, 'keydown', function(event) { dispatch(event) }); // Passing _scope to a callback to ensure it remains the same by execution. Fixes #48
addEvent(document, 'keyup', clearModifier);
// reset modifiers to false whenever the window is (re)focused.
addEvent(window, 'focus', resetModifiers);
// store previously defined key
var previousKey = global.key;
// restore previously defined key and return reference to our key object
function noConflict() {
var k = global.key;
global.key = previousKey;
return k;
}
// set window.key and window.key.set/get/deleteScope, and the default filter
global.key = assignKey;
global.key.setScope = setScope;
global.key.getScope = getScope;
global.key.deleteScope = deleteScope;
global.key.filter = filter;
global.key.isPressed = isPressed;
global.key.isModifier = isModifierPressed;
global.key.getPressedKeyCodes = getPressedKeyCodes;
global.key.noConflict = noConflict;
global.key.unbind = unbindKey;
if(typeof module !== 'undefined') module.exports = assignKey;
})(window);

@ -1,44 +1,44 @@
const $ = require('./lib/chibi')
/** Module for toggling a modal overlay */
(function () {
let modal = {}
let curCloseCb = null
let modal = {}
let curCloseCb = null
modal.show = function (sel, closeCb) {
let $m = $(sel)
$m.removeClass('hidden visible')
setTimeout(function () {
$m.addClass('visible')
}, 1)
curCloseCb = closeCb
}
modal.show = function (sel, closeCb) {
let $m = $(sel)
$m.removeClass('hidden visible')
setTimeout(function () {
$m.addClass('visible')
}, 1)
curCloseCb = closeCb
}
modal.hide = function (sel) {
let $m = $(sel)
$m.removeClass('visible')
setTimeout(function () {
$m.addClass('hidden')
if (curCloseCb) curCloseCb()
}, 500) // transition time
}
modal.hide = function (sel) {
let $m = $(sel)
$m.removeClass('visible')
setTimeout(function () {
$m.addClass('hidden')
if (curCloseCb) curCloseCb()
}, 500) // transition time
}
modal.init = function () {
// close modal by click outside the dialog
$('.Modal').on('click', function () {
if ($(this).hasClass('no-close')) return // this is a no-close modal
modal.hide(this)
})
modal.init = function () {
// close modal by click outside the dialog
$('.Modal').on('click', function () {
if ($(this).hasClass('no-close')) return // this is a no-close modal
modal.hide(this)
})
$('.Dialog').on('click', function (e) {
e.stopImmediatePropagation()
})
$('.Dialog').on('click', function (e) {
e.stopImmediatePropagation()
})
// Hide all modals on esc
$(window).on('keydown', function (e) {
if (e.which === 27) {
modal.hide('.Modal')
}
})
}
// Hide all modals on esc
$(window).on('keydown', function (e) {
if (e.which === 27) {
modal.hide('.Modal')
}
})
}
window.Modal = modal
})()
module.exports = modal

@ -1,65 +1,65 @@
window.Notify = (function () {
let nt = {}
const sel = '#notif'
let $balloon
const $ = require('./lib/chibi')
const modal = require('./modal')
let timerHideBegin // timeout to start hiding (transition)
let timerHideEnd // timeout to add the hidden class
let timerCanCancel
let canCancel = false
let nt = {}
const sel = '#notif'
let $balloon
let stopTimeouts = function () {
clearTimeout(timerHideBegin)
clearTimeout(timerHideEnd)
}
nt.show = function (message, timeout, isError) {
$balloon.toggleClass('error', isError === true)
$balloon.html(message)
Modal.show($balloon)
stopTimeouts()
let timerHideBegin // timeout to start hiding (transition)
let timerHideEnd // timeout to add the hidden class
let canCancel = false
if (undef(timeout) || timeout === null || timeout <= 0) {
timeout = 2500
}
let stopTimeouts = function () {
clearTimeout(timerHideBegin)
clearTimeout(timerHideEnd)
}
timerHideBegin = setTimeout(nt.hide, timeout)
nt.show = function (message, timeout, isError) {
$balloon.toggleClass('error', isError === true)
$balloon.html(message)
modal.show($balloon)
stopTimeouts()
canCancel = false
timerCanCancel = setTimeout(function () {
canCancel = true
}, 500)
if (!timeout || timeout <= 0) {
timeout = 2500
}
nt.hide = function () {
let $m = $(sel)
$m.removeClass('visible')
timerHideEnd = setTimeout(function () {
$m.addClass('hidden')
}, 250) // transition time
}
timerHideBegin = setTimeout(nt.hide, timeout)
nt.init = function () {
$balloon = $(sel)
canCancel = false
setTimeout(() => {
canCancel = true
}, 500)
}
// close by click outside
$(document).on('click', function () {
if (!canCancel) return
nt.hide(this)
})
nt.hide = function () {
let $m = $(sel)
$m.removeClass('visible')
timerHideEnd = setTimeout(function () {
$m.addClass('hidden')
}, 250) // transition time
}
// click caused by selecting, prevent it from bubbling
$balloon.on('click', function (e) {
e.stopImmediatePropagation()
return false
})
nt.init = function () {
$balloon = $(sel)
// stop fading if moused
$balloon.on('mouseenter', function () {
stopTimeouts()
$balloon.removeClass('hidden').addClass('visible')
})
}
// close by click outside
$(document).on('click', function () {
if (!canCancel) return
nt.hide(this)
})
// click caused by selecting, prevent it from bubbling
$balloon.on('click', function (e) {
e.stopImmediatePropagation()
return false
})
// stop fading if moused
$balloon.on('mouseenter', function () {
stopTimeouts()
$balloon.removeClass('hidden').addClass('visible')
})
}
return nt
})()
module.exports = nt

@ -0,0 +1,190 @@
const EventEmitter = require('events')
const $ = require('../lib/chibi')
let demo
try { demo = require('./demo') } catch (err) {}
/** Handle connections */
module.exports = class TermConnection extends EventEmitter {
constructor (screen) {
super()
this.screen = screen
this.ws = null
this.heartbeatTimeout = null
this.pingInterval = null
this.xoff = false
this.autoXoffTimeout = null
this.reconnTimeout = null
this.forceClosing = false
this.pageShown = false
document.addEventListener('visibilitychange', () => {
if (document.hidden === true) {
console.info('Window lost focus, freeing socket')
this.closeSocket()
clearTimeout(this.heartbeatTimeout)
} else {
console.info('Window got focus, re-connecting')
this.init()
}
}, false)
}
onWSOpen (evt) {
console.log('CONNECTED')
this.heartbeat()
this.send('i')
this.forceClosing = false
this.emit('connect')
}
onWSClose (evt) {
if (this.forceClosing) {
this.forceClosing = false
return
}
console.warn('SOCKET CLOSED, code ' + evt.code + '. Reconnecting...')
if (evt.code < 1000) {
console.error('Bad code from socket!')
// this sometimes happens for unknown reasons, code < 1000 is invalid
// location.reload()
}
clearTimeout(this.reconnTimeout)
this.reconnTimeout = setTimeout(() => this.init(), 2000)
this.emit('disconnect', evt.code)
}
onWSMessage (evt) {
try {
switch (evt.data.charAt(0)) {
case '.':
// heartbeat, no-op message
break
case '-':
// console.log('xoff');
this.xoff = true
this.autoXoffTimeout = setTimeout(() => {
this.xoff = false
}, 250)
break
case '+':
// console.log('xon');
this.xoff = false
clearTimeout(this.autoXoffTimeout)
break
default:
this.emit('load')
this.screen.load(evt.data)
if (!this.pageShown) {
window.showPage()
this.pageShown = true
}
break
}
this.heartbeat()
} catch (e) {
console.error(e)
}
}
canSend () {
return !this.xoff
}
send (message) {
if (window._demo) {
if (typeof window.demoInterface !== 'undefined') {
demo.input(message)
} else {
console.log(`TX: ${JSON.stringify(message)}`)
}
return true // Simulate success
}
if (this.xoff) {
// TODO queue
console.log("Can't send, flood control.")
return false
}
if (!this.ws) return false // for dry testing
if (this.ws.readyState !== 1) {
console.error('Socket not ready')
return false
}
if (typeof message != 'string') {
message = JSON.stringify(message)
}
this.ws.send(message)
return true
}
/** Safely close the socket */
closeSocket () {
if (this.ws) {
this.forceClosing = true
if (this.ws.readyState === 1) this.ws.close()
this.ws = null
}
}
init () {
if (window._demo) {
if (typeof window.demoInterface === 'undefined') {
window.alert('Demoing non-demo build!') // this will catch mistakes when deploying to the website
} else {
demo.init(this.screen)
}
return
}
clearTimeout(this.reconnTimeout)
clearTimeout(this.heartbeatTimeout)
this.closeSocket()
this.ws = new window.WebSocket('ws://' + window._root + '/term/update.ws')
this.ws.addEventListener('open', (...args) => this.onWSOpen(...args))
this.ws.addEventListener('close', (...args) => this.onWSClose(...args))
this.ws.addEventListener('message', (...args) => this.onWSMessage(...args))
console.log('Opening socket.')
this.heartbeat()
this.emit('open')
}
heartbeat () {
clearTimeout(this.heartbeatTimeout)
this.heartbeatTimeout = setTimeout(() => this.onHeartbeatFail(), 2000)
}
onHeartbeatFail () {
this.closeSocket()
this.emit('silence')
console.error('Heartbeat lost, probing server...')
clearInterval(this.pingInterval)
this.pingInterval = setInterval(() => {
console.log('> ping')
this.emit('ping')
$.get('http://' + window._root + '/system/ping', (resp, status) => {
if (status === 200) {
clearInterval(this.pingInterval)
console.info('Server ready, opening socket…')
this.emit('ping-success')
this.init()
// location.reload()
} else this.emit('ping-fail', status)
}, {
timeout: 100,
loader: false // we have loader on-screen
})
}, 1000)
}
}

@ -1,4 +1,6 @@
window.attachDebugScreen = function (screen) {
const { mk } = require('../utils')
module.exports = function attachDebugScreen (screen) {
const debugCanvas = mk('canvas')
const ctx = debugCanvas.getContext('2d')
@ -25,6 +27,7 @@ window.attachDebugScreen = function (screen) {
let startTime, endTime, lastReason
let cells = new Map()
let clippedRects = []
let startDrawing
@ -32,6 +35,7 @@ window.attachDebugScreen = function (screen) {
drawStart (reason) {
lastReason = reason
startTime = Date.now()
clippedRects = []
},
drawEnd () {
endTime = Date.now()
@ -40,13 +44,38 @@ window.attachDebugScreen = function (screen) {
},
setCell (cell, flags) {
cells.set(cell, [flags, Date.now()])
},
clipRect (...args) {
clippedRects.push(args)
}
}
let clipPattern
{
let patternCanvas = document.createElement('canvas')
patternCanvas.width = patternCanvas.height = 12
let pctx = patternCanvas.getContext('2d')
pctx.lineWidth = 1
pctx.strokeStyle = '#00f'
pctx.beginPath()
pctx.moveTo(0, 0)
pctx.lineTo(0 - 4, 12)
pctx.moveTo(4, 0)
pctx.lineTo(4 - 4, 12)
pctx.moveTo(8, 0)
pctx.lineTo(8 - 4, 12)
pctx.moveTo(12, 0)
pctx.lineTo(12 - 4, 12)
pctx.moveTo(16, 0)
pctx.lineTo(16 - 4, 12)
pctx.stroke()
clipPattern = ctx.createPattern(patternCanvas, 'repeat')
}
let isDrawing = false
let drawLoop = function () {
if (isDrawing) requestAnimationFrame(drawLoop)
if (isDrawing) window.requestAnimationFrame(drawLoop)
let { devicePixelRatio, width, height } = screen.window
let { width: cellWidth, height: cellHeight } = screen.getCellSize()
@ -90,6 +119,18 @@ window.attachDebugScreen = function (screen) {
}
}
if (clippedRects.length) {
ctx.globalAlpha = 0.5
ctx.beginPath()
for (let rect of clippedRects) {
ctx.rect(...rect)
}
ctx.fillStyle = clipPattern
ctx.fill()
}
if (activeCells === 0) {
isDrawing = false
removeCanvas()
@ -103,4 +144,37 @@ window.attachDebugScreen = function (screen) {
isDrawing = true
drawLoop()
}
// debug toolbar
const toolbar = mk('div')
toolbar.classList.add('debug-toolbar')
let toolbarAttached = false
const attachToolbar = function () {
screen.canvas.parentNode.appendChild(toolbar)
}
const detachToolbar = function () {
toolbar.parentNode.removeChild(toolbar)
}
screen.on('update-window:debug', debug => {
if (debug !== toolbarAttached) {
toolbarAttached = debug
if (debug) attachToolbar()
else detachToolbar()
}
})
screen.on('draw', () => {
if (!toolbarAttached) return
let cursorCell = screen.cursor.y * screen.window.width + screen.cursor.x
let cellFG = screen.screenFG[cursorCell]
let cellBG = screen.screenBG[cursorCell]
let cellCode = (screen.screen[cursorCell] || '').codePointAt(0)
let cellAttrs = screen.screenAttrs[cursorCell]
let hexcode = cellCode.toString(16).toUpperCase()
if (hexcode.length < 4) hexcode = `0000${hexcode}`.substr(-4)
hexcode = `U+${hexcode}`
toolbar.textContent = `Cursor cell (${cursorCell}): ${hexcode} FG: ${cellFG} BG: ${cellBG} Attrs: ${cellAttrs.toString(2)}`
})
}

@ -1,3 +1,7 @@
const EventEmitter = require('events')
const { parse2B } = require('../utils')
const { themes } = require('./themes')
class ANSIParser {
constructor (handler) {
this.reset()
@ -47,8 +51,8 @@ class ANSIParser {
else if (type === 20) this.handler('add-attrs', 1 << 5) // fraktur
else if (type >= 30 && type <= 37) this.handler('set-color-fg', type % 10)
else if (type >= 40 && type <= 47) this.handler('set-color-bg', type % 10)
else if (type === 39) this.handler('set-color-fg', 7)
else if (type === 49) this.handler('set-color-bg', 0)
else if (type === 39) this.handler('reset-color-fg')
else if (type === 49) this.handler('reset-color-bg')
else if (type >= 90 && type <= 98) this.handler('set-color-fg', (type % 10) + 8)
else if (type >= 100 && type <= 108) this.handler('set-color-bg', (type % 10) + 8)
else if (type === 38 || type === 48) {
@ -87,6 +91,7 @@ class ANSIParser {
else if (code <= 0x06) this.handler('_null')
else if (code === 0x07) this.handler('bell')
else if (code === 0x08) this.handler('back')
else if (code === 0x09) this.handler('tab')
else if (code === 0x0a) this.handler('new-line')
else if (code === 0x0d) this.handler('return')
else if (code === 0x15) this.handler('delete-line')
@ -96,7 +101,7 @@ class ANSIParser {
if (!this.joinChunks) this.reset()
}
}
const TERM_DEFAULT_STYLE = 7
const TERM_DEFAULT_STYLE = 0
const TERM_MIN_DRAW_DELAY = 10
let getRainbowColor = t => {
@ -116,13 +121,15 @@ class ScrollingTerminal {
this.reset()
this._lastLoad = Date.now()
this.termScreen.load(this.serialize(), 0)
this.termScreen.load(this.serialize())
window.showPage()
}
reset () {
this.style = TERM_DEFAULT_STYLE
this.cursor = { x: 0, y: 0, style: 1, visible: true }
this.trackMouse = false
this.theme = 0
this.theme = -1
this.rainbow = false
this.parser.reset()
this.clear()
@ -172,7 +179,7 @@ class ScrollingTerminal {
}
}
}
deleteChar () {
deleteChar () { // FIXME unused?
this.moveBack()
this.screen.splice((this.cursor.y + 1) * this.width, 0, [' ', TERM_DEFAULT_STYLE])
this.screen.splice(this.cursor.y * this.width + this.cursor.x, 1)
@ -194,11 +201,11 @@ class ScrollingTerminal {
} else if (action === 'delete') {
this.deleteForward(args[0])
} else if (action === 'insert-blanks') {
this.insertBlanks(args[0])
this.insertBlanks(args[0]) // FIXME undefined?
} else if (action === 'clear') {
this.clear()
} else if (action === 'bell') {
this.terminal.load('B')
this.termScreen.load('B')
} else if (action === 'back') {
this.moveBack()
} else if (action === 'new-line') {
@ -226,15 +233,15 @@ class ScrollingTerminal {
} else if (action === 'reset-style') {
this.style = TERM_DEFAULT_STYLE
} else if (action === 'add-attrs') {
if (args[0] === -1) {
this.style = (this.style & 0xFF0000) | ((this.style >> 8) & 0xFF) | ((this.style & 0xFF) << 8)
} else {
this.style |= (args[0] << 16)
}
this.style |= (args[0] << 16)
} else if (action === 'set-color-fg') {
this.style = (this.style & 0xFFFF00) | args[0]
this.style = (this.style & 0xFFFFFF00) | (1 << 8 << 16) | args[0]
} else if (action === 'set-color-bg') {
this.style = (this.style & 0xFF00FF) | (args[0] << 8)
this.style = (this.style & 0xFFFF00FF) | (1 << 9 << 16) | (args[0] << 8)
} else if (action === 'reset-color-fg') {
this.style = this.style & 0xFFFEFF00
} else if (action === 'reset-color-bg') {
this.style = this.style & 0xFFFD00FF
} else if (action === 'hide-cursor') {
this.cursor.visible = false
} else if (action === 'show-cursor') {
@ -247,14 +254,14 @@ class ScrollingTerminal {
}
serialize () {
let serialized = 'S'
serialized += encode2B(this.height) + encode2B(this.width)
serialized += encode2B(this.cursor.y) + encode2B(this.cursor.x)
serialized += String.fromCodePoint(this.height + 1) + String.fromCodePoint(this.width + 1)
serialized += String.fromCodePoint(this.cursor.y + 1) + String.fromCodePoint(this.cursor.x + 1)
let attributes = +this.cursor.visible
attributes |= (3 << 5) * +this.trackMouse // track mouse controls both
attributes |= 3 << 7 // buttons/links always visible
attributes |= (this.cursor.style << 9)
serialized += encode3B(attributes)
serialized += String.fromCodePoint(attributes + 1)
let lastStyle = null
let index = 0
@ -263,22 +270,22 @@ class ScrollingTerminal {
if (this.rainbow) {
let x = index % this.width
let y = Math.floor(index / this.width)
style = (style & 0xFF0000) | getRainbowColor((x + y) / 10 + Date.now() / 1000)
// C instead of F in mask and 1 << 8 in attrs to change attr bits 8 and 9
style = (style & 0xFFFC0000) | (1 << 8 << 16) | getRainbowColor((x + y) / 10 + Date.now() / 1000)
index++
}
if (style !== lastStyle) {
let foreground = style & 0xFF
let background = (style >> 8) & 0xFF
let attributes = (style >> 16) & 0xFF
let attributes = (style >> 16) & 0xFFFF
let setForeground = foreground !== (lastStyle & 0xFF)
let setBackground = background !== ((lastStyle >> 8) & 0xFF)
let setAttributes = attributes !== ((lastStyle >> 16) & 0xFF)
if (setForeground && setBackground) serialized += '\x03' + encode3B(style & 0xFFFF)
else if (setForeground) serialized += '\x05' + encode2B(foreground)
else if (setBackground) serialized += '\x06' + encode2B(background)
if (setAttributes) serialized += '\x04' + encode2B(attributes)
let setAttributes = attributes !== ((lastStyle >> 16) & 0xFFFF)
if (setForeground && setBackground) serialized += '\x03' + String.fromCodePoint((style & 0xFFFF) + 1)
else if (setForeground) serialized += '\x05' + String.fromCodePoint(foreground + 1)
else if (setBackground) serialized += '\x06' + String.fromCodePoint(background + 1)
if (setAttributes) serialized += '\x04' + String.fromCodePoint(attributes + 1)
lastStyle = style
}
serialized += cell[0]
@ -288,7 +295,8 @@ class ScrollingTerminal {
scheduleLoad () {
clearTimeout(this._scheduledLoad)
if (this._lastLoad < Date.now() - TERM_MIN_DRAW_DELAY) {
this.termScreen.load(this.serialize(), this.theme)
this.termScreen.load(this.serialize(), { theme: this.theme })
this.theme = -1 // prevent useless theme setting next time
} else {
this._scheduledLoad = setTimeout(() => {
this.termScreen.load(this.serialize())
@ -304,47 +312,7 @@ class ScrollingTerminal {
}
}
class Process {
constructor (args) {
// event listeners
this._listeners = {}
}
on (event, listener) {
if (!this._listeners[event]) this._listeners[event] = []
this._listeners[event].push({ listener })
}
once (event, listener) {
if (!this._listeners[event]) this._listeners[event] = []
this._listeners[event].push({ listener, once: true })
}
off (event, listener) {
let listeners = this._listeners[event]
if (listeners) {
for (let i in listeners) {
if (listeners[i].listener === listener) {
listeners.splice(i, 1)
break
}
}
}
}
emit (event, ...args) {
let listeners = this._listeners[event]
if (listeners) {
let remove = []
for (let listener of listeners) {
try {
listener.listener(...args)
if (listener.once) remove.push(listener)
} catch (err) {
console.error(err)
}
}
for (let listener of remove) {
listeners.splice(listeners.indexOf(listener), 1)
}
}
}
class Process extends EventEmitter {
write (data) {
this.emit('in', data)
}
@ -452,6 +420,13 @@ let demoshIndex = {
'*': 17,
'#': 24
}
let characters = {
' ': ' ',
'.': '░',
'-': '▒',
'*': '▓',
'#': '█'
}
for (let i in splash) {
if (splash[i].length < 79) splash[i] += ' '.repeat(79 - splash[i].length)
}
@ -472,9 +447,11 @@ let demoshIndex = {
let drawCell = (x, y) => {
moveTo(x, y)
if (splash[y][x] === '@') {
this.emit('write', '\x1b[48;5;8m\x1b[38;5;255m▄\b')
this.emit('write', '\x1b[48;5;238m\x1b[38;5;255m▄\b')
} else {
this.emit('write', `\x1b[48;5;${231 + levels[splash[y][x]]}m \b`)
let level = 231 + levels[splash[y][x]]
let character = characters[splash[y][x]]
this.emit('write', `\x1b[48;5;${level}m\x1b[38;5;${level}m${character}\b`)
}
}
return new Promise((resolve, reject) => {
@ -487,7 +464,7 @@ let demoshIndex = {
if (dx > 0) drawCell(dx, y)
}
if (++x < 79) {
if (++x < 69) {
if (++cycles >= 3) {
setTimeout(loop, 20)
cycles = 0
@ -577,9 +554,10 @@ let demoshIndex = {
this.shell = shell
}
run (...args) {
let theme = args[0] | 0
if (!args.length || !Number.isFinite(theme) || theme < 0 || theme > 5) {
this.emit('write', '\x1b[31mUsage: theme [0–5]\r\n')
let theme = +args[0] | 0
const maxnum = themes.length
if (!args.length || !Number.isFinite(theme) || theme < 0 || theme >= maxnum) {
this.emit('write', `\x1b[31mUsage: theme [0–${maxnum - 1}]\r\n`)
this.destroy()
return
}
@ -594,7 +572,7 @@ let demoshIndex = {
run (...args) {
let steady = args.includes('--steady')
if (args.includes('block')) {
this.emit('write', `\x1b[${0 + 2 * steady} q`)
this.emit('write', `\x1b[${2 * steady} q`)
} else if (args.includes('line')) {
this.emit('write', `\x1b[${3 + steady} q`)
} else if (args.includes('bar') || args.includes('beam')) {
@ -681,9 +659,15 @@ let demoshIndex = {
}
},
sudo: class Sudo extends Process {
constructor (shell) {
super()
this.shell = shell
}
run (...args) {
if (args.length === 0) this.emit('write', '\x1b[31musage: sudo <command>\x1b[0m\n')
else if (args.length === 4 && args.join(' ').toLowerCase() === 'make me a sandwich') {
if (args.length === 0) {
this.emit('write', '\x1b[31mUsage: sudo <command>\x1b[m\r\n')
this.destroy()
} else if (args.length === 4 && args.join(' ').toLowerCase() === 'make me a sandwich') {
const b = '\x1b[33m'
const r = '\x1b[0m'
const l = '\x1b[32m'
@ -708,11 +692,29 @@ let demoshIndex = {
`${b} ~-._\\. _.-~_/\r\n` +
`${b} \\\`--...--~_.-~\r\n` +
`${b} \`--...--~${r}\r\n`)
this.destroy()
} else {
this.emit('exec', args.join(' '))
return
let name = args.shift()
if (this.shell.index[name]) {
let Process = this.shell.index[name]
if (Process instanceof Function) {
let child = new Process(this)
let write = data => this.emit('write', data)
child.on('write', write)
child.on('exit', code => {
child.removeListener('write', write)
this.destroy()
})
child.run(...args)
} else {
this.emit('write', Process)
this.destroy()
}
} else {
this.emit('write', `sudo: ${name}: command not found\r\n`)
this.destroy()
}
}
this.destroy()
}
},
make: class Make extends Process {
@ -791,8 +793,10 @@ class DemoShell {
this.history[0] = this.history[0].substr(0, this.cursorPos - 1) + this.history[0].substr(this.cursorPos)
this.cursorPos--
if (this.cursorPos < 0) this.cursorPos = 0
} else if (action === 'tab') {
console.warn('TAB not implemented') // TODO completion
} else if (action === 'move-cursor-x') {
this.cursorPos = Math.max(0, Math.min(this.history[0].length, this.cursorPos + args[0]))
this.cursorPos = Math.max(0, Math.min(this.history[this.historyIndex].length, this.cursorPos + args[0]))
} else if (action === 'delete-line') {
this.copyFromHistoryIndex()
this.history[0] = ''
@ -842,6 +846,7 @@ class DemoShell {
}
let name = parts.shift()
if (name in this.index) {
this.spawn(name, parts)
} else {
@ -854,12 +859,9 @@ class DemoShell {
if (Process instanceof Function) {
this.child = new Process(this)
let write = data => this.terminal.write(data)
let exec = line => this.run(line)
this.child.on('write', write)
this.child.on('exec', exec)
this.child.on('exit', code => {
if (this.child) this.child.off('write', write)
if (this.child) this.child.off('exec', exec)
if (this.child) this.child.removeListener('write', write)
this.child = null
this.prompt(!code)
})
@ -871,7 +873,7 @@ class DemoShell {
}
}
window.demoInterface = {
window.demoInterface = module.exports = {
input (data) {
let type = data[0]
let content = data.substr(1)
@ -898,7 +900,10 @@ window.demoInterface = {
}
}
},
didInit: false,
init (screen) {
if (this.didInit) return
this.didInit = true
this.terminal = new ScrollingTerminal(screen)
this.shell = new DemoShell(this.terminal, true)
}

@ -1,13 +1,49 @@
const { qs, mk } = require('../utils')
const Notify = require('../notif')
const TermScreen = require('./screen')
const TermConnection = require('./connection')
const TermInput = require('./input')
const TermUpload = require('./upload')
const initSoftKeyboard = require('./soft_keyboard')
const attachDebugScreen = require('./debug_screen')
/** Init the terminal sub-module - called from HTML */
window.termInit = function ({ labels, theme, allFn }) {
module.exports = function (opts) {
const screen = new TermScreen()
const conn = Conn(screen)
const input = Input(conn)
const termUpload = TermUpl(conn, input, screen)
const conn = new TermConnection(screen)
const input = TermInput(conn, screen)
const termUpload = TermUpload(conn, input, screen)
screen.input = input
input.termUpload = termUpload
// we delay the display of "connecting" to avoid flash when changing tabs with the terminal open
let showConnectingTimeout = -1
conn.on('open', () => {
showConnectingTimeout = setTimeout(() => {
screen.window.statusScreen = { title: 'Connecting', loading: true }
}, 250)
})
conn.on('connect', () => {
clearTimeout(showConnectingTimeout)
screen.window.statusScreen = { title: 'Waiting for content', loading: true }
})
conn.on('load', () => {
if (screen.window.statusScreen) screen.window.statusScreen = null
})
conn.on('disconnect', () => {
clearTimeout(showConnectingTimeout)
screen.window.statusScreen = { title: 'Disconnected' }
screen.screen = []
screen.screenFG = []
screen.screenBG = []
screen.screenAttrs = []
})
conn.on('silence', () => { screen.window.statusScreen = { title: 'Waiting for server', loading: true } })
// conn.on('ping-fail', () => { screen.window.statusScreen = { title: 'Disconnected' } })
conn.on('ping-success', () => { screen.window.statusScreen = { title: 'Re-connecting', loading: true } })
conn.init()
input.init({ allFn })
input.init(opts)
termUpload.init()
Notify.init()
@ -17,10 +53,10 @@ window.termInit = function ({ labels, theme, allFn }) {
}
qs('#screen').appendChild(screen.canvas)
screen.load(labels, theme) // load labels and theme
screen.load(opts.labels, opts) // load labels and theme
window.initSoftKeyboard(screen, input)
if (window.attachDebugScreen) window.attachDebugScreen(screen)
initSoftKeyboard(screen, input)
if (attachDebugScreen) attachDebugScreen(screen)
let isFullscreen = false
let fitScreen = false
@ -55,10 +91,10 @@ window.termInit = function ({ labels, theme, allFn }) {
})
// add fullscreen mode & button
if (Element.prototype.requestFullscreen || Element.prototype.webkitRequestFullscreen) {
if (window.Element.prototype.requestFullscreen || window.Element.prototype.webkitRequestFullscreen) {
let checkForFullscreen = function () {
// document.fullscreenElement is not really supported yet, so here's a hack
if (isFullscreen && (innerWidth !== window.screen.width || innerHeight !== window.screen.height)) {
if (isFullscreen && (window.innerWidth !== window.screen.width || window.innerHeight !== window.screen.height)) {
isFullscreen = false
fitScreenIfNeeded()
}

@ -0,0 +1,468 @@
const $ = require('../lib/chibi')
const { encode2B } = require('../utils')
/**
* User input
*
* --- Rx messages: ---
* S - screen content (binary encoding of the entire screen with simple compression)
* T - text labels - Title and buttons, \0x01-separated
* B - beep
* . - heartbeat
*
* --- Tx messages ---
* s - string
* b - action button
* p - mb press
* r - mb release
* m - mouse move
*/
module.exports = function (conn, screen) {
// handle for input object
let input
const KEY_NAMES = {
0x03: 'Cancel',
0x06: 'Help',
0x08: 'Backspace',
0x09: 'Tab',
0x0C: 'Clear',
0x0D: 'Enter',
0x10: 'Shift',
0x11: 'Control',
0x12: 'Alt',
0x13: 'Pause',
0x14: 'CapsLock',
0x1B: 'Escape',
0x20: ' ',
0x21: 'PageUp',
0x22: 'PageDown',
0x23: 'End',
0x24: 'Home',
0x25: 'ArrowLeft',
0x26: 'ArrowUp',
0x27: 'ArrowRight',
0x28: 'ArrowDown',
0x29: 'Select',
0x2A: 'Print',
0x2B: 'Execute',
0x2C: 'PrintScreen',
0x2D: 'Insert',
0x2E: 'Delete',
0x3A: ':',
0x3B: ';',
0x3C: '<',
0x3D: '=',
0x3E: '>',
0x3F: '?',
0x40: '@',
0x5B: 'Meta',
0x5C: 'Meta',
0x5D: 'ContextMenu',
0x6A: 'Numpad*',
0x6B: 'Numpad+',
0x6D: 'Numpad-',
0x6E: 'Numpad.',
0x6F: 'Numpad/',
0x90: 'NumLock',
0x91: 'ScrollLock',
0xA0: '^',
0xA1: '!',
0xA2: '"',
0xA3: '#',
0xA4: '$',
0xA5: '%',
0xA6: '&',
0xA7: '_',
0xA8: '(',
0xA9: ')',
0xAA: '*',
0xAB: '+',
0xAC: '|',
0xAD: '-',
0xAE: '{',
0xAF: '}',
0xB0: '~',
0xBA: ';',
0xBB: '=',
0xBC: 'Numpad,',
0xBD: '-',
0xBE: 'Numpad,',
0xC0: '`',
0xC2: 'Numpad,',
0xDB: '[',
0xDC: '\\',
0xDD: ']',
0xDE: "'",
0xE0: 'Meta'
}
// numbers 0-9
for (let i = 0x30; i <= 0x39; i++) KEY_NAMES[i] = String.fromCharCode(i)
// characters A-Z
for (let i = 0x41; i <= 0x5A; i++) KEY_NAMES[i] = String.fromCharCode(i)
// function F1-F20
for (let i = 0x70; i <= 0x83; i++) KEY_NAMES[i] = `F${i - 0x70 + 1}`
// numpad 0-9
for (let i = 0x60; i <= 0x69; i++) KEY_NAMES[i] = `Numpad${i - 0x60}`
let cfg = {
np_alt: false, // Application Numpad Mode
cu_alt: false, // Application Cursors Mode
fn_alt: false, // SS3 function keys mode
mt_click: false, // Mouse click tracking
mt_move: false, // Mouse move tracking
no_keys: false, // Suppress any key / clipboard event intercepting
crlf_mode: false, // Enter sends CR LF
all_fn: false // Capture also F5, F11 and F12
}
/** Fn alt choice for key message */
const fa = (alt, normal) => cfg.fn_alt ? alt : normal
/** Cursor alt choice for key message */
const ca = (alt, normal) => cfg.cu_alt ? alt : normal
/** Numpad alt choice for key message */
const na = (alt, normal) => cfg.np_alt ? alt : normal
const keymap = {
/* eslint-disable key-spacing */
'Backspace': '\x08',
'Tab': '\x09',
'Enter': () => cfg.crlf_mode ? '\x0d\x0a' : '\x0d',
'Control+Enter': '\x0a',
'Escape': '\x1b',
'ArrowUp': () => ca('\x1bOA', '\x1b[A'),
'ArrowDown': () => ca('\x1bOB', '\x1b[B'),
'ArrowRight': () => ca('\x1bOC', '\x1b[C'),
'ArrowLeft': () => ca('\x1bOD', '\x1b[D'),
'Home': () => ca('\x1bOH', fa('\x1b[H', '\x1b[1~')),
'Insert': '\x1b[2~',
'Delete': '\x1b[3~',
'End': () => ca('\x1bOF', fa('\x1b[F', '\x1b[4~')),
'PageUp': '\x1b[5~',
'PageDown': '\x1b[6~',
'F1': () => fa('\x1bOP', '\x1b[11~'),
'F2': () => fa('\x1bOQ', '\x1b[12~'),
'F3': () => fa('\x1bOR', '\x1b[13~'),
'F4': () => fa('\x1bOS', '\x1b[14~'),
'F5': '\x1b[15~', // note the disconnect
'F6': '\x1b[17~',
'F7': '\x1b[18~',
'F8': '\x1b[19~',
'F9': '\x1b[20~',
'F10': '\x1b[21~', // note the disconnect
'F11': '\x1b[23~',
'F12': '\x1b[24~',
'Shift+F1': () => fa('\x1bO1;2P', '\x1b[25~'),
'Shift+F2': () => fa('\x1bO1;2Q', '\x1b[26~'), // note the disconnect
'Shift+F3': () => fa('\x1bO1;2R', '\x1b[28~'),
'Shift+F4': () => fa('\x1bO1;2S', '\x1b[29~'), // note the disconnect
'Shift+F5': () => fa('\x1b[15;2~', '\x1b[31~'),
'Shift+F6': () => fa('\x1b[17;2~', '\x1b[32~'),
'Shift+F7': () => fa('\x1b[18;2~', '\x1b[33~'),
'Shift+F8': () => fa('\x1b[19;2~', '\x1b[34~'),
'Shift+F9': () => fa('\x1b[20;2~', '\x1b[35~'), // 35-38 are not standard - but what is?
'Shift+F10': () => fa('\x1b[21;2~', '\x1b[36~'),
'Shift+F11': () => fa('\x1b[22;2~', '\x1b[37~'),
'Shift+F12': () => fa('\x1b[23;2~', '\x1b[38~'),
'Numpad0': () => na('\x1bOp', '0'),
'Numpad1': () => na('\x1bOq', '1'),
'Numpad2': () => na('\x1bOr', '2'),
'Numpad3': () => na('\x1bOs', '3'),
'Numpad4': () => na('\x1bOt', '4'),
'Numpad5': () => na('\x1bOu', '5'),
'Numpad6': () => na('\x1bOv', '6'),
'Numpad7': () => na('\x1bOw', '7'),
'Numpad8': () => na('\x1bOx', '8'),
'Numpad9': () => na('\x1bOy', '9'),
'Numpad*': () => na('\x1bOR', '*'),
'Numpad+': () => na('\x1bOl', '+'),
'Numpad-': () => na('\x1bOS', '-'),
'Numpad.': () => na('\x1bOn', '.'),
'Numpad/': () => na('\x1bOQ', '/'),
// we don't implement numlock key (should change in numpad_alt mode,
// but it's even more useless than the rest and also has the side
// effect of changing the user's numlock state)
// shortcuts
'Control+]': '\x1b', // alternate way to enter ESC
'Control+\\': '\x1c',
'Control+[': '\x1d',
'Control+^': '\x1e',
'Control+_': '\x1f',
// extra controls
'Control+ArrowLeft': '\x1f[1;5D',
'Control+ArrowRight': '\x1f[1;5C',
'Control+ArrowUp': '\x1f[1;5A',
'Control+ArrowDown': '\x1f[1;5B',
'Control+Home': '\x1f[1;5H',
'Control+End': '\x1f[1;5F',
// extra shift controls
'Shift+ArrowLeft': '\x1f[1;2D',
'Shift+ArrowRight': '\x1f[1;2C',
'Shift+ArrowUp': '\x1f[1;2A',
'Shift+ArrowDown': '\x1f[1;2B',
'Shift+Home': '\x1f[1;2H',
'Shift+End': '\x1f[1;2F',
// macOS text editing commands
'Alt+ArrowLeft': '\x1bb', // ⌥← to go back a word (^[b)
'Alt+ArrowRight': '\x1bf', // ⌥→ to go forward one word (^[f)
'Meta+ArrowLeft': '\x01', // ⌘← to go to the beginning of a line (^A)
'Meta+ArrowRight': '\x05', // ⌘→ to go to the end of a line (^E)
'Alt+Backspace': '\x17', // ⌥⌫ to delete a word (^W)
'Meta+Backspace': '\x15', // ⌘⌫ to delete to the beginning of a line (^U)
// copy to clipboard
'Control+Shift+C' () {
screen.copySelectionToClipboard()
},
'Control+Insert' () {
screen.copySelectionToClipboard()
},
// toggle debug mode
'Control+F12' () {
screen.window.debug ^= 1
}
/* eslint-enable key-spacing */
}
// ctrl+[A-Z] sent as simple low ASCII codes
for (let i = 1; i <= 26; i++) {
keymap[`Control+${String.fromCharCode(0x40 + i)}`] = String.fromCharCode(i)
}
/** Send a literal message */
function sendString (str) {
return conn.send('s' + str)
}
/** Send a button event */
function sendButton (n) {
conn.send('b' + String.fromCharCode(n))
}
const shouldAcceptEvent = function () {
if (cfg.no_keys) return false
if (document.activeElement instanceof window.HTMLTextAreaElement) return false
return true
}
const keyBlacklist = [
'F5', 'F11', 'F12', 'Shift+F5'
]
let softModifiers = {
alt: false,
ctrl: false,
meta: false,
shift: false
}
const handleKeyDown = function (e) {
if (!shouldAcceptEvent()) return
let modifiers = []
// sorted alphabetically
if (e.altKey || softModifiers.alt) modifiers.push('Alt')
if (e.ctrlKey || softModifiers.ctrl) modifiers.push('Control')
if (e.metaKey || softModifiers.meta) modifiers.push('Meta')
if (e.shiftKey || softModifiers.shift) modifiers.push('Shift')
let key = KEY_NAMES[e.which] || e.key
// ignore clipboard events
if ((e.ctrlKey || e.metaKey) && key === 'V') return
let binding = null
for (let name in keymap) {
let itemModifiers = name.split('+')
let itemKey = itemModifiers.pop()
if (itemKey === key && itemModifiers.sort().join() === modifiers.join()) {
if (keyBlacklist.includes(name) && !cfg.all_fn) continue
binding = keymap[name]
break
}
}
if (binding) {
if (binding instanceof Function) binding = binding()
e.preventDefault()
if (typeof binding === 'string') {
sendString(binding)
}
}
}
/** Bind/rebind key messages */
function initKeys ({ allFn }) {
// This takes care of text characters typed
window.addEventListener('keypress', function (evt) {
if (!shouldAcceptEvent()) return
if (evt.ctrlKey || evt.metaKey) return
let str = ''
if (evt.key && evt.key.length === 1) str = evt.key
else if (evt.which && evt.which !== 229) str = String.fromCodePoint(evt.which)
if (str.length > 0 && str.charCodeAt(0) >= 32) {
// prevent space from scrolling
if (evt.which === 32) evt.preventDefault()
sendString(str)
}
})
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('copy', e => {
if (!shouldAcceptEvent()) return
let selectedText = screen.getSelectedText()
if (selectedText) {
e.preventDefault()
e.clipboardData.setData('text/plain', selectedText)
}
})
window.addEventListener('paste', e => {
if (!shouldAcceptEvent()) return
e.preventDefault()
let string = e.clipboardData.getData('text/plain')
if (string.includes('\n') || string.length > 90) {
if (!input.termUpload) console.error('input.termUpload is undefined')
input.termUpload.setContent(string)
input.termUpload.open()
} else {
// simple string, just paste it
if (screen.bracketedPaste) string = `\x1b[200~${string}\x1b[201~`
sendString(string)
}
})
cfg.all_fn = allFn
}
// mouse button states
let mb1 = 0
let mb2 = 0
let mb3 = 0
/** Init the Input module */
function init (opts) {
initKeys(opts)
// Button presses
$('#action-buttons button').forEach(s => {
s.addEventListener('click', function (evt) {
sendButton(+this.dataset['n'])
})
})
// global mouse state tracking - for motion reporting
window.addEventListener('mousedown', evt => {
if (evt.button === 0) mb1 = 1
if (evt.button === 1) mb2 = 1
if (evt.button === 2) mb3 = 1
})
window.addEventListener('mouseup', evt => {
if (evt.button === 0) mb1 = 0
if (evt.button === 1) mb2 = 0
if (evt.button === 2) mb3 = 0
})
}
// record modifier keys
// bits: Meta, Alt, Shift, Ctrl
let modifiers = 0b0000
window.addEventListener('keydown', e => {
if (e.ctrlKey) modifiers |= 1
if (e.shiftKey) modifiers |= 2
if (e.altKey) modifiers |= 4
if (e.metaKey) modifiers |= 8
})
window.addEventListener('keyup', e => {
modifiers = 0
if (e.ctrlKey) modifiers |= 1
if (e.shiftKey) modifiers |= 2
if (e.altKey) modifiers |= 4
if (e.metaKey) modifiers |= 8
})
/** Prepare modifiers byte for mouse message */
function packModifiersForMouse () {
return modifiers
}
input = {
/** Init the Input module */
init,
/** Send a literal string message */
sendString,
/** Enable alternate key modes (cursors, numpad, fn) */
setAlts: function (cu, np, fn, crlf) {
if (cfg.cu_alt !== cu || cfg.np_alt !== np || cfg.fn_alt !== fn || cfg.crlf_mode !== crlf) {
cfg.cu_alt = cu
cfg.np_alt = np
cfg.fn_alt = fn
cfg.crlf_mode = crlf
}
},
setMouseMode (click, move) {
cfg.mt_click = click
cfg.mt_move = move
},
// Mouse events
onMouseMove (x, y) {
if (!cfg.mt_move) return
const b = mb1 ? 1 : mb2 ? 2 : mb3 ? 3 : 0
const m = packModifiersForMouse()
conn.send('m' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m))
},
onMouseDown (x, y, b) {
if (!cfg.mt_click) return
if (b > 3 || b < 1) return
const m = packModifiersForMouse()
conn.send('p' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m))
},
onMouseUp (x, y, b) {
if (!cfg.mt_click) return
if (b > 3 || b < 1) return
const m = packModifiersForMouse()
conn.send('r' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m))
// console.log("B ",b," M ",m);
},
onMouseWheel (x, y, dir) {
if (!cfg.mt_click) return
// -1 ... btn 4 (away from user)
// +1 ... btn 5 (towards user)
const m = packModifiersForMouse()
const b = (dir < 0 ? 4 : 5)
conn.send('p' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m))
// console.log("B ",b," M ",m);
},
/**
* Prevent capturing keys. This is used for text input
* modals on the terminal screen
*/
blockKeys (yes) {
cfg.no_keys = yes
},
handleKeyDown,
softModifiers
}
return input
}

@ -0,0 +1,578 @@
const EventEmitter = require('events')
const $ = require('../lib/chibi')
const { mk, qs } = require('../utils')
const notify = require('../notif')
const ScreenParser = require('./screen_parser')
const ScreenRenderer = require('./screen_renderer')
module.exports = class TermScreen extends EventEmitter {
constructor () {
super()
this.canvas = mk('canvas')
this.ctx = this.canvas.getContext('2d')
this.parser = new ScreenParser(this)
this.renderer = new ScreenRenderer(this)
// debug screen handle
this._debug = null
if ('AudioContext' in window || 'webkitAudioContext' in window) {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)()
} else {
console.warn('No AudioContext!')
}
// dummy. Handle for Input
this.input = new Proxy({}, {
get () {
return () => console.warn('TermScreen#input not set!')
}
})
this.cursor = {
x: 0,
y: 0,
blinking: true,
visible: true,
hanging: false,
style: 'block'
}
this._window = {
width: 0,
height: 0,
devicePixelRatio: 1,
fontFamily: '"DejaVu Sans Mono", "Liberation Mono", "Inconsolata", "Menlo", monospace',
fontSize: 20,
gridScaleX: 1.0,
gridScaleY: 1.2,
fitIntoWidth: 0,
fitIntoHeight: 0,
debug: false,
graphics: 0,
statusScreen: null
}
// scaling caused by fitIntoWidth/fitIntoHeight
this._windowScale = 1
// properties of this.window that require updating size and redrawing
this.windowState = {
width: 0,
height: 0,
devicePixelRatio: 0,
gridScaleX: 0,
gridScaleY: 0,
fontFamily: '',
fontSize: 0,
fitIntoWidth: 0,
fitIntoHeight: 0
}
// current selection
this.selection = {
// when false, this will prevent selection in favor of mouse events,
// though alt can be held to override it
selectable: true,
// selection start and end (x, y) tuples
start: [0, 0],
end: [0, 0]
}
// mouse features
this.mouseMode = { clicks: false, movement: false }
// make writing to window update size and draw
const self = this
this.window = new Proxy(this._window, {
set (target, key, value, receiver) {
target[key] = value
self.scheduleSizeUpdate()
self.renderer.scheduleDraw(`window:${key}=${value}`)
self.emit(`update-window:${key}`, value)
return true
}
})
this.bracketedPaste = false
this.blinkingCellCount = 0
this.reverseVideo = false
this.screen = []
this.screenFG = []
this.screenBG = []
this.screenAttrs = []
let selecting = false
let selectStart = (x, y) => {
if (selecting) return
selecting = true
this.selection.start = this.selection.end = this.screenToGrid(x, y, true)
this.renderer.scheduleDraw('select-start')
}
let selectMove = (x, y) => {
if (!selecting) return
this.selection.end = this.screenToGrid(x, y, true)
this.renderer.scheduleDraw('select-move')
}
let selectEnd = (x, y) => {
if (!selecting) return
selecting = false
this.selection.end = this.screenToGrid(x, y, true)
this.renderer.scheduleDraw('select-end')
Object.assign(this.selection, this.getNormalizedSelection())
}
// bind event listeners
this.canvas.addEventListener('mousedown', e => {
if ((this.selection.selectable || e.altKey) && e.button === 0) {
selectStart(e.offsetX, e.offsetY)
} else {
this.input.onMouseDown(...this.screenToGrid(e.offsetX, e.offsetY),
e.button + 1)
}
})
window.addEventListener('mousemove', e => {
selectMove(e.offsetX, e.offsetY)
})
window.addEventListener('mouseup', e => {
selectEnd(e.offsetX, e.offsetY)
})
// touch event listeners
let touchPosition = null
let touchDownTime = 0
let touchSelectMinTime = 500
let touchDidMove = false
let getTouchPositionOffset = touch => {
let rect = this.canvas.getBoundingClientRect()
return [touch.clientX - rect.left, touch.clientY - rect.top]
}
this.canvas.addEventListener('touchstart', e => {
touchPosition = getTouchPositionOffset(e.touches[0])
touchDidMove = false
touchDownTime = Date.now()
}, { passive: true })
this.canvas.addEventListener('touchmove', e => {
touchPosition = getTouchPositionOffset(e.touches[0])
if (!selecting && touchDidMove === false) {
if (touchDownTime < Date.now() - touchSelectMinTime) {
selectStart(...touchPosition)
}
} else if (selecting) {
e.preventDefault()
selectMove(...touchPosition)
}
touchDidMove = true
})
this.canvas.addEventListener('touchend', e => {
if (e.touches[0]) {
touchPosition = getTouchPositionOffset(e.touches[0])
}
if (selecting) {
e.preventDefault()
selectEnd(...touchPosition)
// selection ended; show touch select menu
let touchSelectMenu = qs('#touch-select-menu')
touchSelectMenu.classList.add('open')
let rect = touchSelectMenu.getBoundingClientRect()
// use middle position for x and one line above for y
let selectionPos = this.gridToScreen(
(this.selection.start[0] + this.selection.end[0]) / 2,
this.selection.start[1] - 1
)
selectionPos[0] -= rect.width / 2
selectionPos[1] -= rect.height / 2
touchSelectMenu.style.transform = `translate(${selectionPos[0]}px, ${
selectionPos[1]}px)`
}
if (!touchDidMove) {
this.emit('tap', Object.assign(e, {
x: touchPosition[0],
y: touchPosition[1]
}))
}
touchPosition = null
})
this.on('tap', e => {
if (this.selection.start[0] !== this.selection.end[0] ||
this.selection.start[1] !== this.selection.end[1]) {
// selection is not empty
// reset selection
this.selection.start = this.selection.end = [0, 0]
qs('#touch-select-menu').classList.remove('open')
this.renderer.scheduleDraw('select-reset')
} else {
e.preventDefault()
this.emit('open-soft-keyboard')
}
})
$.ready(() => {
let copyButton = qs('#touch-select-copy-btn')
if (copyButton) {
copyButton.addEventListener('click', () => {
this.copySelectionToClipboard()
})
}
})
this.canvas.addEventListener('mousemove', e => {
if (!selecting) {
this.input.onMouseMove(...this.screenToGrid(e.offsetX, e.offsetY))
}
})
this.canvas.addEventListener('mouseup', e => {
if (!selecting) {
this.input.onMouseUp(...this.screenToGrid(e.offsetX, e.offsetY),
e.button + 1)
}
})
this.canvas.addEventListener('wheel', e => {
if (this.mouseMode.clicks) {
this.input.onMouseWheel(...this.screenToGrid(e.offsetX, e.offsetY),
e.deltaY > 0 ? 1 : -1)
// prevent page scrolling
e.preventDefault()
}
})
this.canvas.addEventListener('contextmenu', e => {
if (this.mouseMode.clicks) {
// prevent mouse keys getting stuck
e.preventDefault()
}
selectEnd(e.offsetX, e.offsetY)
})
}
/**
* Schedule a size update in the next millisecond
*/
scheduleSizeUpdate () {
clearTimeout(this._scheduledSizeUpdate)
this._scheduledSizeUpdate = setTimeout(() => this.updateSize(), 1)
}
/**
* Returns a CSS font string with this TermScreen's font settings and the
* font modifiers.
* @param {Object} modifiers
* @param {string} [modifiers.style] - the font style
* @param {string} [modifiers.weight] - the font weight
* @returns {string} a CSS font string
*/
getFont (modifiers = {}) {
let fontStyle = modifiers.style || 'normal'
let fontWeight = modifiers.weight || 'normal'
return `${fontStyle} normal ${fontWeight} ${this.window.fontSize}px ${this.window.fontFamily}`
}
/**
* Converts screen coordinates to grid coordinates.
* @param {number} x - x in pixels
* @param {number} y - y in pixels
* @param {boolean} rounded - whether to round the coord, used for select highlighting
* @returns {number[]} a tuple of (x, y) in cells
*/
screenToGrid (x, y, rounded = false) {
let cellSize = this.getCellSize()
return [
Math.floor((x + (rounded ? cellSize.width / 2 : 0)) / cellSize.width),
Math.floor(y / cellSize.height)
]
}
/**
* Converts grid coordinates to screen coordinates.
* @param {number} x - x in cells
* @param {number} y - y in cells
* @param {boolean} [withScale] - when true, will apply window scale
* @returns {number[]} a tuple of (x, y) in pixels
*/
gridToScreen (x, y, withScale = false) {
let cellSize = this.getCellSize()
return [x * cellSize.width, y * cellSize.height].map(v => withScale ? v * this._windowScale : v)
}
/**
* The character size, used for calculating the cell size. The space character
* is used for measuring.
* @returns {Object} the character size with `width` and `height` in pixels
*/
getCharSize () {
this.ctx.font = this.getFont()
return {
width: Math.floor(this.ctx.measureText(' ').width),
height: this.window.fontSize
}
}
/**
* The cell size, which is the character size multiplied by the grid scale.
* @returns {Object} the cell size with `width` and `height` in pixels
*/
getCellSize () {
let charSize = this.getCharSize()
return {
width: Math.ceil(charSize.width * this.window.gridScaleX),
height: Math.ceil(charSize.height * this.window.gridScaleY)
}
}
/**
* Updates the canvas size if it changed
*/
updateSize () {
// see below (this is just updating it)
this._window.devicePixelRatio = Math.round(this._windowScale * (window.devicePixelRatio || 1) * 2) / 2
let didChange = false
for (let key in this.windowState) {
if (this.windowState.hasOwnProperty(key) && this.windowState[key] !== this.window[key]) {
didChange = true
this.windowState[key] = this.window[key]
}
}
if (didChange) {
const {
width,
height,
fitIntoWidth,
fitIntoHeight
} = this.window
const cellSize = this.getCellSize()
// real height of the canvas element in pixels
let realWidth = width * cellSize.width
let realHeight = height * cellSize.height
if (fitIntoWidth && fitIntoHeight) {
let terminalAspect = realWidth / realHeight
let fitAspect = fitIntoWidth / fitIntoHeight
if (terminalAspect < fitAspect) {
// align heights
realHeight = fitIntoHeight
realWidth = realHeight * terminalAspect
} else {
// align widths
realWidth = fitIntoWidth
realHeight = realWidth / terminalAspect
}
} else if (fitIntoWidth) {
realHeight = fitIntoWidth / (realWidth / realHeight)
realWidth = fitIntoWidth
} else if (fitIntoHeight) {
realWidth = fitIntoHeight * (realWidth / realHeight)
realHeight = fitIntoHeight
}
// store new window scale
this._windowScale = realWidth / (width * cellSize.width)
// the DPR must be rounded to a very nice value to prevent gaps between cells
let devicePixelRatio = this._window.devicePixelRatio = Math.round(this._windowScale * (window.devicePixelRatio || 1) * 2) / 2
this.canvas.width = width * devicePixelRatio * cellSize.width
this.canvas.style.width = `${realWidth}px`
this.canvas.height = height * devicePixelRatio * cellSize.height
this.canvas.style.height = `${realHeight}px`
// the screen has been cleared (by changing canvas width)
this.renderer.resetDrawn()
// draw immediately; the canvas shouldn't flash
this.renderer.draw('update-size')
}
}
/**
* Returns a normalized version of the current selection, such that `start`
* is always before `end`.
* @returns {Object} the normalized selection, with `start` and `end`
*/
getNormalizedSelection () {
let { start, end } = this.selection
// if the start line is after the end line, or if they're both on the same
// line but the start column comes after the end column, swap
if (start[1] > end[1] || (start[1] === end[1] && start[0] > end[0])) {
[start, end] = [end, start]
}
return { start, end }
}
/**
* Returns whether or not a given cell is in the current selection.
* @param {number} col - the column (x)
* @param {number} line - the line (y)
* @returns {boolean}
*/
isInSelection (col, line) {
let { start, end } = this.getNormalizedSelection()
let colAfterStart = start[0] <= col
let colBeforeEnd = col < end[0]
let onStartLine = line === start[1]
let onEndLine = line === end[1]
if (onStartLine && onEndLine) return colAfterStart && colBeforeEnd
else if (onStartLine) return colAfterStart
else if (onEndLine) return colBeforeEnd
else return start[1] < line && line < end[1]
}
/**
* Sweeps for selected cells and joins them in a multiline string.
* @returns {string} the selection
*/
getSelectedText () {
const screenLength = this.window.width * this.window.height
let lines = []
let previousLineIndex = -1
for (let cell = 0; cell < screenLength; cell++) {
let x = cell % this.window.width
let y = Math.floor(cell / this.window.width)
if (this.isInSelection(x, y)) {
if (previousLineIndex !== y) {
previousLineIndex = y
lines.push('')
}
lines[lines.length - 1] += this.screen[cell]
}
}
return lines.join('\n')
}
/**
* Copies the selection to clipboard and creates a notification balloon.
*/
copySelectionToClipboard () {
let selectedText = this.getSelectedText()
// don't copy anything if nothing is selected
if (!selectedText) return
let textarea = mk('textarea')
document.body.appendChild(textarea)
textarea.value = selectedText
textarea.select()
if (document.execCommand('copy')) {
notify.show('Copied to clipboard')
} else {
notify.show('Failed to copy')
}
document.body.removeChild(textarea)
}
/**
* Shows an actual notification (if possible) or a notification balloon.
* @param {string} text - the notification content
*/
showNotification (text) {
console.info(`Notification: ${text}`)
if (window.Notification && window.Notification.permission === 'granted') {
let notification = new window.Notification('ESPTerm', {
body: text
})
notification.addEventListener('click', () => window.focus())
} else {
if (window.Notification && window.Notification.permission !== 'denied') {
window.Notification.requestPermission()
} else {
// Fallback using the built-in notification balloon
notify.show(text)
}
}
}
/**
* Creates a beep sound.
*/
beep () {
const audioCtx = this.audioCtx
if (!audioCtx) return
// prevent screeching
if (this._lastBeep && this._lastBeep > Date.now() - 50) return
this._lastBeep = Date.now()
if (!this._convolver) {
this._convolver = audioCtx.createConvolver()
let impulseLength = audioCtx.sampleRate * 0.8
let impulse = audioCtx.createBuffer(2, impulseLength, audioCtx.sampleRate)
for (let i = 0; i < impulseLength; i++) {
impulse.getChannelData(0)[i] = (1 - i / impulseLength) ** (7 + Math.random())
impulse.getChannelData(1)[i] = (1 - i / impulseLength) ** (7 + Math.random())
}
this._convolver.buffer = impulse
this._convolver.connect(audioCtx.destination)
}
// main beep
const mainOsc = audioCtx.createOscillator()
const mainGain = audioCtx.createGain()
mainOsc.connect(mainGain)
mainGain.gain.value = 4
mainOsc.frequency.value = 750
mainOsc.type = 'sine'
// surrogate beep (making it sound like 'oops')
const surrOsc = audioCtx.createOscillator()
const surrGain = audioCtx.createGain()
surrOsc.connect(surrGain)
surrGain.gain.value = 2
surrOsc.frequency.value = 400
surrOsc.type = 'sine'
mainGain.connect(this._convolver)
surrGain.connect(this._convolver)
let startTime = audioCtx.currentTime
mainOsc.start()
mainOsc.stop(startTime + 0.5)
surrOsc.start(startTime + 0.05)
surrOsc.stop(startTime + 0.8)
let loop = function () {
if (audioCtx.currentTime < startTime + 0.8) window.requestAnimationFrame(loop)
mainGain.gain.value *= 0.8
surrGain.gain.value *= 0.8
}
loop()
}
load (...args) {
this.parser.load(...args)
}
}

@ -0,0 +1,269 @@
const $ = require('../lib/chibi')
const { qs } = require('../utils')
const { themes } = require('./themes')
// constants for decoding the update blob
const SEQ_REPEAT = 2
const SEQ_SET_COLORS = 3
const SEQ_SET_ATTRS = 4
const SEQ_SET_FG = 5
const SEQ_SET_BG = 6
module.exports = class ScreenParser {
constructor (screen) {
this.screen = screen
// true if TermScreen#load was called at least once
this.contentLoaded = false
}
/**
* Parses the content of an `S` message and schedules a draw
* @param {string} str - the message content
*/
loadContent (str) {
// current index
let i = 0
let strArray = Array.from ? Array.from(str) : str.split('')
// Uncomment to capture screen content for the demo page
// console.log(JSON.stringify(`S${str}`))
if (!this.contentLoaded) {
let errmsg = qs('#load-failed')
if (errmsg) errmsg.parentNode.removeChild(errmsg)
this.contentLoaded = true
}
// window size
const newHeight = strArray[i++].codePointAt(0) - 1
const newWidth = strArray[i++].codePointAt(0) - 1
const resized = (this.screen.window.height !== newHeight) || (this.screen.window.width !== newWidth)
this.screen.window.height = newHeight
this.screen.window.width = newWidth
// cursor position
let [cursorY, cursorX] = [
strArray[i++].codePointAt(0) - 1,
strArray[i++].codePointAt(0) - 1
]
let cursorMoved = (cursorX !== this.screen.cursor.x || cursorY !== this.screen.cursor.y)
this.screen.cursor.x = cursorX
this.screen.cursor.y = cursorY
if (cursorMoved) {
this.screen.renderer.resetCursorBlink()
this.screen.emit('cursor-moved')
}
// attributes
let attributes = strArray[i++].codePointAt(0) - 1
this.screen.cursor.visible = !!(attributes & 1)
this.screen.cursor.hanging = !!(attributes & (1 << 1))
this.screen.input.setAlts(
!!(attributes & (1 << 2)), // cursors alt
!!(attributes & (1 << 3)), // numpad alt
!!(attributes & (1 << 4)), // fn keys alt
!!(attributes & (1 << 12)) // crlf mode
)
let trackMouseClicks = !!(attributes & (1 << 5))
let trackMouseMovement = !!(attributes & (1 << 6))
// 0 - Block blink 2 - Block steady (1 is unused)
// 3 - Underline blink 4 - Underline steady
// 5 - I-bar blink 6 - I-bar steady
let cursorShape = (attributes >> 9) & 0x07
// if it's not zero, decrement such that the two most significant bits
// are the type and the least significant bit is the blink state
if (cursorShape > 0) cursorShape--
let cursorStyle = cursorShape >> 1
let cursorBlinking = !(cursorShape & 1)
if (cursorStyle === 0) this.screen.cursor.style = 'block'
else if (cursorStyle === 1) this.screen.cursor.style = 'line'
else if (cursorStyle === 2) this.screen.cursor.style = 'bar'
if (this.screen.cursor.blinking !== cursorBlinking) {
this.screen.cursor.blinking = cursorBlinking
this.screen.renderer.resetCursorBlink()
}
this.screen.input.setMouseMode(trackMouseClicks, trackMouseMovement)
this.screen.selection.selectable = !trackMouseClicks && !trackMouseMovement
$(this.screen.canvas).toggleClass('selectable', this.screen.selection.selectable)
this.screen.mouseMode = {
clicks: trackMouseClicks,
movement: trackMouseMovement
}
let showButtons = !!(attributes & (1 << 7))
let showConfigLinks = !!(attributes & (1 << 8))
$('.x-term-conf-btn').toggleClass('hidden', !showConfigLinks)
$('#action-buttons').toggleClass('hidden', !showButtons)
this.screen.bracketedPaste = !!(attributes & (1 << 13))
this.screen.reverseVideo = !!(attributes & (1 << 14))
// content
let fg = 7
let bg = 0
let attrs = 0
let cell = 0 // cell index
let lastChar = ' '
let screenLength = this.screen.window.width * this.screen.window.height
if (resized) {
this.screen.updateSize()
this.screen.blinkingCellCount = 0
this.screen.screen = new Array(screenLength).fill(' ')
this.screen.screenFG = new Array(screenLength).fill(' ')
this.screen.screenBG = new Array(screenLength).fill(' ')
this.screen.screenAttrs = new Array(screenLength).fill(0)
}
const MASK_LINE_ATTR = 0xC8
const MASK_BLINK = 1 << 4
let setCellContent = () => {
// Remove blink attribute if it wouldn't have any effect
let myAttrs = attrs
let hasFG = attrs & (1 << 8)
let hasBG = attrs & (1 << 9)
if ((myAttrs & MASK_BLINK) !== 0 &&
((lastChar === ' ' && ((myAttrs & MASK_LINE_ATTR) === 0)) || // no line styles
(fg === bg && hasFG && hasBG) // invisible text
)
) {
myAttrs ^= MASK_BLINK
}
// update blinking cells counter if blink state changed
if ((this.screen.screenAttrs[cell] & MASK_BLINK) !== (myAttrs & MASK_BLINK)) {
if (myAttrs & MASK_BLINK) this.screen.blinkingCellCount++
else this.screen.blinkingCellCount--
}
this.screen.screen[cell] = lastChar
this.screen.screenFG[cell] = fg
this.screen.screenBG[cell] = bg
this.screen.screenAttrs[cell] = myAttrs
}
while (i < strArray.length && cell < screenLength) {
let character = strArray[i++]
let charCode = character.codePointAt(0)
let data
switch (charCode) {
case SEQ_REPEAT:
let count = strArray[i++].codePointAt(0) - 1
for (let j = 0; j < count; j++) {
setCellContent()
if (++cell > screenLength) break
}
break
case SEQ_SET_COLORS:
data = strArray[i++].codePointAt(0) - 1
fg = data & 0xFF
bg = (data >> 8) & 0xFF
break
case SEQ_SET_ATTRS:
data = strArray[i++].codePointAt(0) - 1
attrs = data & 0xFFFF
break
case SEQ_SET_FG:
data = strArray[i++].codePointAt(0) - 1
fg = data & 0xFF
break
case SEQ_SET_BG:
data = strArray[i++].codePointAt(0) - 1
bg = data & 0xFF
break
default:
if (charCode < 32) character = '\ufffd'
lastChar = character
setCellContent()
cell++
}
}
if (this.screen.window.debug) console.log(`Blinky cells: ${this.screen.blinkingCellCount}`)
this.screen.renderer.scheduleDraw('load', 16)
this.screen.emit('load')
}
/**
* Parses the content of a `T` message and updates the screen title and button
* labels.
* @param {string} str - the message content
*/
loadLabels (str) {
let pieces = str.split('\x01')
let screenTitle = pieces[0]
qs('#screen-title').textContent = screenTitle
if (screenTitle.length === 0) screenTitle = 'Terminal'
qs('title').textContent = `${screenTitle} :: ESPTerm`
$('#action-buttons button').forEach((button, i) => {
let label = pieces[i + 1].trim()
// if empty string, use the "dim" effect and put nbsp instead to
// stretch the button vertically
button.innerHTML = label ? $.htmlEscape(label) : '&nbsp;'
button.style.opacity = label ? 1 : 0.2
})
}
/**
* Loads a message from the server, and optionally a theme.
* @param {string} str - the message
* @param {object} [opts] - options
* @param {number} [opts.theme] - theme
* @param {number} [opts.defaultFg] - default foreground
* @param {number} [opts.defaultBg] - default background
*/
load (str, opts = null) {
const content = str.substr(1)
if (opts) {
if (typeof opts.defaultFg !== 'undefined' && typeof opts.defaultBg !== 'undefined') {
this.screen.renderer.setDefaultColors(opts.defaultFg, opts.defaultBg)
}
if (typeof opts.theme !== 'undefined') {
if (opts.theme >= 0 && opts.theme < themes.length) {
this.screen.renderer.palette = themes[opts.theme]
}
}
}
switch (str[0]) {
case 'S':
this.loadContent(content)
break
case 'T':
this.loadLabels(content)
break
case 'B':
this.screen.beep()
break
case 'G':
this.screen.showNotification(content)
break
default:
console.warn(`Bad data message type; ignoring.\n${JSON.stringify(str)}`)
}
}
}

@ -0,0 +1,690 @@
const { themes, buildColorTable, SELECTION_FG, SELECTION_BG } = require('./themes')
// Some non-bold Fraktur symbols are outside the contiguous block
const frakturExceptions = {
'C': '\u212d',
'H': '\u210c',
'I': '\u2111',
'R': '\u211c',
'Z': '\u2128'
}
module.exports = class ScreenRenderer {
constructor (screen) {
this.screen = screen
this.ctx = screen.ctx
this._palette = null // colors 0-15
this.defaultBgNum = 0
this.defaultFgNum = 7
// 256color lookup table
// should not be used to look up 0-15 (will return transparent)
this.colorTable256 = buildColorTable()
this.resetDrawn()
this.blinkStyleOn = false
this.blinkInterval = null
this.cursorBlinkOn = false
this.cursorBlinkInterval = null
// start blink timers
this.resetBlink()
this.resetCursorBlink()
}
resetDrawn () {
// used to determine if a cell should be redrawn; storing the current state
// as it is on screen
this.drawnScreen = []
this.drawnScreenFG = []
this.drawnScreenBG = []
this.drawnScreenAttrs = []
this.drawnCursor = [-1, -1, '']
}
/**
* The color palette. Should define 16 colors in an array.
* @type {string[]}
*/
get palette () {
return this._palette || themes[0]
}
/** @type {string[]} */
set palette (palette) {
if (this._palette !== palette) {
this._palette = palette
this.resetDrawn()
this.scheduleDraw('palette')
}
}
setDefaultColors (fg, bg) {
this.defaultFgNum = fg
this.defaultBgNum = bg
this.resetDrawn()
this.scheduleDraw('defaultColors')
}
/**
* Schedule a draw in the next millisecond
* @param {string} why - the reason why the draw occured (for debugging)
* @param {number} [aggregateTime] - time to wait for more scheduleDraw calls
* to occur. 1 ms by default.
*/
scheduleDraw (why, aggregateTime = 1) {
clearTimeout(this._scheduledDraw)
this._scheduledDraw = setTimeout(() => this.draw(why), aggregateTime)
}
/**
* Returns the specified color. If `i` is in the palette, it will return the
* palette color. If `i` is between 16 and 255, it will return the 256color
* value. If `i` is larger than 255, it will return an RGB color value. If `i`
* is -1 (foreground) or -2 (background), it will return the selection colors.
* @param {number} i - the color
* @returns {string} the CSS color
*/
getColor (i) {
// return palette color if it exists
if (i < 16 && i in this.palette) return this.palette[i]
// -1 for selection foreground, -2 for selection background
if (i === -1) return SELECTION_FG
if (i === -2) return SELECTION_BG
// 256 color
if (i > 15 && i < 256) return this.colorTable256[i]
// true color, encoded as (hex) + 256 (such that #000 == 256)
if (i > 255) {
i -= 256
let red = (i >> 16) & 0xFF
let green = (i >> 8) & 0xFF
let blue = i & 0xFF
return `rgb(${red}, ${green}, ${blue})`
}
// return error color
return (Date.now() / 1000) % 2 === 0 ? '#f0f' : '#0f0'
}
/**
* Resets the cursor blink to on and restarts the timer
*/
resetCursorBlink () {
this.cursorBlinkOn = true
clearInterval(this.cursorBlinkInterval)
this.cursorBlinkInterval = setInterval(() => {
this.cursorBlinkOn = this.screen.cursor.blinking
? !this.cursorBlinkOn
: true
if (this.screen.cursor.blinking) this.scheduleDraw('cursor-blink')
}, 500)
}
/**
* Resets the blink style to on and restarts the timer
*/
resetBlink () {
this.blinkStyleOn = true
clearInterval(this.blinkInterval)
let intervals = 0
this.blinkInterval = setInterval(() => {
if (this.screen.blinkingCellCount <= 0) return
intervals++
if (intervals >= 4 && this.blinkStyleOn) {
this.blinkStyleOn = false
intervals = 0
this.scheduleDraw('blink-style')
} else if (intervals >= 1 && !this.blinkStyleOn) {
this.blinkStyleOn = true
intervals = 0
this.scheduleDraw('blink-style')
}
}, 200)
}
/**
* Draws a cell's background with the given parameters.
* @param {Object} options
* @param {number} options.x - x in cells
* @param {number} options.y - y in cells
* @param {number} options.cellWidth - cell width in pixels
* @param {number} options.cellHeight - cell height in pixels
* @param {number} options.bg - the background color
*/
drawBackground ({ x, y, cellWidth, cellHeight, bg }) {
const ctx = this.ctx
ctx.fillStyle = this.getColor(bg)
ctx.clearRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight)
ctx.fillRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight)
}
/**
* Draws a cell's character with the given parameters. Won't do anything if
* text is an empty string.
* @param {Object} options
* @param {number} options.x - x in cells
* @param {number} options.y - y in cells
* @param {Object} options.charSize - the character size, an object with
* `width` and `height` in pixels
* @param {number} options.cellWidth - cell width in pixels
* @param {number} options.cellHeight - cell height in pixels
* @param {string} options.text - the cell content
* @param {number} options.fg - the foreground color
* @param {number} options.attrs - the cell's attributes
*/
drawCharacter ({ x, y, charSize, cellWidth, cellHeight, text, fg, attrs }) {
if (!text) return
const ctx = this.ctx
let underline = false
let strike = false
let overline = false
if (attrs & (1 << 1)) ctx.globalAlpha = 0.5
if (attrs & (1 << 3)) underline = true
if (attrs & (1 << 5)) text = ScreenRenderer.alphaToFraktur(text)
if (attrs & (1 << 6)) strike = true
if (attrs & (1 << 7)) overline = true
ctx.fillStyle = this.getColor(fg)
let codePoint = text.codePointAt(0)
if (codePoint >= 0x2580 && codePoint <= 0x259F) {
// block elements
ctx.beginPath()
const left = x * cellWidth
const top = y * cellHeight
const cw = cellWidth
const ch = cellHeight
const c2w = cellWidth / 2
const c2h = cellHeight / 2
// http://www.fileformat.info/info/unicode/block/block_elements/utf8test.htm
// 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B 0x0C 0x0D 0x0E 0x0F
// 0x2580 ▀ ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ ▉ ▊ ▋ ▌ ▍ ▎ ▏
// 0x2590 ▐ ░ ▒ ▓ ▔ ▕ ▖ ▗ ▘ ▙ ▚ ▛ ▜ ▝ ▞ ▟
if (codePoint === 0x2580) {
// upper half block >▀<
ctx.rect(left, top, cw, c2h)
} else if (codePoint <= 0x2588) {
// lower n eighth block (increasing) >▁< to >█<
let offset = (1 - (codePoint - 0x2580) / 8) * ch
ctx.rect(left, top + offset, cw, ch - offset)
} else if (codePoint <= 0x258F) {
// left n eighth block (decreasing) >▉< to >▏<
let offset = (codePoint - 0x2588) / 8 * cw
ctx.rect(left, top, cw - offset, ch)
} else if (codePoint === 0x2590) {
// right half block >▐<
ctx.rect(left + c2w, top, c2w, ch)
} else if (codePoint <= 0x2593) {
// shading >░< >▒< >▓<
// dot spacing by dividing cell size by a constant. This could be
// reworked to always return a whole number, but that would require
// prime factorization, and doing that without a loop would let you
// take over the world, which is not within the scope of this project.
let dotSpacingX, dotSpacingY, dotSize
if (codePoint === 0x2591) {
dotSpacingX = cw / 4
dotSpacingY = ch / 10
dotSize = 1
} else if (codePoint === 0x2592) {
dotSpacingX = cw / 6
dotSpacingY = cw / 10
dotSize = 1
} else if (codePoint === 0x2593) {
dotSpacingX = cw / 4
dotSpacingY = cw / 7
dotSize = 2
}
let alignRight = false
for (let dy = 0; dy < ch; dy += dotSpacingY) {
for (let dx = 0; dx < cw; dx += dotSpacingX) {
// prevent overflow
let dotSizeY = Math.min(dotSize, ch - dy)
ctx.rect(x * cw + (alignRight ? cw - dx - dotSize : dx), y * ch + dy, dotSize, dotSizeY)
}
alignRight = !alignRight
}
} else if (codePoint === 0x2594) {
// upper one eighth block >▔<
ctx.rect(x * cw, y * ch, cw, ch / 8)
} else if (codePoint === 0x2595) {
// right one eighth block >▕<
ctx.rect((x + 7 / 8) * cw, y * ch, cw / 8, ch)
} else if (codePoint === 0x2596) {
// left bottom quadrant >▖<
ctx.rect(left, top + c2h, c2w, c2h)
} else if (codePoint === 0x2597) {
// right bottom quadrant >▗<
ctx.rect(left + c2w, top + c2h, c2w, c2h)
} else if (codePoint === 0x2598) {
// left top quadrant >▘<
ctx.rect(left, top, c2w, c2h)
} else if (codePoint === 0x2599) {
// left chair >▙<
ctx.rect(left, top, c2w, ch)
ctx.rect(left + c2w, top + c2h, c2w, c2h)
} else if (codePoint === 0x259A) {
// quadrants lt rb >▚<
ctx.rect(left, top, c2w, c2h)
ctx.rect(left + c2w, top + c2h, c2w, c2h)
} else if (codePoint === 0x259B) {
// left chair upside down >▛<
ctx.rect(left, top, c2w, ch)
ctx.rect(left + c2w, top, c2w, c2h)
} else if (codePoint === 0x259C) {
// right chair upside down >▜<
ctx.rect(left, top, cw, c2h)
ctx.rect(left + c2w, top + c2h, c2w, c2h)
} else if (codePoint === 0x259D) {
// right top quadrant >▝<
ctx.rect(left + c2w, top, c2w, c2h)
} else if (codePoint === 0x259E) {
// quadrants lb rt >▞<
ctx.rect(left, top + c2h, c2w, c2h)
ctx.rect(left + c2w, top, c2w, c2h)
} else if (codePoint === 0x259F) {
// right chair upside down >▟<
ctx.rect(left, top + c2h, c2w, c2h)
ctx.rect(left + c2w, top, c2w, ch)
}
ctx.fill()
} else {
// Draw other characters using the text renderer
ctx.fillText(text, (x + 0.5) * cellWidth, (y + 0.5) * cellHeight)
}
// -- line drawing - a reference for a possible future rect/line implementation ---
// http://www.fileformat.info/info/unicode/block/box_drawing/utf8test.htm
// 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B 0x0C 0x0D 0x0E 0x0F
// 0x2500 ─ ━ │ ┃ ┄ ┅ ┆ ┇ ┈ ┉ ┊ ┋ ┌ ┍ ┎ ┏
// 0x2510 ┐ ┑ ┒ ┓ └ ┕ ┖ ┗ ┘ ┙ ┚ ┛ ├ ┝ ┞ ┟
// 0x2520 ┠ ┡ ┢ ┣ ┤ ┥ ┦ ┧ ┨ ┩ ┪ ┫ ┬ ┭ ┮ ┯
// 0x2530 ┰ ┱ ┲ ┳ ┴ ┵ ┶ ┷ ┸ ┹ ┺ ┻ ┼ ┽ ┾ ┿
// 0x2540 ╀ ╁ ╂ ╃ ╄ ╅ ╆ ╇ ╈ ╉ ╊ ╋ ╌ ╍ ╎ ╏
// 0x2550 ═ ║ ╒ ╓ ╔ ╕ ╖ ╗ ╘ ╙ ╚ ╛ ╜ ╝ ╞ ╟
// 0x2560 ╠ ╡ ╢ ╣ ╤ ╥ ╦ ╧ ╨ ╩ ╪ ╫ ╬ ╭ ╮ ╯
// 0x2570 ╰ ╱ ╲ ╳ ╴ ╵ ╶ ╷ ╸ ╹ ╺ ╻ ╼ ╽ ╾ ╿
if (underline || strike || overline) {
ctx.strokeStyle = this.getColor(fg)
ctx.lineWidth = 1
ctx.lineCap = 'round'
ctx.beginPath()
if (underline) {
let lineY = Math.round(y * cellHeight + charSize.height) + 0.5
ctx.moveTo(x * cellWidth, lineY)
ctx.lineTo((x + 1) * cellWidth, lineY)
}
if (strike) {
let lineY = Math.round((y + 0.5) * cellHeight) + 0.5
ctx.moveTo(x * cellWidth, lineY)
ctx.lineTo((x + 1) * cellWidth, lineY)
}
if (overline) {
let lineY = Math.round(y * cellHeight) + 0.5
ctx.moveTo(x * cellWidth, lineY)
ctx.lineTo((x + 1) * cellWidth, lineY)
}
ctx.stroke()
}
ctx.globalAlpha = 1
}
/**
* Returns all adjacent cell indices given a radius.
* @param {number} cell - the center cell index
* @param {number} [radius] - the radius. 1 by default
* @returns {number[]} an array of cell indices
*/
getAdjacentCells (cell, radius = 1) {
const { width, height } = this.screen.window
const screenLength = width * height
let cells = []
for (let x = -radius; x <= radius; x++) {
for (let y = -radius; y <= radius; y++) {
if (x === 0 && y === 0) continue
cells.push(cell + x + y * width)
}
}
return cells.filter(cell => cell >= 0 && cell < screenLength)
}
/**
* Updates the screen.
* @param {string} why - the draw reason (for debugging)
*/
draw (why) {
const ctx = this.ctx
const {
width,
height,
devicePixelRatio,
statusScreen
} = this.screen.window
if (statusScreen) {
// draw status screen instead
this.drawStatus(statusScreen)
this.startDrawLoop()
return
} else this.stopDrawLoop()
const charSize = this.screen.getCharSize()
const { width: cellWidth, height: cellHeight } = this.screen.getCellSize()
const screenLength = width * height
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawStart(why)
ctx.font = this.screen.getFont()
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// bits in the attr value that affect the font
const FONT_MASK = 0b101
// Map of (attrs & FONT_MASK) -> Array of cell indices
let fontGroups = new Map()
// Map of (cell index) -> boolean, whether or not a cell has updated
let updateMap = new Map()
for (let cell = 0; cell < screenLength; cell++) {
let x = cell % width
let y = Math.floor(cell / width)
let isCursor = !this.screen.cursor.hanging &&
this.screen.cursor.x === x &&
this.screen.cursor.y === y &&
this.screen.cursor.visible &&
this.cursorBlinkOn
let wasCursor = x === this.drawnCursor[0] && y === this.drawnCursor[1]
let inSelection = this.screen.isInSelection(x, y)
let text = this.screen.screen[cell]
let fg = this.screen.screenFG[cell] | 0
let bg = this.screen.screenBG[cell] | 0
let attrs = this.screen.screenAttrs[cell] | 0
if (!(attrs & (1 << 8))) fg = this.defaultFgNum
if (!(attrs & (1 << 9))) bg = this.defaultBgNum
if (attrs & (1 << 10)) [fg, bg] = [bg, fg] // swap - reversed character colors
if (this.screen.reverseVideo) [fg, bg] = [bg, fg] // swap - reversed all screen
if (attrs & (1 << 4) && !this.blinkStyleOn) {
// blinking is enabled and blink style is off
// set text to nothing so drawCharacter doesn't draw anything
text = ''
}
if (inSelection) {
fg = -1
bg = -2
}
let didUpdate = text !== this.drawnScreen[cell] || // text updated
fg !== this.drawnScreenFG[cell] || // foreground updated, and this cell has text
bg !== this.drawnScreenBG[cell] || // background updated
attrs !== this.drawnScreenAttrs[cell] || // attributes updated
isCursor !== wasCursor || // cursor blink/position updated
(isCursor && this.screen.cursor.style !== this.drawnCursor[2]) // cursor style updated
let font = attrs & FONT_MASK
if (!fontGroups.has(font)) fontGroups.set(font, [])
fontGroups.get(font).push({ cell, x, y, text, fg, bg, attrs, isCursor, inSelection })
updateMap.set(cell, didUpdate)
}
// Map of (cell index) -> boolean, whether or not a cell should be redrawn
const redrawMap = new Map()
let isTextWide = text =>
text !== ' ' && ctx.measureText(text).width >= (cellWidth + 0.05)
// decide for each cell if it should be redrawn
let updateRedrawMapAt = cell => {
let shouldUpdate = updateMap.get(cell) || redrawMap.get(cell) || false
// TODO: fonts (necessary?)
let text = this.screen.screen[cell]
let isWideCell = isTextWide(text)
let checkRadius = isWideCell ? 2 : 1
if (!shouldUpdate) {
// check adjacent cells
let adjacentDidUpdate = false
for (let adjacentCell of this.getAdjacentCells(cell, checkRadius)) {
// update this cell if:
// - the adjacent cell updated (For now, this'll always be true because characters can be slightly larger than they say they are)
// - the adjacent cell updated and this cell or the adjacent cell is wide
if (updateMap.get(adjacentCell) && (this.screen.window.graphics < 2 || isWideCell || isTextWide(this.screen.screen[adjacentCell]))) {
adjacentDidUpdate = true
break
}
}
if (adjacentDidUpdate) shouldUpdate = true
}
redrawMap.set(cell, shouldUpdate)
}
for (let cell of updateMap.keys()) updateRedrawMapAt(cell)
// mask to redrawing regions only
if (this.screen.window.graphics >= 1) {
let debug = this.screen.window.debug && this.screen._debug
ctx.save()
ctx.beginPath()
for (let y = 0; y < height; y++) {
let regionStart = null
for (let x = 0; x < width; x++) {
let cell = y * width + x
let redrawing = redrawMap.get(cell)
if (redrawing && regionStart === null) regionStart = x
if (!redrawing && regionStart !== null) {
ctx.rect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight)
if (debug) this.screen._debug.clipRect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight)
regionStart = null
}
}
if (regionStart !== null) {
ctx.rect(regionStart * cellWidth, y * cellHeight, (width - regionStart) * cellWidth, cellHeight)
if (debug) this.screen._debug.clipRect(regionStart * cellWidth, y * cellHeight, (width - regionStart) * cellWidth, cellHeight)
}
}
ctx.clip()
}
// pass 1: backgrounds
for (let font of fontGroups.keys()) {
for (let data of fontGroups.get(font)) {
let { cell, x, y, text, bg } = data
if (redrawMap.get(cell)) {
this.drawBackground({ x, y, cellWidth, cellHeight, bg })
if (this.screen.window.debug && this.screen._debug) {
// set cell flags
let flags = (+redrawMap.get(cell))
flags |= (+updateMap.get(cell)) << 1
flags |= (+isTextWide(text)) << 2
this.screen._debug.setCell(cell, flags)
}
}
}
}
// reset drawn cursor
this.drawnCursor = [-1, -1, -1]
// pass 2: characters
for (let font of fontGroups.keys()) {
// set font once because in Firefox, this is a really slow action for some
// reason
let modifiers = {}
if (font & 1) modifiers.weight = 'bold'
if (font & 1 << 2) modifiers.style = 'italic'
ctx.font = this.screen.getFont(modifiers)
for (let data of fontGroups.get(font)) {
let { cell, x, y, text, fg, bg, attrs, isCursor, inSelection } = data
if (redrawMap.get(cell)) {
this.drawCharacter({
x, y, charSize, cellWidth, cellHeight, text, fg, attrs
})
this.drawnScreen[cell] = text
this.drawnScreenFG[cell] = fg
this.drawnScreenBG[cell] = bg
this.drawnScreenAttrs[cell] = attrs
if (isCursor) this.drawnCursor = [x, y, this.screen.cursor.style]
if (isCursor && !inSelection) {
ctx.save()
ctx.beginPath()
if (this.screen.cursor.style === 'block') {
// block
ctx.rect(x * cellWidth, y * cellHeight, cellWidth, cellHeight)
} else if (this.screen.cursor.style === 'bar') {
// vertical bar
let barWidth = 2
ctx.rect(x * cellWidth, y * cellHeight, barWidth, cellHeight)
} else if (this.screen.cursor.style === 'line') {
// underline
let lineHeight = 2
ctx.rect(x * cellWidth, y * cellHeight + charSize.height, cellWidth, lineHeight)
}
ctx.clip()
// swap foreground/background
;[fg, bg] = [bg, fg]
// HACK: ensure cursor is visible
if (fg === bg) bg = fg === 0 ? 7 : 0
this.drawBackground({ x, y, cellWidth, cellHeight, bg })
this.drawCharacter({
x, y, charSize, cellWidth, cellHeight, text, fg, attrs
})
ctx.restore()
}
}
}
}
if (this.screen.window.graphics >= 1) ctx.restore()
if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawEnd()
this.screen.emit('draw')
}
drawStatus (statusScreen) {
const ctx = this.ctx
const {
fontFamily,
width,
height,
devicePixelRatio
} = this.screen.window
// reset drawnScreen to force redraw when statusScreen is disabled
this.drawnScreen = []
const cellSize = this.screen.getCellSize()
const screenWidth = width * cellSize.width
const screenHeight = height * cellSize.height
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
ctx.clearRect(0, 0, screenWidth, screenHeight)
ctx.font = `24px ${fontFamily}`
ctx.fillStyle = '#fff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(statusScreen.title || '', screenWidth / 2, screenHeight / 2 - 50)
if (statusScreen.loading) {
// show loading spinner
ctx.save()
ctx.translate(screenWidth / 2, screenHeight / 2 + 20)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 5
ctx.lineCap = 'round'
let t = Date.now() / 1000
for (let i = 0; i < 12; i++) {
ctx.rotate(Math.PI / 6)
let offset = ((t * 12) - i) % 12
ctx.globalAlpha = Math.max(0.2, 1 - offset / 3)
ctx.beginPath()
ctx.moveTo(0, 15)
ctx.lineTo(0, 30)
ctx.stroke()
}
ctx.restore()
}
}
startDrawLoop () {
if (this._drawTimerThread) return
let threadID = Math.random().toString(36)
this._drawTimerThread = threadID
this.drawTimerLoop(threadID)
}
stopDrawLoop () {
this._drawTimerThread = null
}
drawTimerLoop (threadID) {
if (!threadID || threadID !== this._drawTimerThread) return
window.requestAnimationFrame(() => this.drawTimerLoop(threadID))
this.draw('draw-loop')
}
/**
* Converts an alphabetic character to its fraktur variant.
* @param {string} character - the character
* @returns {string} the converted character
*/
static alphaToFraktur (character) {
if (character >= 'a' && character <= 'z') {
character = String.fromCodePoint(0x1d51e - 0x61 + character.charCodeAt(0))
} else if (character >= 'A' && character <= 'Z') {
character = frakturExceptions[character] || String.fromCodePoint(0x1d504 - 0x41 + character.charCodeAt(0))
}
return character
}
}

@ -1,7 +1,15 @@
window.initSoftKeyboard = function (screen, input) {
const { qs } = require('../utils')
module.exports = function (screen, input) {
const keyInput = qs('#softkb-input')
if (!keyInput) return // abort, we're not on the terminal page
const shortcutBar = document.createElement('div')
shortcutBar.id = 'keyboard-shortcut-bar'
if (navigator.userAgent.match(/iPad|iPhone|iPod/)) {
qs('#screen').appendChild(shortcutBar)
}
let keyboardOpen = false
// moves the input to where the cursor is on the canvas.
@ -17,9 +25,13 @@ window.initSoftKeyboard = function (screen, input) {
keyInput.addEventListener('focus', () => {
keyboardOpen = true
updateInputPosition()
shortcutBar.classList.add('open')
})
keyInput.addEventListener('blur', () => (keyboardOpen = false))
keyInput.addEventListener('blur', () => {
keyboardOpen = false
shortcutBar.classList.remove('open')
})
screen.on('cursor-moved', updateInputPosition)
@ -33,7 +45,6 @@ window.initSoftKeyboard = function (screen, input) {
// that deals with the input composition events.
let lastCompositionString = ''
let compositing = false
// sends the difference between the last and the new composition string
let sendInputDelta = function (newValue) {
@ -64,13 +75,8 @@ window.initSoftKeyboard = function (screen, input) {
keyInput.value = ''
if (e.key === 'Backspace') {
e.preventDefault()
input.sendString('\b')
} else if (e.key === 'Enter') {
e.preventDefault()
input.sendString('\x0d')
}
e.stopPropagation()
input.handleKeyDown(e)
})
keyInput.addEventListener('keypress', e => {
@ -81,8 +87,12 @@ window.initSoftKeyboard = function (screen, input) {
keyInput.addEventListener('input', e => {
e.stopPropagation()
if (e.isComposing) {
if (e.isComposing && 'data' in e) {
sendInputDelta(e.data)
} else if (e.isComposing) {
// Firefox Mobile doesn't support InputEvent#data, so here's a hack
// that just takes the input value and uses that
sendInputDelta(keyInput.value)
} else {
if (e.inputType === 'insertCompositionText') input.sendString(e.data)
else if (e.inputType === 'deleteContentBackward') {
@ -96,14 +106,61 @@ window.initSoftKeyboard = function (screen, input) {
keyInput.addEventListener('compositionstart', e => {
lastCompositionString = ''
compositing = true
})
keyInput.addEventListener('compositionend', e => {
lastCompositionString = ''
compositing = false
keyInput.value = ''
})
screen.on('open-soft-keyboard', () => keyInput.focus())
// shortcut bar
const shortcuts = {
Control: 'ctrl',
Esc: 0x1b,
Tab: 0x09,
'←': 0x25,
'↓': 0x28,
'↑': 0x26,
'→': 0x27
}
let touchMoved = false
for (const shortcut in shortcuts) {
const button = document.createElement('button')
button.classList.add('shortcut-button')
button.textContent = shortcut
shortcutBar.appendChild(button)
const key = shortcuts[shortcut]
if (typeof key === 'string') button.classList.add('modifier')
button.addEventListener('touchstart', e => {
touchMoved = false
if (typeof key === 'string') {
// modifier button
input.softModifiers[key] = true
button.classList.add('enabled')
// prevent default. This prevents scrolling, but also prevents the
// selection popup
e.preventDefault()
}
})
window.addEventListener('touchmove', e => {
touchMoved = true
})
button.addEventListener('touchend', e => {
e.preventDefault()
if (typeof key === 'number') {
if (touchMoved) return
let fakeEvent = { which: key, preventDefault: () => {} }
input.handleKeyDown(fakeEvent)
} else if (typeof key === 'string') {
button.classList.remove('enabled')
input.softModifiers[key] = false
}
})
}
}

@ -0,0 +1,111 @@
const themes = exports.themes = [
[ // Tango
'#111213', '#CC0000', '#4E9A06', '#C4A000', '#3465A4', '#75507B', '#06989A', '#D3D7CF',
'#555753', '#EF2929', '#8AE234', '#FCE94F', '#729FCF', '#AD7FA8', '#34E2E2', '#EEEEEC'
],
[ // Linux (CGA)
'#000000', '#aa0000', '#00aa00', '#aa5500', '#0000aa', '#aa00aa', '#00aaaa', '#aaaaaa',
'#555555', '#ff5555', '#55ff55', '#ffff55', '#5555ff', '#ff55ff', '#55ffff', '#ffffff'
],
[ // xterm
'#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5',
'#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff'
],
[ // rxvt
'#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000cd', '#cd00cd', '#00cdcd', '#faebd7',
'#404040', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff'
],
[ // Ambience
'#2e3436', '#cc0000', '#4e9a06', '#c4a000', '#3465a4', '#75507b', '#06989a', '#d3d7cf',
'#555753', '#ef2929', '#8ae234', '#fce94f', '#729fcf', '#ad7fa8', '#34e2e2', '#eeeeec'
],
[ // Solarized light
'#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5',
'#002b36', '#cb4b16', '#586e75', '#657b83', '#839496', '#6c71c4', '#93a1a1', '#fdf6e3'
],
[ // CGA NTSC
'#000000', '#69001A', '#117800', '#769100', '#1A00A6', '#8019AB', '#289E76', '#A4A4A4',
'#484848', '#C54E76', '#6DD441', '#D2ED46', '#765BFF', '#DC75FF', '#84FAD2', '#FFFFFF'
],
[ // ZX Spectrum
'#000000', '#aa0000', '#00aa00', '#aaaa00', '#0000aa', '#aa00aa', '#00aaaa', '#aaaaaa',
'#000000', '#ff0000', '#00FF00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff'
],
[ // Apple II
'#000000', '#722640', '#0E5940', '#808080', '#40337F', '#E434FE', '#1B9AFE', '#BFB3FF',
'#404C00', '#E46501', '#1BCB01', '#BFCC80', '#808080', '#F1A6BF', '#8DD9BF', '#ffffff'
],
[ // Commodore
'#000000', '#8D3E37', '#55A049', '#AAB95D', '#40318D', '#80348B', '#72C1C8', '#D59F74',
'#8B5429', '#B86962', '#94E089', '#FFFFB2', '#8071CC', '#AA5FB6', '#87D6DD', '#ffffff'
]
]
exports.fgbgThemes = [
['#AAAAAA', '#000000'], // GREY_ON_BLACK
['#EFF0F1', '#31363B'], // BREEZE
['#FFFFFF', '#000000'], // WHITE_ON_BLACK
['#00FF00', '#000000'], // GREEN_ON_BLACK
['#E53C00', '#000000'], // ORANGE_ON_BLACK
['#FFFFFF', '#300A24'], // AMBIENCE
['#839496', '#002B36'], // SOLARIZED_DARK
['#657B83', '#FDF6E3'], // SOLARIZED_LIGHT
['#000000', '#FFFFDD'], // BLACK_ON_YELLOW
['#000000', '#FFFFFF'] // BLACK_ON_WHITE
]
let colorTable256 = null
exports.buildColorTable = function () {
if (colorTable256 !== null) return colorTable256
// 256color lookup table
// should not be used to look up 0-15 (will return transparent)
colorTable256 = new Array(16).fill('rgba(0, 0, 0, 0)')
// fill color table
// colors 16-231 are a 6x6x6 color cube
for (let red = 0; red < 6; red++) {
for (let green = 0; green < 6; green++) {
for (let blue = 0; blue < 6; blue++) {
let redValue = red * 40 + (red ? 55 : 0)
let greenValue = green * 40 + (green ? 55 : 0)
let blueValue = blue * 40 + (blue ? 55 : 0)
colorTable256.push(`rgb(${redValue}, ${greenValue}, ${blueValue})`)
}
}
}
// colors 232-255 are a grayscale ramp, sans black and white
for (let gray = 0; gray < 24; gray++) {
let value = gray * 10 + 8
colorTable256.push(`rgb(${value}, ${value}, ${value})`)
}
return colorTable256
}
exports.SELECTION_FG = '#333'
exports.SELECTION_BG = '#b2d7fe'
function resolveColor (themeN, shade) {
shade = +shade
if (shade < 16) shade = themes[themeN][shade]
else {
shade = exports.buildColorTable()[shade]
}
return shade
}
exports.themePreview = function (n) {
document.querySelectorAll('[data-fg]').forEach((elem) => {
let shade = elem.dataset.fg
if (/^\d+$/.test(shade)) shade = resolveColor(n, shade)
elem.style.color = shade
})
document.querySelectorAll('[data-bg]').forEach((elem) => {
let shade = elem.dataset.bg
if (/^\d+$/.test(shade)) shade = resolveColor(n, shade)
elem.style.backgroundColor = shade
})
}

@ -1,5 +1,9 @@
const $ = require('../lib/chibi')
const { qs } = require('../utils')
const modal = require('../modal')
/** File upload utility */
window.TermUpl = function (conn, input, screen) {
module.exports = function (conn, input, screen) {
let lines, // array of lines without newlines
line_i, // current line index
fuTout, // timeout handle for line sending
@ -14,7 +18,7 @@ window.TermUpl = function (conn, input, screen) {
function openUploadDialog () {
updateStatus('Ready...')
Modal.show('#fu_modal', onDialogClose)
modal.show('#fu_modal', onDialogClose)
$('#fu_form').toggleClass('busy', false)
input.blockKeys(true)
}
@ -125,19 +129,19 @@ window.TermUpl = function (conn, input, screen) {
}
function fuClose () {
Modal.hide('#fu_modal')
modal.hide('#fu_modal')
}
return {
init: function () {
qs('#fu_file').addEventListener('change', function (evt) {
let reader = new FileReader()
let reader = new window.FileReader()
let file = evt.target.files[0]
let ftype = file.type || 'application/octet-stream'
console.log('Selected file type: ' + ftype)
if (!ftype.match(/text\/.*|application\/(json|csv|.*xml.*|.*script.*|x-php)/)) {
// Deny load of blobs like img - can crash browser and will get corrupted anyway
if (!confirm(`This does not look like a text file: ${ftype}\nReally load?`)) {
if (!window.confirm(`This does not look like a text file: ${ftype}\nReally load?`)) {
qs('#fu_file').value = ''
return
}
@ -164,6 +168,10 @@ window.TermUpl = function (conn, input, screen) {
fuClose()
return false
})
},
open: openUploadDialog,
setContent (content) {
qs('#fu_text').value = content
}
}
}

@ -1,144 +0,0 @@
/** Handle connections */
window.Conn = function (screen) {
let ws
let heartbeatTout
let pingIv
let xoff = false
let autoXoffTout
let reconTout
let pageShown = false
function onOpen (evt) {
console.log('CONNECTED')
heartbeat()
doSend('i')
}
function onClose (evt) {
console.warn('SOCKET CLOSED, code ' + evt.code + '. Reconnecting...')
clearTimeout(reconTout)
reconTout = setTimeout(function () {
init()
}, 2000)
// this happens when the buffer gets fucked up via invalid unicode.
// we basically use polling instead of socket then
}
function onMessage (evt) {
try {
// . = heartbeat
switch (evt.data.charAt(0)) {
case '.':
// heartbeat, no-op message
break
case '-':
// console.log('xoff');
xoff = true
autoXoffTout = setTimeout(function () {
xoff = false
}, 250)
break
case '+':
// console.log('xon');
xoff = false
clearTimeout(autoXoffTout)
break
default:
screen.load(evt.data)
if (!pageShown) {
showPage()
pageShown = true
}
break
}
heartbeat()
} catch (e) {
console.error(e)
}
}
function canSend () {
return !xoff
}
function doSend (message) {
if (_demo) {
if (typeof demoInterface !== 'undefined') {
demoInterface.input(message)
} else {
console.log(`TX: ${JSON.stringify(message)}`)
}
return true // Simulate success
}
if (xoff) {
// TODO queue
console.log("Can't send, flood control.")
return false
}
if (!ws) return false // for dry testing
if (ws.readyState !== 1) {
console.error('Socket not ready')
return false
}
if (typeof message != 'string') {
message = JSON.stringify(message)
}
ws.send(message)
return true
}
function init () {
if (window._demo) {
if (typeof demoInterface === 'undefined') {
alert('Demoing non-demo demo!') // this will catch mistakes when deploying to the website
} else {
demoInterface.init(screen)
showPage()
}
return
}
clearTimeout(reconTout)
clearTimeout(heartbeatTout)
ws = new WebSocket('ws://' + _root + '/term/update.ws')
ws.onopen = onOpen
ws.onclose = onClose
ws.onmessage = onMessage
console.log('Opening socket.')
heartbeat()
}
function heartbeat () {
clearTimeout(heartbeatTout)
heartbeatTout = setTimeout(heartbeatFail, 2000)
}
function heartbeatFail () {
console.error('Heartbeat lost, probing server...')
pingIv = setInterval(function () {
console.log('> ping')
$.get('http://' + _root + '/system/ping', function (resp, status) {
if (status === 200) {
clearInterval(pingIv)
console.info('Server ready, reloading page...')
location.reload()
}
}, {
timeout: 100
})
}, 1000)
}
return {
ws: null,
init,
send: doSend,
canSend // check flood control
}
}

@ -1,303 +0,0 @@
/**
* User input
*
* --- Rx messages: ---
* S - screen content (binary encoding of the entire screen with simple compression)
* T - text labels - Title and buttons, \0x01-separated
* B - beep
* . - heartbeat
*
* --- Tx messages ---
* s - string
* b - action button
* p - mb press
* r - mb release
* m - mouse move
*/
window.Input = function (conn) {
let cfg = {
np_alt: false,
cu_alt: false,
fn_alt: false,
mt_click: false,
mt_move: false,
no_keys: false,
crlf_mode: false
}
/** Send a literal message */
function sendStrMsg (str) {
return conn.send('s' + str)
}
/** Send a button event */
function sendBtnMsg (n) {
conn.send('b' + String.fromCharCode(n))
}
/** Fn alt choice for key message */
function fa (alt, normal) {
return cfg.fn_alt ? alt : normal
}
/** Cursor alt choice for key message */
function ca (alt, normal) {
return cfg.cu_alt ? alt : normal
}
/** Numpad alt choice for key message */
function na (alt, normal) {
return cfg.np_alt ? alt : normal
}
function bindFnKeys (allFn) {
const keymap = {
'tab': '\x09',
'backspace': '\x08',
'enter': cfg.crlf_mode ? '\x0d\x0a' : '\x0d',
'ctrl+enter': '\x0a',
'esc': '\x1b',
'up': ca('\x1bOA', '\x1b[A'),
'down': ca('\x1bOB', '\x1b[B'),
'right': ca('\x1bOC', '\x1b[C'),
'left': ca('\x1bOD', '\x1b[D'),
'home': ca('\x1bOH', fa('\x1b[H', '\x1b[1~')),
'insert': '\x1b[2~',
'delete': '\x1b[3~',
'end': ca('\x1bOF', fa('\x1b[F', '\x1b[4~')),
'pageup': '\x1b[5~',
'pagedown': '\x1b[6~',
'f1': fa('\x1bOP', '\x1b[11~'),
'f2': fa('\x1bOQ', '\x1b[12~'),
'f3': fa('\x1bOR', '\x1b[13~'),
'f4': fa('\x1bOS', '\x1b[14~'),
'f5': '\x1b[15~', // note the disconnect
'f6': '\x1b[17~',
'f7': '\x1b[18~',
'f8': '\x1b[19~',
'f9': '\x1b[20~',
'f10': '\x1b[21~', // note the disconnect
'f11': '\x1b[23~',
'f12': '\x1b[24~',
'shift+f1': fa('\x1bO1;2P', '\x1b[25~'),
'shift+f2': fa('\x1bO1;2Q', '\x1b[26~'), // note the disconnect
'shift+f3': fa('\x1bO1;2R', '\x1b[28~'),
'shift+f4': fa('\x1bO1;2S', '\x1b[29~'), // note the disconnect
'shift+f5': fa('\x1b[15;2~', '\x1b[31~'),
'shift+f6': fa('\x1b[17;2~', '\x1b[32~'),
'shift+f7': fa('\x1b[18;2~', '\x1b[33~'),
'shift+f8': fa('\x1b[19;2~', '\x1b[34~'),
'shift+f9': fa('\x1b[20;2~', '\x1b[35~'), // 35-38 are not standard - but what is?
'shift+f10': fa('\x1b[21;2~', '\x1b[36~'),
'shift+f11': fa('\x1b[22;2~', '\x1b[37~'),
'shift+f12': fa('\x1b[23;2~', '\x1b[38~'),
'np_0': na('\x1bOp', '0'),
'np_1': na('\x1bOq', '1'),
'np_2': na('\x1bOr', '2'),
'np_3': na('\x1bOs', '3'),
'np_4': na('\x1bOt', '4'),
'np_5': na('\x1bOu', '5'),
'np_6': na('\x1bOv', '6'),
'np_7': na('\x1bOw', '7'),
'np_8': na('\x1bOx', '8'),
'np_9': na('\x1bOy', '9'),
'np_mul': na('\x1bOR', '*'),
'np_add': na('\x1bOl', '+'),
'np_sub': na('\x1bOS', '-'),
'np_point': na('\x1bOn', '.'),
'np_div': na('\x1bOQ', '/')
// we don't implement numlock key (should change in numpad_alt mode,
// but it's even more useless than the rest and also has the side
// effect of changing the user's numlock state)
}
const blacklist = [
'f5', 'f11', 'f12', 'shift+f5'
]
for (let k in keymap) {
if (!allFn && blacklist.includes(k)) continue
if (keymap.hasOwnProperty(k)) {
bind(k, keymap[k])
}
}
}
/** Bind a keystroke to message */
function bind (combo, str) {
// mac fix - allow also cmd
if (combo.indexOf('ctrl+') !== -1) {
combo += ',' + combo.replace('ctrl', 'command')
}
// unbind possible old binding
key.unbind(combo)
key(combo, function (e) {
if (cfg.no_keys) return
e.preventDefault()
sendStrMsg(str)
})
}
/** Bind/rebind key messages */
function initKeys ({ allFn }) {
// This takes care of text characters typed
window.addEventListener('keypress', function (evt) {
if (cfg.no_keys) return
let str = ''
if (evt.key) str = evt.key
else if (evt.which) str = String.fromCodePoint(evt.which)
if (str.length > 0 && str.charCodeAt(0) >= 32) {
// console.log("Typed ", str);
// prevent space from scrolling
if (evt.which === 32) evt.preventDefault()
sendStrMsg(str)
}
})
// ctrl-letter codes are sent as simple low ASCII codes
for (let i = 1; i <= 26; i++) {
bind('ctrl+' + String.fromCharCode(96 + i), String.fromCharCode(i))
}
/* eslint-disable */
bind('ctrl+]', '\x1b') // alternate way to enter ESC
bind('ctrl+\\', '\x1c')
bind('ctrl+[', '\x1d')
bind('ctrl+^', '\x1e')
bind('ctrl+_', '\x1f')
// extra ctrl-
bind('ctrl+left', '\x1f[1;5D')
bind('ctrl+right', '\x1f[1;5C')
bind('ctrl+up', '\x1f[1;5A')
bind('ctrl+down', '\x1f[1;5B')
bind('ctrl+home', '\x1f[1;5H')
bind('ctrl+end', '\x1f[1;5F')
// extra shift-
bind('shift+left', '\x1f[1;2D')
bind('shift+right', '\x1f[1;2C')
bind('shift+up', '\x1f[1;2A')
bind('shift+down', '\x1f[1;2B')
bind('shift+home', '\x1f[1;2H')
bind('shift+end', '\x1f[1;2F')
// macOS editing commands
bind('⌥+left', '\x1bb') // ⌥← to go back a word (^[b)
bind('⌥+right', '\x1bf') // ⌥→ to go forward one word (^[f)
bind('⌘+left', '\x01') // ⌘← to go to the beginning of a line (^A)
bind('⌘+right', '\x05') // ⌘→ to go to the end of a line (^E)
bind('⌥+backspace', '\x17') // ⌥⌫ to delete a word (^W)
bind('⌘+backspace', '\x15') // ⌘⌫ to delete to the beginning of a line (^U)
/* eslint-enable */
bindFnKeys(allFn)
}
// mouse button states
let mb1 = 0
let mb2 = 0
let mb3 = 0
/** Init the Input module */
function init (opts) {
initKeys(opts)
// Button presses
$('#action-buttons button').forEach(s => {
s.addEventListener('click', function (evt) {
sendBtnMsg(+this.dataset['n'])
})
})
// global mouse state tracking - for motion reporting
window.addEventListener('mousedown', evt => {
if (evt.button === 0) mb1 = 1
if (evt.button === 1) mb2 = 1
if (evt.button === 2) mb3 = 1
})
window.addEventListener('mouseup', evt => {
if (evt.button === 0) mb1 = 0
if (evt.button === 1) mb2 = 0
if (evt.button === 2) mb3 = 0
})
}
/** Prepare modifiers byte for mouse message */
function packModifiersForMouse () {
return (key.isModifier('ctrl') ? 1 : 0) |
(key.isModifier('shift') ? 2 : 0) |
(key.isModifier('alt') ? 4 : 0) |
(key.isModifier('meta') ? 8 : 0)
}
return {
/** Init the Input module */
init,
/** Send a literal string message */
sendString: sendStrMsg,
/** Enable alternate key modes (cursors, numpad, fn) */
setAlts: function (cu, np, fn, crlf) {
if (cfg.cu_alt !== cu || cfg.np_alt !== np || cfg.fn_alt !== fn || cfg.crlf_mode !== crlf) {
cfg.cu_alt = cu
cfg.np_alt = np
cfg.fn_alt = fn
cfg.crlf_mode = crlf
// rebind keys - codes have changed
bindFnKeys()
}
},
setMouseMode (click, move) {
cfg.mt_click = click
cfg.mt_move = move
},
// Mouse events
onMouseMove (x, y) {
if (!cfg.mt_move) return
const b = mb1 ? 1 : mb2 ? 2 : mb3 ? 3 : 0
const m = packModifiersForMouse()
conn.send('m' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m))
},
onMouseDown (x, y, b) {
if (!cfg.mt_click) return
if (b > 3 || b < 1) return
const m = packModifiersForMouse()
conn.send('p' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m))
// console.log("B ",b," M ",m);
},
onMouseUp (x, y, b) {
if (!cfg.mt_click) return
if (b > 3 || b < 1) return
const m = packModifiersForMouse()
conn.send('r' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m))
// console.log("B ",b," M ",m);
},
onMouseWheel (x, y, dir) {
if (!cfg.mt_click) return
// -1 ... btn 4 (away from user)
// +1 ... btn 5 (towards user)
const m = packModifiersForMouse()
const b = (dir < 0 ? 4 : 5)
conn.send('p' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m))
// console.log("B ",b," M ",m);
},
/**
* Prevent capturing keys. This is used for text input
* modals on the terminal screen
*/
blockKeys (yes) {
cfg.no_keys = yes
}
}
}

File diff suppressed because it is too large Load Diff

@ -1,29 +1,24 @@
/** Make a node */
function mk (e) {
exports.mk = function mk (e) {
return document.createElement(e)
}
/** Find one by query */
function qs (s) {
exports.qs = function qs (s) {
return document.querySelector(s)
}
/** Find all by query */
function qsa (s) {
exports.qsa = function qsa (s) {
return document.querySelectorAll(s)
}
/** Convert any to bool safely */
function bool (x) {
return (x === 1 || x === '1' || x === true || x === 'true')
}
/**
* Filter 'spacebar' and 'return' from keypress handler,
* and when they're pressed, fire the callback.
* use $(...).on('keypress', cr(handler))
*/
function cr (hdl) {
exports.cr = function cr (hdl) {
return function (e) {
if (e.which === 10 || e.which === 13 || e.which === 32) {
hdl()
@ -31,53 +26,33 @@ function cr (hdl) {
}
}
/** HTML escape */
function esc (str) {
return $.htmlEscape(str)
}
/** Check for undefined */
function undef (x) {
return typeof x == 'undefined'
}
/** Safe json parse */
function jsp (str) {
try {
return JSON.parse(str)
} catch (e) {
console.error(e)
return null
}
}
/** Create a character from ASCII code */
function Chr (n) {
return String.fromCharCode(n)
/** Convert any to bool safely */
exports.bool = function bool (x) {
return (x === 1 || x === '1' || x === true || x === 'true')
}
/** Decode number from 2B encoding */
function parse2B (s, i = 0) {
exports.parse2B = function parse2B (s, i = 0) {
return (s.charCodeAt(i++) - 1) + (s.charCodeAt(i) - 1) * 127
}
/** Decode number from 3B encoding */
function parse3B (s, i = 0) {
exports.parse3B = function parse3B (s, i = 0) {
return (s.charCodeAt(i) - 1) + (s.charCodeAt(i + 1) - 1) * 127 + (s.charCodeAt(i + 2) - 1) * 127 * 127
}
/** Encode using 2B encoding, returns string. */
function encode2B (n) {
exports.encode2B = function encode2B (n) {
let lsb, msb
lsb = (n % 127)
n = ((n - lsb) / 127)
lsb += 1
msb = (n + 1)
return Chr(lsb) + Chr(msb)
return String.fromCharCode(lsb) + String.fromCharCode(msb)
}
/** Encode using 3B encoding, returns string. */
function encode3B (n) {
exports.encode3B = function encode3B (n) {
let lsb, msb, xsb
lsb = (n % 127)
n = (n - lsb) / 127
@ -86,5 +61,5 @@ function encode3B (n) {
n = (n - msb) / 127
msb += 1
xsb = (n + 1)
return Chr(lsb) + Chr(msb) + Chr(xsb)
return String.fromCharCode(lsb) + String.fromCharCode(msb) + String.fromCharCode(xsb)
}

@ -1,4 +1,8 @@
(function (w) {
const $ = require('./lib/chibi')
const { mk, bool } = require('./utils')
const tr = require('./lang')
;(function (w) {
const authStr = ['Open', 'WEP', 'WPA', 'WPA2', 'WPA/WPA2']
let curSSID
@ -15,8 +19,8 @@
$('#sta-nw').toggleClass('hidden', name.length === 0)
$('#sta-nw-nil').toggleClass('hidden', name.length > 0)
$('#sta-nw .essid').html(esc(name))
const nopw = undef(password) || password.length === 0
$('#sta-nw .essid').html($.htmlEscape(name))
const nopw = !password || password.length === 0
$('#sta-nw .passwd').toggleClass('hidden', nopw)
$('#sta-nw .nopasswd').toggleClass('hidden', !nopw)
$('#sta-nw .ip').html(ip.length > 0 ? tr('wifi.connected_ip_is') + ip : tr('wifi.not_conn'))
@ -96,7 +100,7 @@
if (+$th.data('pwd')) {
// this AP needs a password
conn_pass = prompt(tr('wifi.enter_passwd').replace(':ssid:', conn_ssid))
conn_pass = window.prompt(tr('wifi.enter_passwd').replace(':ssid:', conn_ssid))
if (!conn_pass) return
}
@ -120,10 +124,10 @@
/** Ask the CGI what APs are visible (async) */
function scanAPs () {
if (_demo) {
onScan(_demo_aps, 200)
if (window._demo) {
onScan(window._demo_aps, 200)
} else {
$.get('http://' + _root + '/cfg/wifi/scan', onScan)
$.get('http://' + window._root + '/cfg/wifi/scan', onScan)
}
}

@ -45,10 +45,18 @@ return [
'term.example' => 'Default colors preview',
'term.explain_scheme' => '
To select default text and background color, click on the
preview palette. Alternatively, use numbers 0-15 for theme colors, 16-255 for standard
colors and hex (#FFFFFF) for True Color (24-bit).
',
'term.fgbg_presets' => 'Presets',
'term.color_scheme' => 'Color Scheme',
'term.reset_screen' => 'Reset screen & parser',
'term.term_title' => 'Header text',
'term.term_width' => 'Width / height',
'term.default_fg_bg' => 'Text / background',
'term.term_width' => 'Width',
'term.term_height' => 'Height',
'term.buttons' => 'Button labels',
'term.theme' => 'Color scheme',
'term.cursor_shape' => 'Cursor style',
@ -62,6 +70,11 @@ return [
'term.crlf_mode' => 'Enter sends CR+LF',
'term.want_all_fn' => 'Capture all keys<br>(F5, F11, F12…)',
'term.button_msgs' => 'Button codes<br>(ASCII, dec, CSV)',
'term.color_fg' => 'Default fg.',
'term.color_bg' => 'Default bg.',
'term.color_fg_prev' => 'Fg. colors',
'term.color_bg_prev' => 'Bg. colors',
'term.colors_preview' => 'Defaults',
'cursor.block_blink' => 'Block, blinking',
'cursor.block_steady' => 'Block, steady',
@ -70,23 +83,23 @@ return [
'cursor.bar_blink' => 'I-bar, blinking',
'cursor.bar_steady' => 'I-bar, steady',
// terminal color labels
'color.0' => 'Black',
'color.1' => 'Red',
'color.2' => 'Green',
'color.3' => 'Yellow',
'color.4' => 'Blue',
'color.5' => 'Purple',
'color.6' => 'Cyan',
'color.7' => 'Silver',
'color.8' => 'Gray',
'color.9' => 'Light Red',
'color.10' => 'Light Green',
'color.11' => 'Light Yellow',
'color.12' => 'Light Blue',
'color.13' => 'Light Purple',
'color.14' => 'Light Cyan',
'color.15' => 'White',
// // terminal color labels
// 'color.0' => 'Black',
// 'color.1' => 'Red',
// 'color.2' => 'Green',
// 'color.3' => 'Yellow',
// 'color.4' => 'Blue',
// 'color.5' => 'Purple',
// 'color.6' => 'Cyan',
// 'color.7' => 'Silver',
// 'color.8' => 'Gray',
// 'color.9' => 'Light Red',
// 'color.10' => 'Light Green',
// 'color.11' => 'Light Yellow',
// 'color.12' => 'Light Blue',
// 'color.13' => 'Light Purple',
// 'color.14' => 'Light Cyan',
// 'color.15' => 'White',
'net.explain_sta' => '
Switch off Dynamic IP to configure the static IP address.',
@ -156,20 +169,50 @@ return [
'Restore to firmware default settings? This will reset ' .
'all active settings and switch to AP mode with the default SSID.',
'system.confirm_store_defaults' =>
'Enter admin password to confirm you want to store the current settings as defaults.',
'Enter admin password to confirm you want to overwrite the default settings.',
'system.password' => 'Admin password:',
'system.restore_defaults' => 'Reset active settings to defaults',
'system.restore_defaults' => 'Reset to saved defaults',
'system.write_defaults' => 'Save active settings as defaults',
'system.restore_hard' => 'Reset active settings to firmware defaults',
'system.explain_persist' => '
ESPTerm contains two persistent memory banks, one for default and
one for active settings. Active settings can be stored as defaults
by the administrator (password required).
ESPTerm saves all settings in Flash. The active settings can be copied to
the "defaults area" and restored later using the blue button below.
',
'system.uart' => 'Serial Port',
'system.explain_uart' => '
This form controls the primary, communication UART. The debug UART is fixed at 115.200 baud, one stop-bit and no parity.
This form controls the primary, communication UART. The debug UART is fixed
at 115.200 baud, one stop-bit and no parity.
',
'system.security' => 'Access Restrictions',
'system.explain_security' => '
Some parts, or all of the web interface can be protected by a password prompt.
Leave the new password fields empty if you do not wish to change it.
',
'system.pwlock' => 'Protected pages',
'system.pwlock.none' => 'None, all open',
'system.pwlock.settings_noterm' => 'WiFi, Net & System settings',
'system.pwlock.settings' => 'All settings pages',
'system.pwlock.menus' => 'This entire menu section',
'system.pwlock.all' => 'Everything, even terminal',
'system.new_access_pw' => 'New password',
'system.new_access_pw2' => 'New pass., repeat',
'system.admin_pw' => 'Admin password',
'system.access_name' => 'Username',
'system.change_adminpw' => 'Change Admin Password',
'system.explain_adminpw' =>
'
The "admin password" is used to manipulate the stored default settings
and to change access restrictions. This password is not saved as part
of the main config, i.e. using save / restore does not affect this
password. When the admin password is forgotten, the easiest way to
re-gain access is to wipe and re-flash the chip.
',
'system.new_admin_pw' => 'New admin pass.',
'system.new_admin_pw2' => 'New pass., repeat',
'system.old_admin_pw' => 'Old admin pass.',
'uart.baud' => 'Baud rate',
'uart.parity' => 'Parity',
'uart.parity.none' => 'None',

@ -5,14 +5,15 @@
"license": "MPL-2.0",
"devDependencies": {
"babel-cli": "^6.26.0",
"babel-minify": "^0.2.0",
"babel-loader": "^7.1.2",
"babel-preset-env": "^1.6.0",
"babel-preset-minify": "^0.2.0",
"node-sass": "^4.5.3",
"standard": "^10.0.3"
"standard": "^10.0.3",
"webpack": "^3.6.0"
},
"scripts": {
"babel": "babel $@",
"minify": "babel-minify $@",
"webpack": "webpack --display-modules $@",
"sass": "node-sass $@"
}
}

@ -13,8 +13,3 @@
}
?>
</nav>
<script>
function menuOpen() { $('#menu').toggleClass('expanded') }
$('#brand').on('click', menuOpen).on('keypress', cr(menuOpen));
</script>

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, maximum-scale=1">
<title><?= $_GET['PAGE_TITLE'] ?></title>
<link href="/css/app.<?= GIT_HASH ?>.css" rel="stylesheet">
<script src="/js/app.<?= GIT_HASH ?>.js"></script>

@ -43,7 +43,11 @@
<div class="Box">
<h2>Issues</h2>
<p>
Please report any issues to the <a href="%githubrepo%/issues">bugtracker</a> or send them by e-mail (see above).
Please report any issues to our <a href="%githubrepo%/issues">bugtracker</a> or send them by e-mail.
</p>
<p>
ESPTerm has a <a href="https://groups.google.com/forum/#!forum/espterm-dev">mailing list</a> for
troubleshooting and release announcements.
</p>
<p>
Firmware updates can be downloaded from the <a href="%githubrepo%/releases">releases page</a> and flashed
@ -65,12 +69,24 @@
<div class="Box">
<h2>Thanks</h2>
<p>
The webserver is based on a <a href="https://github.com/MightyPork/libesphttpd">fork</a> of the
<a href="https://github.com/Spritetm/esphttpd">esphttpd</a> library by Jeroen Domburg (Sprite_tm).
</p>
<p>
Using (modified) JS library <a href="https://github.com/kylebarrow/chibi">chibi.js</a> by
Kyle Barrow as a lightweight jQuery alternative.
</p>
<p><i>…for making this project possible, in no particular order, go to:</i></p>
<ul style="padding-left: 20px">
<li>
*Jeroen "SpriteTM" Domburg,* for writing the <a href="https://github.com/Spritetm/esphttpd">esphttpd</a>
server library we use (as a <a href="https://github.com/MightyPork/libesphttpd">fork</a>)
</li>
<li>
*Kyle Barrow,* for writing the <a href="https://github.com/kylebarrow/chibi">chibi.js</a> library
we use instead of jQuery
</li>
<li>
*cpsdqs,* for rewriting the front-end to use HTML5 canvas and other JS improvements
</li>
<li>
*Guenter Honisch,* for finding bugs and suggesting many improvements
</li>
<li>
*doc. Jan Fischer,* who came up with the original idea
</li>
</ul>
</div>

@ -2,7 +2,7 @@
$ipmask='pattern="^([0-9]{1,3}\.){3}[0-9]{1,3}$"';
?>
<form class="Box str mobcol" action="<?= e(url('network_set')) ?>" method="GET" id="form-2">
<form class="Box str mobcol" action="<?= e(url('network_set')) ?>" method="GET" id="form-netsta">
<h2 tabindex=0><?= tr('net.sta') ?></h2>
<div class="Row explain">
@ -31,11 +31,11 @@ $ipmask='pattern="^([0-9]{1,3}\.){3}[0-9]{1,3}$"';
</div>
<div class="Row buttons">
<a class="button icn-ok" href="#" onclick="qs('#form-2').submit()"><?= tr('apply') ?></a>
<a class="button icn-ok" href="#" onclick="qs('#form-netsta').submit()"><?= tr('apply') ?></a>
</div>
</form>
<form class="Box str mobcol" action="<?= e(url('network_set')) ?>" method="GET" id="form-1">
<form class="Box str mobcol" action="<?= e(url('network_set')) ?>" method="GET" id="form-netap">
<h2 tabindex=0><?= tr('net.ap') ?></h2>
<div class="Row explain">
@ -69,7 +69,7 @@ $ipmask='pattern="^([0-9]{1,3}\.){3}[0-9]{1,3}$"';
</div>
<div class="Row buttons">
<a class="button icn-ok" href="#" onclick="qs('#form-1').submit()"><?= tr('apply') ?></a>
<a class="button icn-ok" href="#" onclick="qs('#form-netap').submit()"><?= tr('apply') ?></a>
</div>
</form>

@ -5,7 +5,7 @@
<?= tr('system.explain_persist') ?>
</div>
<div class="Row buttons2">
<div class="Row buttons">
<a class="button icn-restore"
onclick="return confirm('<?= tr('system.confirm_restore') ?>');"
href="<?= e(url('restore_defaults')) ?>">
@ -13,11 +13,11 @@
</a>
</div>
<div class="Row buttons2">
<div class="Row buttons">
<a onclick="writeDefaults(); return false;" href="#"><?= tr('system.write_defaults') ?></a>
</div>
<div class="Row buttons2">
<div class="Row buttons">
<a onclick="return confirm('<?= tr('system.confirm_restore_hard') ?>');"
href="<?= e(url('restore_hard')) ?>">
<?= tr('system.restore_hard') ?>
@ -25,55 +25,77 @@
</div>
</div>
<?php
$NOFILL = 'readonly onfocus="this.removeAttribute(\'readonly\')" style="cursor:text" autocomplete="off"';
?>
<form class="Box str mobcol" action="<?= e(url('system_set')) ?>" method="GET" id="form-1">
<h2 tabindex=0><?= tr('system.uart') ?></h2>
<form class="Box str mobcol" action="<?= e(url('system_set')) ?>" method="GET" id="form-2">
<h2 tabindex=0><?= tr('system.security') ?></h2>
<div class="Row explain">
<?= tr('system.explain_uart') ?>
<?= tr('system.explain_security') ?>
</div>
<div class="Row">
<label for="uart_baud"><?= tr('uart.baud') ?><span class="mq-phone">&nbsp;(bps)</span></label>
<select name="uart_baud" id="uart_baud" class="short">
<?php foreach([
300, 600, 1200, 2400, 4800, 9600, 19200, 38400,
57600, 74880, 115200, 230400, 460800, 921600, 1843200, 3686400,
] as $b):
?><option value="<?=$b?>"><?= number_format($b, 0, ',', '.') ?></option>
<?php endforeach; ?>
<label for="pwlock"><?= tr("system.pwlock") ?></label>
<select name="pwlock" id="pwlock">
<option value="0"><?= tr("system.pwlock.none") ?></option>
<option value="1"><?= tr("system.pwlock.settings_noterm") ?></option>
<option value="2"><?= tr("system.pwlock.settings") ?></option>
<option value="3"><?= tr("system.pwlock.menus") ?></option>
<option value="4"><?= tr("system.pwlock.all") ?></option>
</select>
<span class="mq-no-phone">&nbsp;bps</span>
</div>
<div class="Row">
<label for="uart_parity"><?= tr('uart.parity') ?></label>
<select name="uart_parity" id="uart_parity" class="short">
<?php foreach([
2 => tr('uart.parity.none'),
1 => tr('uart.parity.odd'),
0 => tr('uart.parity.even'),
] as $k => $label):
?><option value="<?=$k?>"><?=$label?></option>
<?php endforeach; ?>
</select>
<label for="access_name"><?= tr('system.access_name') ?></label>
<input type="text" name="access_name" id="access_name" value="%h:access_name%">
</div>
<div class="Row">
<label for="uart_stopbits"><?= tr('uart.stop_bits') ?></label>
<select name="uart_stopbits" id="uart_stopbits" class="short">
<?php foreach([
1 => tr('uart.stop_bits.one'),
2 => tr('uart.stop_bits.one_and_half'),
3 => tr('uart.stop_bits.two'),
] as $k => $label):
?><option value="<?=$k?>"><?=$label?></option>
<?php endforeach; ?>
</select>
<label for="access_pw"><?= tr('system.new_access_pw') ?></label>
<input type="password" name="access_pw" id="access_pw" <?=$NOFILL?>>
</div>
<div class="Row">
<label for="access_pw2"><?= tr('system.new_access_pw2') ?></label>
<input type="password" name="access_pw2" id="access_pw2" <?=$NOFILL?>>
</div>
<div class="Row">
<label for="pw"><?= tr('system.admin_pw') ?></label>
<input type="password" name="pw" id="pw" required>
</div>
<div class="Row buttons">
<a class="button icn-ok" href="#" onclick="qs('#form-2').submit()"><?= tr('apply') ?></a>
</div>
</form>
<form class="Box str mobcol" action="<?= e(url('system_set')) ?>" method="GET" id="form-3">
<h2 tabindex=0><?= tr('system.change_adminpw') ?></h2>
<div class="Row explain">
<?= tr('system.explain_adminpw') ?>
</div>
<div class="Row">
<label for="admin_pw"><?= tr('system.new_admin_pw') ?></label>
<input type="password" name="admin_pw" id="admin_pw">
</div>
<div class="Row">
<label for="admin_pw2"><?= tr('system.new_admin_pw2') ?></label>
<input type="password" name="admin_pw2" id="admin_pw2">
</div>
<div class="Row">
<label for="pw"><?= tr('system.old_admin_pw') ?></label>
<input type="password" name="pw" id="pw" required>
</div>
<div class="Row buttons">
<a class="button icn-ok" href="#" onclick="qs('#form-1').submit()"><?= tr('apply') ?></a>
<a class="button icn-ok" href="#" onclick="qs('#form-3').submit()"><?= tr('apply') ?></a>
</div>
</form>
@ -84,7 +106,5 @@
location.href = <?=json_encode(url('write_defaults')) ?> + '?pw=' + pw;
}
$('#uart_baud').val(%uart_baud%);
$('#uart_parity').val(%uart_parity%);
$('#uart_stopbits').val(%uart_stopbits%);
$('#pwlock').val(%pwlock%);
</script>

@ -2,96 +2,139 @@
<a href="<?= e(url('reset_screen')) ?>"><?= tr('term.reset_screen') ?></a>
</div>
<form class="Box mobopen str" action="<?= e(url('term_set')) ?>" method="GET" id='form-1'>
<h2><?= tr('term.defaults') ?></h2>
<form class="Box mobopen str" action="<?= e(url('term_set')) ?>" method="GET" id='form-scheme'>
<h2><?= tr('term.color_scheme') ?></h2>
<div class="Row explain">
<?= tr('term.explain_initials') ?>
<?= tr('term.explain_scheme') ?>
</div>
<div class="Row">
<label for="theme"><?= tr("term.theme") ?></label>
<select name="theme" id="theme" class="short" onchange="showColor()">
<option value="0">Tango</option>
<option value="1">Linux</option>
<option value="1">Linux (CGA)</option>
<option value="2">XTerm</option>
<option value="3">Rxvt</option>
<option value="4">Ambience</option>
<option value="5">Solarized</option>
<option value="6">CGA NTSC</option>
<option value="7">ZX Spectrum</option>
<option value="8">Apple II</option>
<option value="9">Commodore</option>
</select>
</div>
<div class="Row color-preview">
<div class="colorprev">
<span data-fg=0 class="bg0 fg0">30</span><!--
--><span data-fg=1 class="bg0 fg1">31</span><!--
--><span data-fg=2 class="bg0 fg2">32</span><!--
--><span data-fg=3 class="bg0 fg3">33</span><!--
--><span data-fg=4 class="bg0 fg4">34</span><!--
--><span data-fg=5 class="bg0 fg5">35</span><!--
--><span data-fg=6 class="bg0 fg6">36</span><!--
--><span data-fg=7 class="bg0 fg7">37</span>
</div>
<label><?= tr("term.color_bg_prev") ?></label>
<div>
<div class="colorprev bg">
<span data-bg=0 data-fg=15>0</span><!--
--><span data-bg=1 data-fg=15>1</span><!--
--><span data-bg=2 data-fg=15>2</span><!--
--><span data-bg=3 data-fg=0>3</span><!--
--><span data-bg=4 data-fg=15>4</span><!--
--><span data-bg=5 data-fg=15>5</span><!--
--><span data-bg=6 data-fg=15>6</span><!--
--><span data-bg=7 data-fg=0>7</span>
</div>
<div class="colorprev">
<span data-fg=8 class="bg0 fg8">90</span><!--
--><span data-fg=9 class="bg0 fg9">91</span><!--
--><span data-fg=10 class="bg0 fg10">92</span><!--
--><span data-fg=11 class="bg0 fg11">93</span><!--
--><span data-fg=12 class="bg0 fg12">94</span><!--
--><span data-fg=13 class="bg0 fg13">95</span><!--
--><span data-fg=14 class="bg0 fg14">96</span><!--
--><span data-fg=15 class="bg0 fg15">97</span>
<div class="colorprev bg">
<span data-bg=8 data-fg=15>8</span><!--
--><span data-bg=9 data-fg=0>9</span><!--
--><span data-bg=10 data-fg=0>10</span><!--
--><span data-bg=11 data-fg=0>11</span><!--
--><span data-bg=12 data-fg=0>12</span><!--
--><span data-bg=13 data-fg=0>13</span><!--
--><span data-bg=14 data-fg=0>14</span><!--
--><span data-bg=15 data-fg=0>15</span>
</div>
</div>
</div>
<div class="colorprev">
<span data-bg=0 class="bg0 fg15">40</span><!--
--><span data-bg=1 class="bg1 fg15">41</span><!--
--><span data-bg=2 class="bg2 fg15">42</span><!--
--><span data-bg=3 class="bg3 fg0">43</span><!--
--><span data-bg=4 class="bg4 fg15">44</span><!--
--><span data-bg=5 class="bg5 fg15">45</span><!--
--><span data-bg=6 class="bg6 fg15">46</span><!--
--><span data-bg=7 class="bg7 fg0">47</span>
</div>
<div class="Row color-preview">
<label><?= tr("term.color_fg_prev") ?></label>
<div>
<div class="colorprev fg">
<span data-fg=0 data-bg=0 style="text-shadow: 0 0 4px white;">0</span><!--
--><span data-fg=1 data-bg=0>1</span><!--
--><span data-fg=2 data-bg=0>2</span><!--
--><span data-fg=3 data-bg=0>3</span><!--
--><span data-fg=4 data-bg=0>4</span><!--
--><span data-fg=5 data-bg=0>5</span><!--
--><span data-fg=6 data-bg=0>6</span><!--
--><span data-fg=7 data-bg=0>7</span>
</div>
<div class="colorprev">
<span data-bg=8 class="bg8 fg15">100</span><!--
--><span data-bg=9 class="bg9 fg0">101</span><!--
--><span data-bg=10 class="bg10 fg0">102</span><!--
--><span data-bg=11 class="bg11 fg0">103</span><!--
--><span data-bg=12 class="bg12 fg0">104</span><!--
--><span data-bg=13 class="bg13 fg0">105</span><!--
--><span data-bg=14 class="bg14 fg0">106</span><!--
--><span data-bg=15 class="bg15 fg0">107</span>
<div class="colorprev fg">
<span data-fg=8 data-bg=0>8</span><!--
--><span data-fg=9 data-bg=0>9</span><!--
--><span data-fg=10 data-bg=0>10</span><!--
--><span data-fg=11 data-bg=0>11</span><!--
--><span data-fg=12 data-bg=0>12</span><!--
--><span data-fg=13 data-bg=0>13</span><!--
--><span data-fg=14 data-bg=0>14</span><!--
--><span data-fg=15 data-bg=0>15</span>
</div>
</div>
</div>
<div class="Row color-preview">
<div style="
" id="color-example">
<label><?= tr("term.colors_preview") ?></label>
<div class="color-example" data-fg="" data-bg="">
<?= tr("term.example") ?>
</div>
</div>
<div class="Row color-preview">
<label><?= tr("term.fgbg_presets") ?></label>
<div id="fgbg_presets"></div>
</div>
<div class="Row">
<div class="SubRow">
<label for="default_fg"><?= tr("term.color_fg") ?></label>
<input type="text" name="default_fg" id="default_fg" class="short" value="%default_fg%">
</div>
<div class="SubRow">
<label for="default_bg"><?= tr("term.color_bg") ?></label>
<input type="text" name="default_bg" id="default_bg" class="short" value="%default_bg%">
</div>
</div>
<div class="Row">
<label><?= tr("term.default_fg_bg") ?></label>
<select name="default_fg" id="default_fg" class="short" onchange="showColor()">
<?php for($i=0; $i<16; $i++): ?>
<option value="<?=$i?>"><?= tr("color.$i") ?></option>
<?php endfor; ?>
</select>&nbsp;<!--
--><select name="default_bg" id="default_bg" class="short" onchange="showColor()">
<?php for($i=0; $i<16; $i++): ?>
<option value="<?=$i?>"><?= tr("color.$i") ?></option>
<?php endfor; ?>
<label><?= tr("term.cursor_shape") ?></label>
<select name="cursor_shape" id="cursor_shape">
<option value="0"><?= tr("cursor.block_blink") ?></option>
<option value="2"><?= tr("cursor.block_steady") ?></option>
<option value="3"><?= tr("cursor.underline_blink") ?></option>
<option value="4"><?= tr("cursor.underline_steady") ?></option>
<option value="5"><?= tr("cursor.bar_blink") ?></option>
<option value="6"><?= tr("cursor.bar_steady") ?></option>
</select>
</div>
<div class="Row buttons">
<a class="button icn-ok" href="#" onclick="qs('#form-scheme').submit()"><?= tr('apply') ?></a>
</div>
</form>
<form class="Box fold str" action="<?= e(url('term_set')) ?>" method="GET" id='form-initial'>
<h2><?= tr('term.defaults') ?></h2>
<div class="Row explain">
<?= tr('term.explain_initials') ?>
</div>
<div class="Row">
<label for="term_width"><?= tr('term.term_width') ?></label>
<input type="number" step=1 min=1 max=255 name="term_width" id="term_width" value="%term_width%" required>&nbsp;<!--
--><input type="number" step=1 min=1 max=255 name="term_height" id="term_height" value="%term_height%" required>
<div class="SubRow">
<label for="term_width"><?= tr('term.term_width') ?></label>
<input type="number" step=1 min=1 max=255 name="term_width" id="term_width" value="%term_width%" required>
</div>
<div class="SubRow">
<label for="term_height"><?= tr('term.term_height') ?></label>
<input type="number" step=1 min=1 max=255 name="term_height" id="term_height" value="%term_height%" required>
</div>
</div>
<div class="Row">
@ -99,33 +142,90 @@
<input type="text" name="term_title" id="term_title" value="%h:term_title%" required>
</div>
<div class="Row checkbox" >
<label><?= tr('term.show_buttons') ?></label><!--
--><span class="box" tabindex=0 role=checkbox></span>
<input type="hidden" id="show_buttons" name="show_buttons" value="%show_buttons%">
</div>
<div class="Row">
<label><?= tr("term.buttons") ?></label>
<input class="short" type="text" name="btn1" id="btn1" value="%h:btn1%">&nbsp;
<input class="short" type="text" name="btn2" id="btn2" value="%h:btn2%">&nbsp;
<input class="short" type="text" name="btn3" id="btn3" value="%h:btn3%">&nbsp;
<input class="short" type="text" name="btn4" id="btn4" value="%h:btn4%">&nbsp;
<input class="short" type="text" name="btn5" id="btn5" value="%h:btn5%">
<input class="tiny" type="text" name="btn1" id="btn1" value="%h:btn1%">
<input class="tiny" type="text" name="btn2" id="btn2" value="%h:btn2%">
<input class="tiny" type="text" name="btn3" id="btn3" value="%h:btn3%">
<input class="tiny" type="text" name="btn4" id="btn4" value="%h:btn4%">
<input class="tiny" type="text" name="btn5" id="btn5" value="%h:btn5%">
</div>
<div class="Row">
<label><?= tr("term.cursor_shape") ?></label>
<select name="cursor_shape" id="cursor_shape">
<option value="0"><?= tr("cursor.block_blink") ?></option>
<option value="2"><?= tr("cursor.block_steady") ?></option>
<option value="3"><?= tr("cursor.underline_blink") ?></option>
<option value="4"><?= tr("cursor.underline_steady") ?></option>
<option value="5"><?= tr("cursor.bar_blink") ?></option>
<option value="6"><?= tr("cursor.bar_steady") ?></option>
<label><?= tr("term.button_msgs") ?></label>
<input class="tiny" type="text" name="bm1" id="bm1" value="%h:bm1%">
<input class="tiny" type="text" name="bm2" id="bm2" value="%h:bm2%">
<input class="tiny" type="text" name="bm3" id="bm3" value="%h:bm3%">
<input class="tiny" type="text" name="bm4" id="bm4" value="%h:bm4%">
<input class="tiny" type="text" name="bm5" id="bm5" value="%h:bm5%">
</div>
<div class="Row buttons">
<a class="button icn-ok" href="#" onclick="qs('#form-initial').submit()"><?= tr('apply') ?></a>
</div>
</form>
<form class="Box fold str" action="<?= e(url('term_set')) ?>" method="GET" id="form-uart">
<h2 tabindex=0><?= tr('system.uart') ?></h2>
<div class="Row explain">
<?= tr('system.explain_uart') ?>
</div>
<div class="Row">
<label for="uart_baud"><?= tr('uart.baud') ?><span class="mq-phone">&nbsp;(bps)</span></label>
<select name="uart_baud" id="uart_baud" class="short">
<?php
foreach([
300, 600, 1200, 2400, 4800, 9600, 19200, 38400,
57600, 74880, 115200, 230400, 460800, 921600, 1843200, 3686400,
] as $b):
?><option value="<?=$b?>"><?= number_format($b, 0, ',', '.') ?></option>
<?php endforeach; ?>
</select>
<span class="mq-no-phone">&nbsp;bps</span>
</div>
<div class="Row">
<label for="uart_parity"><?= tr('uart.parity') ?></label>
<select name="uart_parity" id="uart_parity" class="short">
<?php
foreach([
2 => tr('uart.parity.none'),
1 => tr('uart.parity.odd'),
0 => tr('uart.parity.even'),
] as $k => $label):
?><option value="<?=$k?>"><?=$label?></option>
<?php endforeach; ?>
</select>
</div>
<div class="Row">
<label for="uart_stopbits"><?= tr('uart.stop_bits') ?></label>
<select name="uart_stopbits" id="uart_stopbits" class="short">
<?php
foreach([
1 => tr('uart.stop_bits.one'),
2 => tr('uart.stop_bits.one_and_half'),
3 => tr('uart.stop_bits.two'),
] as $k => $label):
?><option value="<?=$k?>"><?=$label?></option>
<?php endforeach; ?>
</select>
</div>
<div class="Row buttons">
<a class="button icn-ok" href="#" onclick="qs('#form-1').submit()"><?= tr('apply') ?></a>
<a class="button icn-ok" href="#" onclick="qs('#form-uart').submit()"><?= tr('apply') ?></a>
</div>
</form>
<form class="Box fold str" action="<?= e(url('term_set')) ?>" method="GET" id='form-2'>
<form class="Box fold str" action="<?= e(url('term_set')) ?>" method="GET" id='form-expert'>
<h2><?= tr('term.expert') ?></h2>
<div class="Row explain">
@ -150,15 +250,6 @@
<span class="mq-no-phone">&nbsp;ms</span>
</div>
<div class="Row">
<label><?= tr("term.button_msgs") ?></label>
<input class="short" type="text" name="bm1" id="bm1" value="%h:bm1%">&nbsp;
<input class="short" type="text" name="bm2" id="bm2" value="%h:bm2%">&nbsp;
<input class="short" type="text" name="bm3" id="bm3" value="%h:bm3%">&nbsp;
<input class="short" type="text" name="bm4" id="bm4" value="%h:bm4%">&nbsp;
<input class="short" type="text" name="bm5" id="bm5" value="%h:bm5%">
</div>
<div class="Row checkbox" >
<label><?= tr('term.fn_alt_mode') ?></label><!--
--><span class="box" tabindex=0 role=checkbox></span>
@ -177,12 +268,6 @@
<input type="hidden" id="crlf_mode" name="crlf_mode" value="%crlf_mode%">
</div>
<div class="Row checkbox" >
<label><?= tr('term.show_buttons') ?></label><!--
--><span class="box" tabindex=0 role=checkbox></span>
<input type="hidden" id="show_buttons" name="show_buttons" value="%show_buttons%">
</div>
<div class="Row checkbox" >
<label><?= tr('term.show_config_links') ?></label><!--
--><span class="box" tabindex=0 role=checkbox></span>
@ -196,33 +281,71 @@
</div>
<div class="Row buttons">
<a class="button icn-ok" href="#" onclick="qs('#form-2').submit()"><?= tr('apply') ?></a>
<a class="button icn-ok" href="#" onclick="qs('#form-expert').submit()"><?= tr('apply') ?></a>
</div>
</form>
<script>
$('#default_fg').val(%default_fg%);
$('#default_bg').val(%default_bg%);
$('#cursor_shape').val(%cursor_shape%);
$('#theme').val(%theme%);
$('#uart_baud').val(%uart_baud%);
$('#uart_parity').val(%uart_parity%);
$('#uart_stopbits').val(%uart_stopbits%);
function showColor() {
var ex = qs('#color-example');
ex.className = '';
ex.classList.add('fg'+$('#default_fg').val());
ex.classList.add('bg'+$('#default_bg').val());
var th = $('#theme').val();
$('.color-preview').forEach(function(e) {
e.className = 'Row color-preview theme-'+th;
});
var ex = qs('.color-example');
var fg = $('#default_fg').val();
var bg = $('#default_bg').val();
if (/^\d+$/.test(fg)) fg = +fg;
else if (!/^#[\da-f]{6}$/i.test(fg)) {
fg = 'black';
}
if (/^\d+$/.test(bg)) bg = +bg;
else if (!/^#[\da-f]{6}$/i.test(bg)) {
bg = 'black';
}
ex.dataset.fg = fg;
ex.dataset.bg = bg;
themes.themePreview(+$('#theme').val())
}
showColor();
$('.colorprev span').on('click', function() {
var fg = this.dataset.fg;
$('#default_fg').on('input', showColor)
$('#default_bg').on('input', showColor)
$('.colorprev.bg span').on('click', function() {
var bg = this.dataset.bg;
if (typeof fg != 'undefined') $('#default_fg').val(fg);
if (typeof bg != 'undefined') $('#default_bg').val(bg);
showColor()
});
$('.colorprev.fg span').on('click', function() {
var fg = this.dataset.fg;
if (typeof fg != 'undefined') $('#default_fg').val(fg);
showColor()
});
var $presets = $('#fgbg_presets');
for(var i = 0; i < themes.fgbgThemes.length; i++) {
fg = themes.fgbgThemes[i][0];
bg = themes.fgbgThemes[i][1];
$presets
.htmlAppend(
'<span class="preset" ' +
'data-xfg="'+fg+'" data-xbg="'+bg+'" ' +
'style="color:'+fg+';background:'+bg+'">&nbsp;['+i+']&nbsp;</span>');
if ((i+1)%5==0) $presets.htmlAppend('<br>');
}
$('.preset').on('click', function() {
$('#default_fg').val(this.dataset.xfg)
$('#default_bg').val(this.dataset.xbg)
showColor()
});
</script>

@ -1,4 +1,4 @@
<form class="Box str mobcol" action="<?= e(url('wifi_set')) ?>" method="GET" id="form-1">
<form class="Box str mobcol" action="<?= e(url('wifi_set')) ?>" method="GET" id="form-ap">
<h2 tabindex=0><?= tr('wifi.ap') ?></h2>
<div class="Row checkbox x-ap-toggle">
@ -38,11 +38,11 @@
</div>
<div class="Row buttons">
<a class="button icn-ok" href="#" onclick="qs('#form-1').submit()"><?= tr('apply') ?></a>
<a class="button icn-ok" href="#" onclick="qs('#form-ap').submit()"><?= tr('apply') ?></a>
</div>
</form>
<form class="Box str mobcol expanded" action="<?= e(url('wifi_set')) ?>" method="GET" id="form-2">
<form class="Box str mobcol expanded" action="<?= e(url('wifi_set')) ?>" method="GET" id="form-sta">
<h2 tabindex=0><?= tr('wifi.sta') ?></h2>
<div class="Row checkbox x-sta-toggle">
@ -85,7 +85,7 @@
</div>
<div class="Row buttons">
<a class="button icn-ok" href="#" onclick="qs('#form-2').submit()"><?= tr('apply') ?></a>
<a class="button icn-ok" href="#" onclick="qs('#form-sta').submit()"><?= tr('apply') ?></a>
</div>
</form>

@ -22,4 +22,7 @@
function hpfold(yes) {
$('.fold').toggleClass('expanded', !!yes);
}
// show theme colors - but this is a static page, so we don't know the current theme.
themes.themePreview(1)
</script>

@ -1,5 +1,5 @@
<div class="Box fold theme-1">
<div class="Box fold">
<h2>Commands: Color SGR</h2>
<div class="Row v">
@ -19,49 +19,49 @@
<h3>Foreground colors</h3>
<div class="colorprev">
<span class="bg7 fg0">30</span>
<span class="bg0 fg1">31</span>
<span class="bg0 fg2">32</span>
<span class="bg0 fg3">33</span>
<span class="bg0 fg4">34</span>
<span class="bg0 fg5">35</span>
<span class="bg0 fg6">36</span>
<span class="bg0 fg7">37</span>
<span data-bg="0" data-fg="0" style="text-shadow: 0 0 3px white;">30</span><!--
--><span data-bg="0" data-fg="1">31</span><!--
--><span data-bg="0" data-fg="2">32</span><!--
--><span data-bg="0" data-fg="3">33</span><!--
--><span data-bg="0" data-fg="4">34</span><!--
--><span data-bg="0" data-fg="5">35</span><!--
--><span data-bg="0" data-fg="6">36</span><!--
--><span data-bg="0" data-fg="7">37</span>
</div>
<div class="colorprev">
<span class="bg0 fg8">90</span>
<span class="bg0 fg9">91</span>
<span class="bg0 fg10">92</span>
<span class="bg0 fg11">93</span>
<span class="bg0 fg12">94</span>
<span class="bg0 fg13">95</span>
<span class="bg0 fg14">96</span>
<span class="bg0 fg15">97</span>
<span data-bg="0" data-fg="8">90</span><!--
--><span data-bg="0" data-fg="9">91</span><!--
--><span data-bg="0" data-fg="10">92</span><!--
--><span data-bg="0" data-fg="11">93</span><!--
--><span data-bg="0" data-fg="12">94</span><!--
--><span data-bg="0" data-fg="13">95</span><!--
--><span data-bg="0" data-fg="14">96</span><!--
--><span data-bg="0" data-fg="15">97</span>
</div>
<h3>Background colors</h3>
<div class="colorprev">
<span class="bg0 fg15">40</span>
<span class="bg1 fg15">41</span>
<span class="bg2 fg15">42</span>
<span class="bg3 fg0">43</span>
<span class="bg4 fg15">44</span>
<span class="bg5 fg15">45</span>
<span class="bg6 fg15">46</span>
<span class="bg7 fg0">47</span>
<span data-bg="0" data-fg="15">40</span><!--
--><span data-bg="1" data-fg="15">41</span><!--
--><span data-bg="2" data-fg="15">42</span><!--
--><span data-bg="3" data-fg="0">43</span><!--
--><span data-bg="4" data-fg="15">44</span><!--
--><span data-bg="5" data-fg="15">45</span><!--
--><span data-bg="6" data-fg="15">46</span><!--
--><span data-bg="7" data-fg="0">47</span>
</div>
<div class="colorprev">
<span class="bg8 fg15">100</span>
<span class="bg9 fg0">101</span>
<span class="bg10 fg0">102</span>
<span class="bg11 fg0">103</span>
<span class="bg12 fg0">104</span>
<span class="bg13 fg0">105</span>
<span class="bg14 fg0">106</span>
<span class="bg15 fg0">107</span>
<span data-bg="8" data-fg="15">100</span><!--
--><span data-bg="9" data-fg="0">101</span><!--
--><span data-bg="10" data-fg="0">102</span><!--
--><span data-bg="11" data-fg="0">103</span><!--
--><span data-bg="12" data-fg="15">104</span><!--
--><span data-bg="13" data-fg="0">105</span><!--
--><span data-bg="14" data-fg="0">106</span><!--
--><span data-bg="15" data-fg="0">107</span>
</div>
<h3>256-color palette</h3>
@ -69,13 +69,40 @@
<p>
ESPTerm supports in total 256 standard colors. The dark and bright basic colors are
numbered 0-7 and 8-15. To use colors higher than 15 (or 0-15 using this simpler numbering),
send `CSI 38 ; 5 ; <i>n</i> m`, where `n` is the color to set. Use 48 for background colors.
send `CSI 38 ; 5 ; <i>n</i> m`, where `n` is the color to set. Use `CSI 48 ; 5 ; <i>n</i> m` for background colors.
</p>
<p>
For a fererence of all 256 shades please refer to
<a href="https://jonasjacek.github.io/colors/">jonasjacek.github.io/colors</a>
or look it up elsewhere.
</p>
<div class="colorprev" id="pal256">
</div>
</div>
</div>
<script>
$.ready(function() {
var wrap = qs('#pal256');
var table = themes.buildColorTable();
for (var i = 0; i < 256; i++) {
var el = document.createElement('span')
var clr = table[i]
if (i < 16) {
clr = themes.themes[1][i]
}
el.style.color = 'black'
if ( i < 7 || i == 12 || i == 8 ||
(i >= 16 && i <= 33) ||
(i >= 52 && i <= 69) ||
(i >= 88 && i <= 99) ||
(i >= 124 && i <= 129)) {
el.style.color = 'white'
}
el.textContent = ""+i
el.style.backgroundColor = clr
wrap.appendChild(el)
if (i==15||(i-16)%24==23) {
el = document.createElement('br')
wrap.appendChild(el)
}
}
});
</script>

@ -2,9 +2,9 @@
<script>
// Workaround for badly loaded page
setTimeout(function() {
if (typeof termInit == 'undefined' || typeof $ == 'undefined') {
console.error("Page load failed, refreshing…");
location.reload(true);
if (typeof termInit == 'undefined') {
console.error("Page load failed, refreshing…")
location.reload(true)
}
}, 3000);
</script>
@ -41,6 +41,7 @@
</div>
<h1 id="screen-title"><!-- Screen title is loaded here by JS --></h1>
<a href="#" id="term-fit-screen" class="mq-tablet-max"><i id="resize-button-icon" class="icn-resize-small"></i></a>
<div id="term-wrap">
<div id="screen">
@ -60,8 +61,7 @@
</div>
<nav id="term-nav">
<a href="#" id="term-fit-screen" class="mq-tablet-max"><i id="resize-button-icon" class="icn-resize-small"></i></a><!--
--><a href="#" id="term-kb-open" class="mq-tablet-max"><i class="icn-keyboard"></i><span><?= tr('term_nav.keybd') ?></span></a><!--
<a href="#" id="term-kb-open" class="mq-tablet-max"><i class="icn-keyboard"></i><span><?= tr('term_nav.keybd') ?></span></a><!--
--><a href="#" id="term-fu-open"><i class="icn-download"></i><span><?= tr('term_nav.upload') ?></span></a><!--
--><a href="<?= url('cfg_term') ?>" class="x-term-conf-btn"><i class="icn-configure"></i><span><?= tr('term_nav.config') ?></span></a><!--
--><a href="<?= url('cfg_wifi') ?>" class="x-term-conf-btn"><i class="icn-wifi"></i><span><?= tr('term_nav.wifi') ?></span></a><!--
@ -70,20 +70,22 @@
</nav>
<script>
try {
window.noAutoShow = true;
termInit({
labels: '%j:labels_seq%',
theme: +'%theme%',
allFn: !!+'%want_all_fn%',
});
} catch(e) {
console.error(e);
<?php if (!DEBUG): ?>
console.error("Fail, reloading in 3s…");
setTimeout(function() {
location.reload(true);
}, 3000);
<?php endif; ?>
}
try {
window.noAutoShow = true;
termInit({
labels: '%j:labels_seq%',
theme: +'%theme%',
defaultFg: +'%default_fg%',
defaultBg: +'%default_bg%',
allFn: !!+'%want_all_fn%',
});
} catch (e) {
console.error(e);
<?php if (!DEBUG): ?>
console.error("Fail, reloading in 3s…");
setTimeout(function () {
location.reload(true);
}, 3000);
<?php endif; ?>
}
</script>

@ -2,10 +2,14 @@
#{$all-text-inputs}, select, label.select-wrap {
width: $form-field-w;
margin-right: 3px;
}
input[type="number"], input.short, select.short {
width: $form-field-w/2;
width: 123.5px;
}
input.tiny, select.tiny {
width: 90px;
}
#{$all-text-inputs}, select {

@ -21,6 +21,18 @@ form { @include naked(); }
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
.SubRow {
display: flex;
flex-direction: row;
@include media($phone) {
flex-direction: column;
margin: 6px auto;
width: 100%;
}
}
&:first-child {
margin-top: 0;
@ -53,7 +65,7 @@ form { @include naked(); }
}
// buttons2 is the same style, but different selector for use in the admin page
&.buttons, &.buttons2 {
&.buttons {
margin: 16px auto;
input, .button {
margin-right: dist(-1);

@ -36,7 +36,7 @@
span {
display: inline-block;
width: 2em;
padding: dist(-2) 0;
padding: 2px 0;
text-align: center;
}
}

@ -66,6 +66,10 @@ body.term {
display: block;
}
}
.debug-toolbar {
line-height: 1.5;
}
}
#action-buttons {
@ -85,6 +89,13 @@ body.term {
}
}
#load-failed {
color: red;
font-size: 18px;
font-weight: bold;
margin: 10px 5px 14px 5px;
}
#term-nav {
padding-top: 1.5em;
text-align: center;
@ -196,75 +207,33 @@ body.term {
}
}
// Tango
.theme-0 {
$term-colors:
#111213, #CC0000, #4E9A06, #C4A000, #3465A4, #75507B, #06989A, #D3D7CF,
#555753, #EF2929, #8AE234, #FCE94F, #729FCF, #AD7FA8, #34E2E2, #EEEEEC;
@for $i from 1 through length($term-colors) {
$c: nth($term-colors, $i);
.fg#{$i - 1} { color: $c; }
.bg#{$i - 1} { background-color: $c; }
}
}
// Linux
.theme-1 {
$term-colors:
#000000, #aa0000, #00aa00, #aa5500, #0000aa, #aa00aa, #00aaaa, #aaaaaa,
#555555, #ff5555, #55ff55, #ffff55, #5555ff, #ff55ff, #55ffff, #ffffff;
@for $i from 1 through length($term-colors) {
$c: nth($term-colors, $i);
.fg#{$i - 1} { color: $c; }
.bg#{$i - 1} { background-color: $c; }
}
}
// xterm
.theme-2 {
$term-colors:
#000000, #cd0000, #00cd00, #cdcd00, #0000ee, #cd00cd, #00cdcd, #e5e5e5,
#7f7f7f, #ff0000, #00ff00, #ffff00, #5c5cff, #ff00ff, #00ffff, #ffffff;
@for $i from 1 through length($term-colors) {
$c: nth($term-colors, $i);
.fg#{$i - 1} { color: $c; }
.bg#{$i - 1} { background-color: $c; }
}
}
// shortcut bar on iOS
#keyboard-shortcut-bar {
border-radius: 4px;
width: calc(100vw - 20px);
background: #d1d5db;
padding: 5px 10px;
overflow-x: auto;
// rxvt
.theme-3 {
$term-colors:
#000000, #cd0000, #00cd00, #cdcd00, #0000cd, #cd00cd, #00cdcd, #faebd7,
#404040, #ff0000, #00ff00, #ffff00, #0000ff, #ff00ff, #00ffff, #ffffff;
@for $i from 1 through length($term-colors) {
$c: nth($term-colors, $i);
.fg#{$i - 1} { color: $c; }
.bg#{$i - 1} { background-color: $c; }
&:not(.open) {
display: none;
}
}
// Ambience
.theme-4 {
$term-colors:
#2e3436, #cc0000, #4e9a06, #c4a000, #3465a4, #75507b, #06989a, #d3d7cf,
#555753, #ef2929, #8ae234, #fce94f, #729fcf, #ad7fa8, #34e2e2, #eeeeec;
@for $i from 1 through length($term-colors) {
$c: nth($term-colors, $i);
.fg#{$i - 1} { color: $c; }
.bg#{$i - 1} { background-color: $c; }
}
}
// Solarized
.theme-5 {
$term-colors:
#073642, #dc322f, #859900, #b58900, #268bd2, #d33682, #2aa198, #eee8d5,
#002b36, #cb4b16, #586e75, #657b83, #839496, #6c71c4, #93a1a1, #fdf6e3;
@for $i from 1 through length($term-colors) {
$c: nth($term-colors, $i);
.fg#{$i - 1} { color: $c; }
.bg#{$i - 1} { background-color: $c; }
.shortcut-button {
background: #fff;
color: #000;
padding: 10px 20px;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.3);
text-shadow: none;
border-radius: 4px;
margin: 0 10px 0 0;
font-family: -apple-system, sans-serif;
min-width: 2em;
&.modifier:not(.enabled) {
background: #9ea6b1;
color: #000;
}
}
}
@ -298,20 +267,35 @@ body.term {
}
//
.Row.color-preview {
.color-example {
font-family: monospace;
font-size: 16pt;
display: block;
margin-bottom: 0;
font-size: 14pt;
padding: 4px 6px;
}
padding-left: $form-label-w;
.preset {
cursor: pointer;
font-family: monospace;
font-size: 14pt;
}
.Row.color-preview {
label {
align-self: center;
}
@include media($phone) {
padding-left: 0;
font-size: 14pt;
font-size: 12pt;
align-items: flex-start;
label {
align-self: flex-start;
}
}
.colorprev {
font-family: monospace;
font-size: 14pt;
display:block;
margin: 0;
cursor: pointer;

@ -0,0 +1,41 @@
const webpack = require('webpack')
const { execSync } = require('child_process')
const path = require('path')
let hash = execSync('git rev-parse --short HEAD').toString().trim()
let plugins = []
let devtool = 'source-map'
if (process.env.ESP_PROD) {
// ignore demo
plugins.push(new webpack.IgnorePlugin(/\.\/demo(?:\.js)?$/))
// no source maps
devtool = ''
}
plugins.push(new webpack.optimize.UglifyJsPlugin({
sourceMap: devtool === 'source-map'
}))
module.exports = {
entry: './js',
output: {
path: path.resolve(__dirname, 'out', 'js'),
filename: `app.${hash}.js`
},
module: {
rules: [
{
test: /\.js$/,
exclude: [
path.resolve(__dirname, 'node_modules')
],
loader: 'babel-loader'
}
]
},
devtool,
plugins
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save