diff --git a/.eslintrc b/.eslintrc
index 25f5194..b6aeb27 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -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",
diff --git a/_build_js.sh b/_build_js.sh
index fa03512..7321e48 100755
--- a/_build_js.sh
+++ b/_build_js.sh
@@ -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
diff --git a/_debug_replacements.php b/_debug_replacements.php
index c7d4124..5363312 100644
--- a/_debug_replacements.php
+++ b/_debug_replacements.php
@@ -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',
];
diff --git a/base.php b/base.php
index a049c68..c0458cf 100644
--- a/base.php
+++ b/base.php
@@ -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);
diff --git a/dump_js_lang.php b/dump_js_lang.php
index 2639eea..d577f16 100755
--- a/dump_js_lang.php
+++ b/dump_js_lang.php
@@ -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"
);
diff --git a/js/appcommon.js b/js/appcommon.js
index f1c4c6a..26f8733 100644
--- a/js/appcommon.js
+++ b/js/appcommon.js
@@ -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' +
+ '.'
+ qs('#screen').appendChild(bnr)
+ showPage()
+ }
+ }, 2000)
+ } else {
setTimeout(function () {
showPage()
}, 1)
diff --git a/js/index.js b/js/index.js
new file mode 100644
index 0000000..eff520a
--- /dev/null
+++ b/js/index.js
@@ -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')
diff --git a/js/lang.js b/js/lang.js
index bce4adb..31117a3 100644
--- a/js/lang.js
+++ b/js/lang.js
@@ -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 + '?' }
diff --git a/js/lib/chibi.js b/js/lib/chibi.js
index 4d1d95e..acaee59 100755
--- a/js/lib/chibi.js
+++ b/js/lib/chibi.js
@@ -699,5 +699,5 @@
};
// Set Chibi's global namespace here ($)
- w.$ = chibi;
+ module.exports = chibi;
}());
diff --git a/js/lib/keymaster.js b/js/lib/keymaster.js
deleted file mode 100644
index 0f33d44..0000000
--- a/js/lib/keymaster.js
+++ /dev/null
@@ -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. 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. 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);
-
diff --git a/js/modal.js b/js/modal.js
index fabc1a7..e4e9cab 100644
--- a/js/modal.js
+++ b/js/modal.js
@@ -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
diff --git a/js/notif.js b/js/notif.js
index 38cbd4e..fa78e3a 100644
--- a/js/notif.js
+++ b/js/notif.js
@@ -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
diff --git a/js/term/connection.js b/js/term/connection.js
new file mode 100644
index 0000000..7b799ca
--- /dev/null
+++ b/js/term/connection.js
@@ -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)
+ }
+}
diff --git a/js/debug_screen.js b/js/term/debug_screen.js
similarity index 56%
rename from js/debug_screen.js
rename to js/term/debug_screen.js
index 97e2444..d49d747 100644
--- a/js/debug_screen.js
+++ b/js/term/debug_screen.js
@@ -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)}`
+ })
}
diff --git a/js/demo.js b/js/term/demo.js
similarity index 87%
rename from js/demo.js
rename to js/term/demo.js
index 3c93ab8..0ea9e33 100644
--- a/js/demo.js
+++ b/js/term/demo.js
@@ -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 \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 \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)
}
diff --git a/js/term.js b/js/term/index.js
similarity index 54%
rename from js/term.js
rename to js/term/index.js
index e9c5981..3a7a6c1 100644
--- a/js/term.js
+++ b/js/term/index.js
@@ -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()
}
diff --git a/js/term/input.js b/js/term/input.js
new file mode 100644
index 0000000..c0649d0
--- /dev/null
+++ b/js/term/input.js
@@ -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
+}
diff --git a/js/term/screen.js b/js/term/screen.js
new file mode 100644
index 0000000..e71a7e3
--- /dev/null
+++ b/js/term/screen.js
@@ -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)
+ }
+}
diff --git a/js/term/screen_parser.js b/js/term/screen_parser.js
new file mode 100644
index 0000000..9e83e12
--- /dev/null
+++ b/js/term/screen_parser.js
@@ -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) : ' '
+ 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)}`)
+ }
+ }
+}
diff --git a/js/term/screen_renderer.js b/js/term/screen_renderer.js
new file mode 100644
index 0000000..a557351
--- /dev/null
+++ b/js/term/screen_renderer.js
@@ -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
+ }
+}
diff --git a/js/soft_keyboard.js b/js/term/soft_keyboard.js
similarity index 59%
rename from js/soft_keyboard.js
rename to js/term/soft_keyboard.js
index 7921e5d..55bfa82 100644
--- a/js/soft_keyboard.js
+++ b/js/term/soft_keyboard.js
@@ -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
+ }
+ })
+ }
}
diff --git a/js/term/themes.js b/js/term/themes.js
new file mode 100644
index 0000000..a37dab4
--- /dev/null
+++ b/js/term/themes.js
@@ -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
+ })
+}
diff --git a/js/term_upload.js b/js/term/upload.js
similarity index 89%
rename from js/term_upload.js
rename to js/term/upload.js
index 938b420..6632755 100644
--- a/js/term_upload.js
+++ b/js/term/upload.js
@@ -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
}
}
}
diff --git a/js/term_conn.js b/js/term_conn.js
deleted file mode 100644
index 37b2800..0000000
--- a/js/term_conn.js
+++ /dev/null
@@ -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
- }
-}
diff --git a/js/term_input.js b/js/term_input.js
deleted file mode 100644
index 2199f7e..0000000
--- a/js/term_input.js
+++ /dev/null
@@ -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
- }
- }
-}
diff --git a/js/term_screen.js b/js/term_screen.js
deleted file mode 100644
index 21e280c..0000000
--- a/js/term_screen.js
+++ /dev/null
@@ -1,1357 +0,0 @@
-// Some non-bold Fraktur symbols are outside the contiguous block
-const frakturExceptions = {
- 'C': '\u212d',
- 'H': '\u210c',
- 'I': '\u2111',
- 'R': '\u211c',
- 'Z': '\u2128'
-}
-
-// 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
-
-const SELECTION_BG = '#b2d7fe'
-const SELECTION_FG = '#333'
-
-const themes = [
- [ // Tango
- '#111213', '#CC0000', '#4E9A06', '#C4A000', '#3465A4', '#75507B', '#06989A', '#D3D7CF',
- '#555753', '#EF2929', '#8AE234', '#FCE94F', '#729FCF', '#AD7FA8', '#34E2E2', '#EEEEEC'
- ],
- [ // Linux
- '#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
- '#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5',
- '#002b36', '#cb4b16', '#586e75', '#657b83', '#839496', '#6c71c4', '#93a1a1', '#fdf6e3'
- ]
-]
-
-// TODO move this to the initializer so it's not run on non-terminal pages
-
-// 256color lookup table
-// should not be used to look up 0-15 (will return transparent)
-const 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})`)
-}
-
-window.TermScreen = class TermScreen {
- constructor () {
- this.canvas = mk('canvas')
- this.ctx = this.canvas.getContext('2d')
-
- if ('AudioContext' in window || 'webkitAudioContext' in window) {
- this.audioCtx = new (window.AudioContext || window.webkitAudioContext)()
- } else {
- console.warn('No AudioContext!')
- }
-
- // dummy
- this.input = new Proxy({}, {
- get () {
- return () => console.warn('TermScreen#input not set!')
- }
- })
-
- this.cursor = {
- x: 0,
- y: 0,
- blinkOn: false,
- blinking: true,
- visible: true,
- hanging: false,
- style: 'block',
- blinkInterval: null
- }
-
- this._palette = null
-
- 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,
- blinkStyleOn: true,
- blinkInterval: null,
- fitIntoWidth: 0,
- fitIntoHeight: 0,
- debug: false,
- graphics: 0
- }
-
- // 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 }
-
- // event listeners
- this._listeners = {}
-
- // 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.scheduleDraw(`window:${key}=${value}`)
- return true
- }
- })
-
- this.bracketedPaste = false
- this.blinkingCellCount = 0
-
- this.screen = []
- this.screenFG = []
- this.screenBG = []
- this.screenAttrs = []
-
- // 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, '']
-
- // start blink timers
- this.resetBlink()
- this.resetCursorBlink()
-
- let selecting = false
-
- let selectStart = (x, y) => {
- if (selecting) return
- selecting = true
- this.selection.start = this.selection.end = this.screenToGrid(x, y, true)
- this.scheduleDraw('select-start')
- }
-
- let selectMove = (x, y) => {
- if (!selecting) return
- this.selection.end = this.screenToGrid(x, y, true)
- this.scheduleDraw('select-move')
- }
-
- let selectEnd = (x, y) => {
- if (!selecting) return
- selecting = false
- this.selection.end = this.screenToGrid(x, y, true)
- this.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()
- })
-
- 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.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)
- })
-
- // bind ctrl+shift+c to copy
- key('⌃+⇧+c', e => {
- e.preventDefault()
- this.copySelectionToClipboard()
- })
- }
-
- /**
- * Bind an event listener to an event
- * @param {string} event - the event name
- * @param {Function} listener - the event listener
- */
- on (event, listener) {
- if (!this._listeners[event]) this._listeners[event] = []
- this._listeners[event].push({ listener })
- }
-
- /**
- * Bind an event listener to be run only once the next time the event fires
- * @param {string} event - the event name
- * @param {Function} listener - the event listener
- */
- once (event, listener) {
- if (!this._listeners[event]) this._listeners[event] = []
- this._listeners[event].push({ listener, once: true })
- }
-
- /**
- * Remove an event listener
- * @param {string} event - the event name
- * @param {Function} listener - the event listener
- */
- 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
- }
- }
- }
- }
-
- /**
- * Emits an event
- * @param {string} event - the event name
- * @param {...any} args - arguments passed to all listeners
- */
- 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)
- }
- }
-
- // this needs to be done in this roundabout way because for loops
- // do not like arrays with changing lengths
- for (let listener of remove) {
- listeners.splice(listeners.indexOf(listener), 1)
- }
- }
- }
-
- /**
- * The color palette. Should define 16 colors in an array.
- * @type {number[]}
- */
- get palette () {
- return this._palette || themes[0]
- }
- /** @type {number[]} */
- set palette (palette) {
- if (this._palette !== palette) {
- this._palette = palette
- this.scheduleDraw('palette')
- }
- }
-
- /**
- * 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 (this.palette[i]) 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 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})`
- }
-
- // default to transparent
- return 'rgba(0, 0, 0, 0)'
- }
-
- /**
- * Schedule a size update in the next millisecond
- */
- scheduleSizeUpdate () {
- clearTimeout(this._scheduledSizeUpdate)
- this._scheduledSizeUpdate = setTimeout(() => this.updateSize(), 1)
- }
-
- /**
- * 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 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}`
- }
-
- /**
- * 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,
- gridScaleX,
- gridScaleY,
- 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.drawnScreen = []
- this.drawnScreenFG = []
- this.drawnScreenBG = []
- this.drawnScreenAttrs = []
-
- // draw immediately; the canvas shouldn't flash
- this.draw('init')
- }
- }
-
- /**
- * Resets the cursor blink to on and restarts the timer
- */
- resetCursorBlink () {
- this.cursor.blinkOn = true
- clearInterval(this.cursor.blinkInterval)
- this.cursor.blinkInterval = setInterval(() => {
- this.cursor.blinkOn = this.cursor.blinking
- ? !this.cursor.blinkOn
- : true
- if (this.cursor.blinking) this.scheduleDraw('cursor-blink')
- }, 500)
- }
-
- /**
- * Resets the blink style to on and restarts the timer
- */
- resetBlink () {
- this.window.blinkStyleOn = true
- clearInterval(this.window.blinkInterval)
- let intervals = 0
- this.window.blinkInterval = setInterval(() => {
- if (this.blinkingCellCount <= 0) return
-
- intervals++
- if (intervals >= 4 && this.window.blinkStyleOn) {
- this.window.blinkStyleOn = false
- intervals = 0
- } else if (intervals >= 1 && !this.window.blinkStyleOn) {
- this.window.blinkStyleOn = true
- intervals = 0
- }
- }, 200)
- }
-
- /**
- * 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)
- }
-
- /**
- * 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)
- }
-
- /**
- * 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
- */
- drawCellBackground ({ 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
- */
- drawCell ({ 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 = TermScreen.alphaToFraktur(text)
- if (attrs & (1 << 6)) strike = true
- if (attrs & (1 << 7)) overline = true
-
- ctx.fillStyle = this.getColor(fg)
- ctx.fillText(text, (x + 0.5) * cellWidth, (y + 0.5) * cellHeight)
-
- 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.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,
- gridScaleX,
- gridScaleY
- } = this.window
-
- const charSize = this.getCharSize()
- const { width: cellWidth, height: cellHeight } = this.getCellSize()
- const screenWidth = width * cellWidth
- const screenHeight = height * cellHeight
- const screenLength = width * height
-
- ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
-
- if (this.window.debug && this._debug) this._debug.drawStart(why)
-
- ctx.font = this.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.cursor.hanging &&
- this.cursor.x === x &&
- this.cursor.y === y &&
- this.cursor.blinkOn &&
- this.cursor.visible
-
- let wasCursor = x === this.drawnCursor[0] && y === this.drawnCursor[1]
-
- let inSelection = this.isInSelection(x, y)
-
- let text = this.screen[cell]
- let fg = this.screenFG[cell]
- let bg = this.screenBG[cell]
- let attrs = this.screenAttrs[cell]
-
- if (attrs & (1 << 4) && !this.window.blinkStyleOn) {
- // blinking is enabled and blink style is off
- // set text to nothing so drawCell doesn't draw anything
- text = ''
- }
-
- if (inSelection) {
- fg = -1
- bg = -2
- }
-
- let didUpdate = text !== this.drawnScreen[cell] ||
- fg !== this.drawnScreenFG[cell] ||
- bg !== this.drawnScreenBG[cell] ||
- attrs !== this.drawnScreenAttrs[cell] ||
- isCursor !== wasCursor ||
- (isCursor && this.cursor.style !== this.drawnCursor[2])
-
- 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)
-
- // TODO: fonts (necessary?)
- let text = this.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)) {
- if (updateMap.get(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.window.graphics >= 1) {
- 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)
- regionStart = null
- }
- }
- if (regionStart !== null) {
- ctx.rect(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, fg, bg, attrs, isCursor] = data
-
- if (redrawMap.get(cell)) {
- this.drawCellBackground({ x, y, cellWidth, cellHeight, bg })
- }
- }
- }
-
- // 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.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.drawCell({
- 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.cursor.style]
-
- if (this.window.debug && this._debug) {
- // set cell flags
- let flags = 1 // always redrawn
- flags |= (+updateMap.get(cell)) << 1
- flags |= (+isTextWide(text)) << 2
- this._debug.setCell(cell, flags)
- }
- }
-
- if (isCursor && !inSelection) {
- ctx.save()
- ctx.beginPath()
- if (this.cursor.style === 'block') {
- // block
- ctx.rect(x * cellWidth, y * cellHeight, cellWidth, cellHeight)
- } else if (this.cursor.style === 'bar') {
- // vertical bar
- let barWidth = 2
- ctx.rect(x * cellWidth, y * cellHeight, barWidth, cellHeight)
- } else if (this.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.drawCellBackground({ x, y, cellWidth, cellHeight, bg })
- this.drawCell({
- x, y, charSize, cellWidth, cellHeight, text, fg, attrs
- })
- ctx.restore()
- }
- }
- }
-
- if (this.window.graphics >= 1) ctx.restore()
-
- if (this.window.debug && this._debug) this._debug.drawEnd()
- }
-
- /**
- * Parses the content of an `S` message and schedules a draw
- * @param {string} str - the message content
- */
- loadContent (str) {
- // current index
- let i = 0
- // Uncomment to capture screen content for the demo page
- // console.log(JSON.stringify(`S${str}`))
-
- // window size
- const newHeight = parse2B(str, i)
- const newWidth = parse2B(str, i + 2)
- const resized = (this.window.height !== newHeight) || (this.window.width !== newWidth)
- this.window.height = newHeight
- this.window.width = newWidth
- i += 4
-
- // cursor position
- let [cursorY, cursorX] = [parse2B(str, i), parse2B(str, i + 2)]
- i += 4
- let cursorMoved = (cursorX !== this.cursor.x || cursorY !== this.cursor.y)
- this.cursor.x = cursorX
- this.cursor.y = cursorY
-
- if (cursorMoved) {
- this.resetCursorBlink()
- this.emit('cursor-moved')
- }
-
- // attributes
- let attributes = parse3B(str, i)
- i += 3
-
- this.cursor.visible = !!(attributes & 1)
- this.cursor.hanging = !!(attributes & (1 << 1))
-
- this.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.cursor.style = 'block'
- else if (cursorStyle === 1) this.cursor.style = 'line'
- else if (cursorStyle === 2) this.cursor.style = 'bar'
-
- if (this.cursor.blinking !== cursorBlinking) {
- this.cursor.blinking = cursorBlinking
- this.resetCursorBlink()
- }
-
- this.input.setMouseMode(trackMouseClicks, trackMouseMovement)
- this.selection.selectable = !trackMouseClicks && !trackMouseMovement
- $(this.canvas).toggleClass('selectable', this.selection.selectable)
- this.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.bracketedPaste = !!(attributes & (1 << 13))
-
- // content
- let fg = 7
- let bg = 0
- let attrs = 0
- let cell = 0 // cell index
- let lastChar = ' '
- let screenLength = this.window.width * this.window.height
-
- if (resized) {
- this.updateSize()
- this.blinkingCellCount = 0
- this.screen = new Array(screenLength).fill(' ')
- this.screenFG = new Array(screenLength).fill(' ')
- this.screenBG = new Array(screenLength).fill(' ')
- this.screenAttrs = new Array(screenLength).fill(' ')
- }
-
- let strArray = !undef(Array.from) ? Array.from(str) : str.split('')
-
- 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
- if ((myAttrs & MASK_BLINK) !== 0 &&
- ((lastChar === ' ' && ((myAttrs & MASK_LINE_ATTR) === 0)) || // no line styles
- fg === bg // invisible text
- )
- ) {
- myAttrs ^= MASK_BLINK
- }
- // update blinking cells counter if blink state changed
- if ((this.screenAttrs[cell] & MASK_BLINK) !== (myAttrs & MASK_BLINK)) {
- if (myAttrs & MASK_BLINK) this.blinkingCellCount++
- else this.blinkingCellCount--
- }
-
- this.screen[cell] = lastChar
- this.screenFG[cell] = fg
- this.screenBG[cell] = bg
- this.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 = parse2B(strArray[i] + strArray[i + 1])
- i += 2
- for (let j = 0; j < count; j++) {
- setCellContent(cell)
- if (++cell > screenLength) break
- }
- break
-
- case SEQ_SET_COLORS:
- data = parse3B(strArray[i] + strArray[i + 1] + strArray[i + 2])
- i += 3
- fg = data & 0xFF
- bg = (data >> 8) & 0xFF
- break
-
- case SEQ_SET_ATTRS:
- data = parse2B(strArray[i] + strArray[i + 1])
- i += 2
- attrs = data & 0xFF
- break
-
- case SEQ_SET_FG:
- data = parse2B(strArray[i] + strArray[i + 1])
- i += 2
- fg = data & 0xFF
- break
-
- case SEQ_SET_BG:
- data = parse2B(strArray[i] + strArray[i + 1])
- i += 2
- bg = data & 0xFF
- break
-
- default:
- if (charCode < 32) character = '\ufffd'
- lastChar = character
- setCellContent(cell)
- cell++
- }
- }
-
- if (this.window.debug) console.log(`Blinky cells = ${this.blinkingCellCount}`)
-
- this.scheduleDraw('load', 16)
- this.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')
- qs('#screen-title').textContent = pieces[0]
- $('#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 ? esc(label) : ' '
- button.style.opacity = label ? 1 : 0.2
- })
- }
-
- /**
- * Shows an actual notification (if possible) or a notification balloon.
- * @param {string} text - the notification content
- */
- showNotification (text) {
- console.info(`Notification: ${text}`)
- if (Notification && Notification.permission === 'granted') {
- let notification = new Notification('ESPTerm', {
- body: text
- })
- notification.addEventListener('click', () => window.focus())
- } else {
- if (Notification && Notification.permission !== 'denied') {
- Notification.requestPermission()
- } else {
- // Fallback using the built-in notification balloon
- Notify.show(text)
- }
- }
- }
-
- /**
- * Loads a message from the server, and optionally a theme.
- * @param {string} str - the message
- * @param {number} [theme] - the new theme index
- */
- load (str, theme = -1) {
- const content = str.substr(1)
- if (theme >= 0 && theme < themes.length) {
- this.palette = themes[theme]
- }
-
- switch (str[0]) {
- case 'S':
- this.loadContent(content)
- break
-
- case 'T':
- this.loadLabels(content)
- break
-
- case 'B':
- this.beep()
- break
-
- case 'G':
- this.showNotification(content)
- break
-
- default:
- console.warn(`Bad data message type; ignoring.\n${JSON.stringify(str)}`)
- }
- }
-
- /**
- * 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()
-
- let osc, gain
-
- // main beep
- osc = audioCtx.createOscillator()
- gain = audioCtx.createGain()
- osc.connect(gain)
- gain.connect(audioCtx.destination)
- gain.gain.value = 0.5
- osc.frequency.value = 750
- osc.type = 'sine'
- osc.start()
- osc.stop(audioCtx.currentTime + 0.05)
-
- // surrogate beep (making it sound like 'oops')
- osc = audioCtx.createOscillator()
- gain = audioCtx.createGain()
- osc.connect(gain)
- gain.connect(audioCtx.destination)
- gain.gain.value = 0.2
- osc.frequency.value = 400
- osc.type = 'sine'
- osc.start(audioCtx.currentTime + 0.05)
- osc.stop(audioCtx.currentTime + 0.08)
- }
-
- /**
- * 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
- }
-}
diff --git a/js/utils.js b/js/utils.js
index 9a5049c..9a9e973 100755
--- a/js/utils.js
+++ b/js/utils.js
@@ -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)
}
diff --git a/js/wifi.js b/js/wifi.js
index 8e90328..40d8020 100644
--- a/js/wifi.js
+++ b/js/wifi.js
@@ -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)
}
}
diff --git a/lang/en.php b/lang/en.php
index 9c57a65..9b29c99 100644
--- a/lang/en.php
+++ b/lang/en.php
@@ -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 (F5, F11, F12…)',
'term.button_msgs' => 'Button codes (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',
diff --git a/package.json b/package.json
index cd9b9e5..33a779f 100644
--- a/package.json
+++ b/package.json
@@ -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 $@"
}
}
diff --git a/pages/_cfg_menu.php b/pages/_cfg_menu.php
index db01711..bf6afb0 100644
--- a/pages/_cfg_menu.php
+++ b/pages/_cfg_menu.php
@@ -13,8 +13,3 @@
}
?>
-
-
diff --git a/pages/_head.php b/pages/_head.php
index a5d3cd7..9466a12 100644
--- a/pages/_head.php
+++ b/pages/_head.php
@@ -3,7 +3,7 @@
-
+
= $_GET['PAGE_TITLE'] ?>
diff --git a/pages/about.php b/pages/about.php
index 6e7d4c5..8f9fa6d 100644
--- a/pages/about.php
+++ b/pages/about.php
@@ -43,7 +43,11 @@
Issues
- Please report any issues to the bugtracker or send them by e-mail (see above).
+ Please report any issues to our bugtracker or send them by e-mail.
+
+
+ ESPTerm has a mailing list for
+ troubleshooting and release announcements.
Firmware updates can be downloaded from the releases page and flashed
@@ -65,12 +69,24 @@
Thanks
-
- The webserver is based on a fork of the
- esphttpd library by Jeroen Domburg (Sprite_tm).
-
-
- Using (modified) JS library chibi.js by
- Kyle Barrow as a lightweight jQuery alternative.
-
+
…for making this project possible, in no particular order, go to:
+
+
+ *Jeroen "SpriteTM" Domburg,* for writing the esphttpd
+ server library we use (as a fork)
+
+
+ *Kyle Barrow,* for writing the chibi.js library
+ we use instead of jQuery
+
+
+ *cpsdqs,* for rewriting the front-end to use HTML5 canvas and other JS improvements
+
+
+ *Guenter Honisch,* for finding bugs and suggesting many improvements
+
+
+ *doc. Jan Fischer,* who came up with the original idea
+