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 +
  • +
diff --git a/pages/cfg_network.php b/pages/cfg_network.php index 21fefc1..d9b602d 100644 --- a/pages/cfg_network.php +++ b/pages/cfg_network.php @@ -2,7 +2,7 @@ $ipmask='pattern="^([0-9]{1,3}\.){3}[0-9]{1,3}$"'; ?> -
+

@@ -31,11 +31,11 @@ $ipmask='pattern="^([0-9]{1,3}\.){3}[0-9]{1,3}$"';
- +
-
+

@@ -69,7 +69,7 @@ $ipmask='pattern="^([0-9]{1,3}\.){3}[0-9]{1,3}$"';
- +
diff --git a/pages/cfg_system.php b/pages/cfg_system.php index d0287a3..bcf99f9 100644 --- a/pages/cfg_system.php +++ b/pages/cfg_system.php @@ -5,7 +5,7 @@
-
+ -
+
- + -
-

+ +

- +
- - + + + + + -  bps
- - + +
- - + + > +
+ +
+ + > +
+ +
+ + +
+ +
+ +
+
+ +
+

+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ +
- +
@@ -84,7 +106,5 @@ location.href = + '?pw=' + pw; } - $('#uart_baud').val(%uart_baud%); - $('#uart_parity').val(%uart_parity%); - $('#uart_stopbits').val(%uart_stopbits%); + $('#pwlock').val(%pwlock%); diff --git a/pages/cfg_term.php b/pages/cfg_term.php index e4dba6b..11b70aa 100644 --- a/pages/cfg_term.php +++ b/pages/cfg_term.php @@ -2,96 +2,139 @@
-
-

+ +

- +
-
- 3031323334353637 -
+ +
+
+ 01234567 +
-
- 9091929394959697 +
+ 89101112131415 +
+
-
- 4041424344454647 -
+
+ +
+
+ 01234567 +
-
- 100101102103104105106107 +
+ 89101112131415 +
-
+ +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+
- -   + + + + + +
+
+ +
+ + +
+

+ +
+ +
+
- -   +
+ + +
+
+ + +
@@ -99,33 +142,90 @@
+
+ + +
+
-   -   -   -   - + + + + +
- - + + + + +
+ +
+ +
+
+ +
+

+ +
+ +
+ +
+ + +  bps +
+ +
+ + +
+ +
+ +
- +
-
+

@@ -150,15 +250,6 @@  ms
-
- -   -   -   -   - -
-
@@ -177,12 +268,6 @@
-
- - -
-
@@ -196,33 +281,71 @@
- +
diff --git a/pages/cfg_wifi.php b/pages/cfg_wifi.php index 804f383..056c3e2 100644 --- a/pages/cfg_wifi.php +++ b/pages/cfg_wifi.php @@ -1,4 +1,4 @@ -
+

@@ -38,11 +38,11 @@
- +
-
+

@@ -85,7 +85,7 @@
- +
diff --git a/pages/help.php b/pages/help.php index 12bbb45..e577a72 100644 --- a/pages/help.php +++ b/pages/help.php @@ -22,4 +22,7 @@ function hpfold(yes) { $('.fold').toggleClass('expanded', !!yes); } + + // show theme colors - but this is a static page, so we don't know the current theme. + themes.themePreview(1) diff --git a/pages/help/sgr_colors.php b/pages/help/sgr_colors.php index b9a0e55..7ad778c 100644 --- a/pages/help/sgr_colors.php +++ b/pages/help/sgr_colors.php @@ -1,5 +1,5 @@ -
+

Commands: Color SGR

@@ -19,49 +19,49 @@

Foreground colors

- 30 - 31 - 32 - 33 - 34 - 35 - 36 - 37 + 3031323334353637
- 90 - 91 - 92 - 93 - 94 - 95 - 96 - 97 + 9091929394959697

Background colors

- 40 - 41 - 42 - 43 - 44 - 45 - 46 - 47 + 4041424344454647
- 100 - 101 - 102 - 103 - 104 - 105 - 106 - 107 + 100101102103104105106107

256-color palette

@@ -69,13 +69,40 @@

ESPTerm supports in total 256 standard colors. The dark and bright basic colors are numbered 0-7 and 8-15. To use colors higher than 15 (or 0-15 using this simpler numbering), - send `CSI 38 ; 5 ; n m`, where `n` is the color to set. Use 48 for background colors. + send `CSI 38 ; 5 ; n m`, where `n` is the color to set. Use `CSI 48 ; 5 ; n m` for background colors.

-

- For a fererence of all 256 shades please refer to - jonasjacek.github.io/colors - or look it up elsewhere. -

+
+
+ + diff --git a/pages/term.php b/pages/term.php index bbf97cd..3c0ede0 100644 --- a/pages/term.php +++ b/pages/term.php @@ -2,9 +2,9 @@ @@ -41,6 +41,7 @@

+
@@ -60,8 +61,7 @@