Compare commits

..

2 Commits

  1. 1
      .gitignore
  2. 2
      _build_js.sh
  3. 23
      _debug_replacements.php
  4. 4
      _pages.php
  5. 26
      base.php
  6. 15
      compile_html.php
  7. 28
      dump_js_lang.php
  8. 89
      js/appcommon.js
  9. 5
      js/lang.js
  10. 62
      js/term/buttons.js
  11. 22
      js/term/connection.js
  12. 539
      js/term/debug.js
  13. 381
      js/term/debug_screen.js
  14. 443
      js/term/demo.js
  15. 114
      js/term/index.js
  16. 1
      js/term/input.js
  17. 496
      js/term/screen.js
  18. 15
      js/term/screen_attr_bits.js
  19. 285
      js/term/screen_layout.js
  20. 353
      js/term/screen_parser.js
  21. 721
      js/term/screen_renderer.js
  22. 2
      js/term/soft_keyboard.js
  23. 36
      js/term/themes.js
  24. 7
      js/term_conf.js
  25. 4
      js/wifi.js
  26. 14
      lang/_js-dump.php
  27. 54
      lang/_js-lang-loader.js
  28. 2
      lang/common.php
  29. 34
      lang/cs.php
  30. 31
      lang/de.php
  31. 20
      lang/en.php
  32. 292
      lang/hu.php
  33. 12
      lang/js-keys.js
  34. 4
      package.json
  35. 1
      pages/_head.php
  36. 4
      pages/about.php
  37. 63
      pages/cfg_system.php
  38. 49
      pages/cfg_term.php
  39. 1
      pages/help.php
  40. 26
      pages/help/charsets.php
  41. 6
      pages/help/cmd_d2d.php
  42. 102
      pages/help/cmd_screen.php
  43. 165
      pages/help/cmd_system.php
  44. 39
      pages/help/iocontrol.php
  45. 4
      pages/help/sgr_colors.php
  46. 4
      pages/term.php
  47. 13
      sass/form/_form_layout.scss
  48. 2
      sass/layout/_base.scss
  49. 122
      sass/pages/_term.scss
  50. 13
      webpack.config.js
  51. 1958
      yarn.lock

1
.gitignore vendored

@ -7,3 +7,4 @@ node_modules/
.idea .idea
.sass-cache .sass-cache
*.map *.map
js/lang.js

@ -2,6 +2,8 @@
source "_build_common.sh" source "_build_common.sh"
mkdir -p out/js mkdir -p out/js
echo 'Generating lang.js...'
php ./dump_js_lang.php $@
echo 'Processing JS...' echo 'Processing JS...'
npm run webpack npm run webpack

@ -20,27 +20,18 @@ if (file_exists($versfn)) {
} }
return [ return [
'title' => ESP_DEMO ? 'ESPTerm Web UI Demo' : 'ESPTerm local debug', 'term_title' => ESP_DEMO ? 'ESPTerm Web UI Demo' : 'ESPTerm local debug',
'btn1' => 'OK', 'btn1' => 'OK',
'btn2' => 'Cancel', 'btn2' => 'Cancel',
'btn3' => '', 'btn3' => '',
'btn4' => '', 'btn4' => '',
'btn5' => 'Help', 'btn5' => 'Help',
'bm1' => '01,'.ord('y'), 'bm1' => '01,'.ord('y'),
'bm2' => '01,'.ord('n'), 'bm2' => '01,'.ord('n'),
'bm3' => '', 'bm3' => '',
'bm4' => '', 'bm4' => '',
'bm5' => '05', 'bm5' => '05',
'bc1' => '',
'bc2' => '',
'bc3' => '',
'bc4' => '',
'bc5' => '',
'button_count' => 5,
'labels_seq' => ESP_DEMO ? 'TESPTerm Web UI DemoOKCancelHelp' : 'TESPTerm local debugOKCancelHelp', 'labels_seq' => ESP_DEMO ? 'TESPTerm Web UI DemoOKCancelHelp' : 'TESPTerm local debugOKCancelHelp',
'want_all_fn' => '0', 'want_all_fn' => '0',
@ -87,21 +78,15 @@ return [
'sta_mac' => '5c:cf:7f:02:74:51', 'sta_mac' => '5c:cf:7f:02:74:51',
'ap_mac' => '5e:cf:7f:02:74:51', 'ap_mac' => '5e:cf:7f:02:74:51',
'gpio2_conf' => '0',
'gpio4_conf' => '1',
'gpio5_conf' => '1',
'width' => '80', 'term_width' => '80',
'height' => '25', 'term_height' => '25',
'default_bg' => '0', 'default_bg' => '0',
'default_fg' => '7', 'default_fg' => '7',
'show_buttons' => '1', 'show_buttons' => '1',
'show_config_links' => '1', 'show_config_links' => '1',
'font_stack' => '',
'font_size' => '20',
'uart_baudrate' => 115200, 'uart_baud' => 115200,
'uart_stopbits' => 1, 'uart_stopbits' => 1,
'uart_parity' => 2, 'uart_parity' => 2,

@ -41,9 +41,7 @@ pg('help', 'cfg page-help', 'help', '/help');
pg('about', 'cfg page-about', 'about', '/about'); pg('about', 'cfg page-about', 'about', '/about');
pg('term', 'term', '', '/', 'title.term'); pg('term', 'term', '', '/', 'title.term');
pg('reset_screen', 'api', '', '/api/v1/clear'); pg('reset_screen', 'api', '', '/api/v1/clear', 'title.term');
pg('ini_export', 'api', '', '/cfg/system/export');
pg('ini_import', 'api', '', '/cfg/system/import');
pg('index', 'api', '', '/', ''); pg('index', 'api', '', '/', '');

@ -151,8 +151,7 @@ if (!function_exists('load_esp_charsets')) {
if (! file_exists($chsf)) { if (! file_exists($chsf)) {
return [ return [
'!! ERROR: `../user/character_sets.h` not found !!' => [ '!! ERROR: `../user/character_sets.h` not found !!' => [
'start' => 65, ['65', 'A', '&'],
'chars' => ['&'],
], ],
]; ];
} }
@ -174,31 +173,20 @@ if (!function_exists('load_esp_charsets')) {
$rows = array_map('trim', $rows); $rows = array_map('trim', $rows);
foreach($rows as $j => $v) { foreach($rows as $j => $v) {
$literal = false;
if (strpos($v, '0x') === 0) { if (strpos($v, '0x') === 0) {
// hexa codes
$v = substr($v, 2); $v = substr($v, 2);
$v = hexdec($v); $v = hexdec($v);
} else if (strpos($v, 'u\'\\0\'') === 0) {
// zero
$v = 0;
} else if (strpos($v, 'u\'') === 0) {
// utf8 literals
$v = mb_substr($v, 2, 1, 'utf-8');
$literal = true;
} else { } else {
$v = intval($v); $v = intval($v);
} }
$ascii = $start+$j; $ascii = $start+$j;
$table[] = $literal ? $v : utf8($v==0? $ascii :$v); $table[] = [
$ascii,
chr($ascii),
utf8($v==0? $ascii :$v),
];
} }
$cps[$name] = $table;
$obj = [
'start' => $start,
'chars' => $table,
];
$cps[$name] = $obj;
} }
return $cps; return $cps;
} }

@ -63,17 +63,10 @@ foreach($_pages as $_k => $p) {
if (ESP_PROD) { if (ESP_PROD) {
$tmpfile = tempnam('/tmp', 'espterm').'.html'; $tmpfile = tempnam('/tmp', 'espterm').'.html';
file_put_contents($tmpfile, $s); file_put_contents($tmpfile, $s);
system('npm run html-minifier --'. // using https://github.com/tdewolff/minify
' --remove-optional-tags'. system('minify --html-keep-default-attrvals '.
' --remove-script-type-attributes'. '-o '.escapeshellarg($outputPath).' '.
' --remove-style-link-type-attributes'. ''.escapeshellarg($tmpfile), $rv);
' --remove-comments'.
' --collapse-whitespace'.
' --collapse-boolean-attributes'.
' --html5'.
//' --max-line-length 120'.
' -o '.escapeshellarg($outputPath).
' '.escapeshellarg($tmpfile), $rv);
// fallback if minify is not installed // fallback if minify is not installed
if (!file_exists($outputPath)) file_put_contents($outputPath, $s); if (!file_exists($outputPath)) file_put_contents($outputPath, $s);

@ -0,0 +1,28 @@
<?php
/* This script is run on demand to generate JS version of tr() */
require_once __DIR__ . '/base.php';
$selected = [
'wifi.connected_ip_is',
'wifi.not_conn',
'wifi.enter_passwd',
'term_nav.fullscreen',
'term_conn.connecting',
'term_conn.waiting_content',
'term_conn.disconnected',
'term_conn.waiting_server',
'term_conn.reconnecting'
];
$out = [];
foreach ($selected as $key) {
$out[$key] = tr($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" .
"module.exports = function tr (key) { return _tr[key] || '?' + key + '?' }\n"
);

@ -90,65 +90,41 @@ $.ready(function () {
e.preventDefault() e.preventDefault()
}) })
try { // populate the form errors box from GET arg ?err=...
do { // (a way to pass errors back from server via redirect)
let msgAt, box let errAt = window.location.search.indexOf('err=')
// populate the form errors box from GET arg ?err=... if (errAt !== -1 && qs('.Box.errors')) {
// (a way to pass errors back from server via redirect) let errs = decodeURIComponent(window.location.search.substr(errAt + 4)).split(',')
msgAt = window.location.search.indexOf('err=') let humanReadableErrors = []
if (msgAt !== -1 && qs('.Box.errors')) { errs.forEach(function (er) {
let errs = decodeURIComponent(window.location.search.substr(msgAt + 4)).split(',') let lbls = qsa('label[for="' + er + '"]')
let humanReadableErrors = [] if (lbls) {
errs.forEach(function (er) { for (let i = 0; i < lbls.length; i++) {
if (er.length === 0) return let lbl = lbls[i]
let lbls = qsa('label[for="' + er + '"]') lbl.classList.add('error')
if (lbls && lbls.length > 0) { if (i === 0) humanReadableErrors.push(lbl.childNodes[0].textContent.trim().replace(/: ?$/, ''))
for (let i = 0; i < lbls.length; i++) { }
let lbl = lbls[i] } else {
lbl.classList.add('error') humanReadableErrors.push(er)
if (i === 0) humanReadableErrors.push(lbl.childNodes[0].textContent.trim().replace(/: ?$/, ''))
}
} else {
console.log(JSON.stringify(er))
humanReadableErrors.push(er)
}
})
qs('.Box.errors .list').innerHTML = humanReadableErrors.join(', ')
qs('.Box.errors').classList.remove('hidden')
break
}
let fademsgbox = function (box, time) {
box.classList.remove('hidden')
setTimeout(() => {
box.classList.add('hiding')
setTimeout(() => {
box.classList.add('hidden')
}, 1000)
}, time)
} }
})
msgAt = window.location.search.indexOf('errmsg=') qs('.Box.errors .list').innerHTML = humanReadableErrors.join(', ')
box = qs('.Box.errmessage') qs('.Box.errors').classList.remove('hidden')
if (msgAt !== -1 && box) { }
let msg = decodeURIComponent(window.location.search.substr(msgAt + 7))
box.innerHTML = msg
fademsgbox(box, 3000)
break
}
msgAt = window.location.search.indexOf('msg=') let msgAt = window.location.search.indexOf('msg=')
box = qs('.Box.message') if (msgAt !== -1 && qs('.Box.message')) {
if (msgAt !== -1 && box) { let msg = decodeURIComponent(window.location.search.substr(msgAt + 4))
let msg = decodeURIComponent(window.location.search.substr(msgAt + 4)) let box = qs('.Box.message')
box.innerHTML = msg box.innerHTML = msg
fademsgbox(box, 2000) box.classList.remove('hidden')
break setTimeout(() => {
} box.classList.add('hiding')
} while (0) setTimeout(() => {
} catch (e) { box.classList.add('hidden')
console.error(e) }, 1000)
}, 2000)
} }
modal.init() modal.init()
@ -193,7 +169,6 @@ $.ready(function () {
'Server connection failed! Trying again' + 'Server connection failed! Trying again' +
'<span class="anim-dots" style="width:1.5em;text-align:left;display:inline-block">.</span>' '<span class="anim-dots" style="width:1.5em;text-align:left;display:inline-block">.</span>'
qs('#screen').appendChild(bnr) qs('#screen').appendChild(bnr)
qs('#screen').classList.add('failed')
showPage() showPage()
} }
}, 2000) }, 2000)

@ -1,5 +0,0 @@
let data = require('locale-data')
module.exports = function localize (key) {
return data[key] || `?${key}?`
}

@ -1,19 +1,11 @@
const { getColor } = require('./themes')
const { qs } = require('../utils') const { qs } = require('../utils')
const { rgb2hsl, hex2rgb, rgb2hex, hsl2rgb } = require('../lib/color_utils')
module.exports = function initButtons (input) { module.exports = function initButtons (input) {
let container = qs('#action-buttons') let container = qs('#action-buttons')
// current color palette
let palette = []
// button labels // button labels
let labels = [] let labels = []
// button colors
let colors = {}
// button elements // button elements
let buttons = [] let buttons = []
@ -29,13 +21,6 @@ module.exports = function initButtons (input) {
// might as well use the attribute ¯\_(ツ)_/¯ // might as well use the attribute ¯\_(ツ)_/¯
let index = +button.getAttribute('data-n') let index = +button.getAttribute('data-n')
input.sendButton(index) input.sendButton(index)
e.target.blur() // if it keeps focus, spacebar will push it
})
// this prevents button retaining focus after half-click/drag-away
button.addEventListener('mouseleave', e => {
e.target.blur()
}) })
return button return button
@ -54,7 +39,7 @@ module.exports = function initButtons (input) {
pushButton() pushButton()
} }
} else if (buttons.length > labels.length) { } else if (buttons.length > labels.length) {
for (let i = buttons.length; i > labels.length; i--) { for (let i = labels.length; i < buttons.length; i++) {
popButton() popButton()
} }
} }
@ -62,51 +47,12 @@ module.exports = function initButtons (input) {
for (let i = 0; i < labels.length; i++) { for (let i = 0; i < labels.length; i++) {
let label = labels[i].trim() let label = labels[i].trim()
let button = buttons[i] let button = buttons[i]
let color = colors[i]
button.textContent = label || '\u00a0' // label or nbsp button.textContent = label || '\u00a0' // label or nbsp
if (!label) {
if (!label) button.classList.add('inactive') button.classList.add('inactive')
else button.classList.remove('inactive')
// 0 or undefined can be used to disable custom color
if (Number.isFinite(color) && color !== 0) {
const clr = getColor(color, palette)
button.style.background = clr
// darken the color a bit for the 3D side
const hsl = rgb2hsl(...hex2rgb(clr))
const hex = rgb2hex(...hsl2rgb(hsl[0], hsl[1], hsl[2] * 0.7))
button.style.boxShadow = `0 3px 0 ${hex}`
} else {
button.style.background = null
button.style.boxShadow = null
} }
} }
} }
return { return { update, labels }
update,
get labels () {
return labels
},
set labels (value) {
labels = value
update()
},
get colors () {
return colors
},
set colors (value) {
colors = value
update()
},
get palette () {
return palette
},
set palette (value) {
palette = value
update()
}
}
} }

@ -5,7 +5,6 @@ try { demo = require('./demo') } catch (err) {}
const RECONN_DELAY = 2000 const RECONN_DELAY = 2000
const HEARTBEAT_TIME = 3000 const HEARTBEAT_TIME = 3000
const HTTPS = window.location.protocol.match(/s:/)
/** Handle connections */ /** Handle connections */
module.exports = class TermConnection extends EventEmitter { module.exports = class TermConnection extends EventEmitter {
@ -20,10 +19,9 @@ module.exports = class TermConnection extends EventEmitter {
this.autoXoffTimeout = null this.autoXoffTimeout = null
this.reconnTimeout = null this.reconnTimeout = null
this.forceClosing = false this.forceClosing = false
this.queue = []
try { try {
this.blobReader = new window.FileReader() this.blobReader = new FileReader()
this.blobReader.onload = (evt) => { this.blobReader.onload = (evt) => {
this.onDecodedWSMessage(this.blobReader.result) this.onDecodedWSMessage(this.blobReader.result)
} }
@ -84,6 +82,7 @@ module.exports = class TermConnection extends EventEmitter {
onDecodedWSMessage (str) { onDecodedWSMessage (str) {
switch (str.charAt(0)) { switch (str.charAt(0)) {
case '.': case '.':
console.log(str)
// heartbeat, no-op message // heartbeat, no-op message
break break
@ -92,14 +91,12 @@ module.exports = class TermConnection extends EventEmitter {
this.xoff = true this.xoff = true
this.autoXoffTimeout = setTimeout(() => { this.autoXoffTimeout = setTimeout(() => {
this.xoff = false this.xoff = false
this.flushQueue()
}, 250) }, 250)
break break
case '+': case '+':
// console.log('xon'); // console.log('xon');
this.xoff = false this.xoff = false
this.flushQueue()
clearTimeout(this.autoXoffTimeout) clearTimeout(this.autoXoffTimeout)
break break
@ -146,8 +143,8 @@ module.exports = class TermConnection extends EventEmitter {
return true // Simulate success return true // Simulate success
} }
if (this.xoff) { if (this.xoff) {
console.log("Can't send, flood control. Queueing") // TODO queue
this.queue.push(message) console.log("Can't send, flood control.")
return false return false
} }
@ -163,12 +160,6 @@ module.exports = class TermConnection extends EventEmitter {
return true return true
} }
flushQueue () {
console.log('Flushing input queue')
for (let message of this.queue) this.send(message)
this.queue = []
}
/** Safely close the socket */ /** Safely close the socket */
closeSocket () { closeSocket () {
if (this.ws) { if (this.ws) {
@ -193,7 +184,7 @@ module.exports = class TermConnection extends EventEmitter {
this.closeSocket() this.closeSocket()
this.ws = new window.WebSocket(`${HTTPS ? 'wss' : 'ws'}://${window._root}/term/update.ws`) this.ws = new window.WebSocket('ws://' + window._root + '/term/update.ws')
this.ws.addEventListener('open', (...args) => this.onWSOpen(...args)) this.ws.addEventListener('open', (...args) => this.onWSOpen(...args))
this.ws.addEventListener('close', (...args) => this.onWSClose(...args)) this.ws.addEventListener('close', (...args) => this.onWSClose(...args))
this.ws.addEventListener('message', (...args) => this.onWSMessage(...args)) this.ws.addEventListener('message', (...args) => this.onWSMessage(...args))
@ -204,7 +195,6 @@ module.exports = class TermConnection extends EventEmitter {
} }
heartbeat () { heartbeat () {
this.emit('heartbeat')
clearTimeout(this.heartbeatTimeout) clearTimeout(this.heartbeatTimeout)
this.heartbeatTimeout = setTimeout(() => this.onHeartbeatFail(), HEARTBEAT_TIME) this.heartbeatTimeout = setTimeout(() => this.onHeartbeatFail(), HEARTBEAT_TIME)
} }
@ -212,7 +202,7 @@ module.exports = class TermConnection extends EventEmitter {
sendPing () { sendPing () {
console.log('> ping') console.log('> ping')
this.emit('ping') this.emit('ping')
$.get(`${HTTPS ? 'https' : 'http'}://${window._root}/api/v1/ping`, (resp, status) => { $.get('http://' + window._root + '/api/v1/ping', (resp, status) => {
if (status === 200) { if (status === 200) {
clearInterval(this.pingInterval) clearInterval(this.pingInterval)
console.info('Server ready, opening socket…') console.info('Server ready, opening socket…')

@ -1,539 +0,0 @@
const { getColor } = require('./themes')
const {
ATTR_FG,
ATTR_BG,
ATTR_BOLD,
ATTR_UNDERLINE,
ATTR_BLINK,
ATTR_ITALIC,
ATTR_STRIKE,
ATTR_OVERLINE,
ATTR_FAINT,
ATTR_FRAKTUR
} = require('./screen_attr_bits')
// debug toolbar, tooltip and screen
module.exports = function attachDebugger (screen, connection) {
// debug screen overlay
const debugCanvas = document.createElement('canvas')
debugCanvas.classList.add('debug-canvas')
const ctx = debugCanvas.getContext('2d')
// debug toolbar
const toolbar = document.createElement('div')
toolbar.classList.add('debug-toolbar')
// debug tooltip
const tooltip = document.createElement('div')
tooltip.classList.add('debug-tooltip')
tooltip.classList.add('hidden')
// update functions, defined somewhere below
let updateTooltip
let updateToolbar
// tooltip cell
let selectedCell = null
// update tooltip cell when mouse moves
const onMouseMove = (e) => {
if (e.target !== screen.layout.canvas) {
selectedCell = null
return
}
selectedCell = screen.layout.screenToGrid(e.offsetX, e.offsetY)
updateTooltip()
}
// hide tooltip when mouse leaves
const onMouseOut = (e) => {
selectedCell = null
tooltip.classList.add('hidden')
}
// updates debug canvas size
const updateCanvasSize = function () {
let { width, height, devicePixelRatio } = screen.layout.window
let cellSize = screen.layout.getCellSize()
let padding = Math.round(screen.layout._padding)
debugCanvas.width = (width * cellSize.width + 2 * padding) * devicePixelRatio
debugCanvas.height = (height * cellSize.height + 2 * padding) * devicePixelRatio
debugCanvas.style.width = `${width * cellSize.width + 2 * screen.layout._padding}px`
debugCanvas.style.height = `${height * cellSize.height + 2 * screen.layout._padding}px`
}
// defined somewhere below
let startDrawLoop
let screenAttached = false
// node to which events were bound (kept here for when they need to be removed)
let eventNode
// attaches/detaches debug screen overlay to/from DOM
const setScreenAttached = function (attached) {
if (attached && !debugCanvas.parentNode) {
screen.layout.canvas.parentNode.appendChild(debugCanvas)
eventNode = debugCanvas.parentNode
eventNode.addEventListener('mousemove', onMouseMove)
eventNode.addEventListener('mouseout', onMouseOut)
screen.layout.on('size-update', updateCanvasSize)
updateCanvasSize()
screenAttached = true
startDrawLoop()
} else if (!attached && debugCanvas.parentNode) {
debugCanvas.parentNode.removeChild(debugCanvas)
eventNode.removeEventListener('mousemove', onMouseMove)
eventNode.removeEventListener('mouseout', onMouseOut)
screen.layout.removeListener('size-update', updateCanvasSize)
screenAttached = false
}
}
// attaches/detaches toolbar and tooltip to/from DOM
const setToolbarAttached = function (attached) {
if (attached && !toolbar.parentNode) {
screen.layout.canvas.parentNode.appendChild(toolbar)
screen.layout.canvas.parentNode.appendChild(tooltip)
updateToolbar()
} else if (!attached && toolbar.parentNode) {
screen.layout.canvas.parentNode.removeChild(toolbar)
screen.layout.canvas.parentNode.removeChild(tooltip)
}
}
// attach/detach toolbar when debug mode is enabled/disabled
screen.on('update-window:debug', enabled => {
setToolbarAttached(enabled)
})
// ditto ^
screen.layout.on('update-window:debug', enabled => {
setScreenAttached(enabled)
})
let drawData = {
// last draw reason
reason: '',
// when true, will show colored cell update overlays
showUpdates: false,
// draw start time in milliseconds
startTime: 0,
// end time
endTime: 0,
// partial update frames
frames: [],
// cell data
cells: new Map(),
// scroll region
scrollRegion: null
}
// debug interface
screen._debug = screen.layout.renderer._debug = {
drawStart (reason) {
drawData.reason = reason
drawData.startTime = window.performance.now()
},
drawEnd () {
drawData.endTime = window.performance.now()
},
setCell (cell, flags) {
drawData.cells.set(cell, [flags, window.performance.now()])
},
pushFrame (frame) {
drawData.frames.push([...frame, window.performance.now()])
}
}
let isDrawing = false
let drawLoop = function () {
// draw while the screen is attached
if (screenAttached) window.requestAnimationFrame(drawLoop)
else isDrawing = false
let now = window.performance.now()
let { width, height, devicePixelRatio } = screen.layout.window
let padding = Math.round(screen.layout._padding)
let cellSize = screen.layout.getCellSize()
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
ctx.clearRect(0, 0, width * cellSize.width + 2 * padding, height * cellSize.height + 2 * padding)
ctx.translate(padding, padding)
ctx.lineWidth = 2
ctx.lineJoin = 'round'
if (drawData.showUpdates) {
const cells = drawData.cells
for (let cell = 0; cell < width * height; cell++) {
// cell does not exist or has no flags set
if (!cells.has(cell) || cells.get(cell)[0] === 0) continue
const [flags, timestamp] = cells.get(cell)
let elapsedTime = (now - timestamp) / 1000
if (elapsedTime > 1) {
cells.delete(cell)
continue
}
ctx.globalAlpha = 0.5 * Math.max(0, 1 - elapsedTime)
let x = cell % width
let y = Math.floor(cell / width)
if (flags & 2) {
// updated
ctx.fillStyle = '#0f0'
} else if (flags & 1) {
// redrawn
ctx.fillStyle = '#f0f'
}
if (!(flags & 4)) {
// outside a clipped region
ctx.fillStyle = '#0ff'
}
if (flags & 16) {
// was filled to speed up rendering
ctx.globalAlpha /= 2
}
ctx.fillRect(x * cellSize.width, y * cellSize.height, cellSize.width, cellSize.height)
if (flags & 8) {
// wide cell
ctx.strokeStyle = '#f00'
ctx.beginPath()
ctx.moveTo(x * cellSize.width, (y + 1) * cellSize.height)
ctx.lineTo((x + 1) * cellSize.width, (y + 1) * cellSize.height)
ctx.stroke()
}
}
let framesToDelete = []
for (let frame of drawData.frames) {
let timestamp = frame[4]
let elapsedTime = (now - timestamp) / 1000
if (elapsedTime > 1) framesToDelete.push(frame)
else {
ctx.globalAlpha = 1 - elapsedTime
ctx.strokeStyle = '#ff0'
ctx.strokeRect(frame[0] * cellSize.width, frame[1] * cellSize.height,
frame[2] * cellSize.width, frame[3] * cellSize.height)
}
}
for (let frame of framesToDelete) {
drawData.frames.splice(drawData.frames.indexOf(frame), 1)
}
}
if (selectedCell !== null) {
// draw a dashed outline around the selected cell
let [x, y] = selectedCell
ctx.save()
ctx.globalAlpha = 0.5
ctx.lineWidth = 1
// draw X line
ctx.beginPath()
ctx.moveTo(0, y * cellSize.height)
ctx.lineTo(x * cellSize.width, y * cellSize.height)
ctx.strokeStyle = '#f00'
ctx.setLineDash([cellSize.width])
ctx.stroke()
// draw Y line
ctx.beginPath()
ctx.moveTo(x * cellSize.width, 0)
ctx.lineTo(x * cellSize.width, y * cellSize.height)
ctx.strokeStyle = '#0f0'
ctx.setLineDash([cellSize.height])
ctx.stroke()
ctx.globalAlpha = 1
ctx.lineWidth = 1 + 0.5 * Math.sin((now / 1000) * 10)
ctx.strokeStyle = '#fff'
ctx.lineJoin = 'round'
ctx.setLineDash([2, 2])
ctx.lineDashOffset = (now / 1000) * 10
ctx.strokeRect(x * cellSize.width, y * cellSize.height, cellSize.width, cellSize.height)
ctx.lineDashOffset += 2
ctx.strokeStyle = '#000'
ctx.strokeRect(x * cellSize.width, y * cellSize.height, cellSize.width, cellSize.height)
ctx.restore()
}
if (drawData.scrollRegion !== null) {
// draw two lines marking the scroll region bounds
let [start, end] = drawData.scrollRegion
ctx.save()
ctx.globalAlpha = 1
ctx.strokeStyle = '#00f'
ctx.lineWidth = 2
ctx.setLineDash([2, 2])
ctx.beginPath()
ctx.moveTo(0, start * cellSize.height)
ctx.lineTo(width * cellSize.width, start * cellSize.height)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(0, (end + 1) * cellSize.height)
ctx.lineTo(width * cellSize.width, (end + 1) * cellSize.height)
ctx.stroke()
ctx.restore()
}
}
startDrawLoop = function () {
if (isDrawing) return
isDrawing = true
drawLoop()
}
let pad2 = i => ('00' + i.toString()).substr(-2)
let formatColor = color => color < 256
? color.toString()
: '#' + pad2(color >> 16) + pad2((color >> 8) & 0xFF) + pad2(color & 0xFF)
let makeSpan = (text, styles) => {
let span = document.createElement('span')
span.textContent = text
Object.assign(span.style, styles || {})
return span
}
let formatAttributes = (target, attrs) => {
if (attrs & ATTR_FG) target.appendChild(makeSpan('HasFG'))
if (attrs & ATTR_BG) target.appendChild(makeSpan('HasBG'))
if (attrs & ATTR_BOLD) target.appendChild(makeSpan('Bold', { fontWeight: 'bold' }))
if (attrs & ATTR_UNDERLINE) target.appendChild(makeSpan('Uline', { textDecoration: 'underline' }))
if (attrs & ATTR_BLINK) target.appendChild(makeSpan('Blink'))
if (attrs & ATTR_ITALIC) target.appendChild(makeSpan('Italic', { fontStyle: 'italic' }))
if (attrs & ATTR_STRIKE) target.appendChild(makeSpan('Strike', { textDecoration: 'line-through' }))
if (attrs & ATTR_OVERLINE) target.appendChild(makeSpan('Oline', { textDecoration: 'overline' }))
if (attrs & ATTR_FAINT) target.appendChild(makeSpan('Faint', { opacity: 0.5 }))
if (attrs & ATTR_FRAKTUR) target.appendChild(makeSpan('Fraktur'))
}
updateTooltip = function () {
// TODO: make this not destroy and recreate the same nodes every time
tooltip.classList.remove('hidden')
tooltip.innerHTML = ''
let cell = selectedCell[1] * screen.window.width + selectedCell[0]
if (!screen.screen[cell]) return
let foreground = document.createElement('span')
foreground.textContent = formatColor(screen.screenFG[cell])
let preview = document.createElement('span')
preview.textContent = ' ●'
preview.style.color = getColor(screen.screenFG[cell], screen.layout.renderer.palette)
foreground.appendChild(preview)
let background = document.createElement('span')
background.textContent = formatColor(screen.screenBG[cell])
let bgPreview = document.createElement('span')
bgPreview.textContent = ' ●'
bgPreview.style.color = getColor(screen.screenBG[cell], screen.layout.renderer.palette)
background.appendChild(bgPreview)
let character = screen.screen[cell]
let codePoint = character.codePointAt(0)
let formattedCodePoint = codePoint.toString(16).length <= 4
? `0000${codePoint.toString(16)}`.substr(-4)
: codePoint.toString(16)
let attributes = document.createElement('span')
attributes.classList.add('attributes')
formatAttributes(attributes, screen.screenAttrs[cell])
let data = {
Cell: `col ${selectedCell[0] + 1}, ln ${selectedCell[1] + 1} (${cell})`,
Foreground: foreground,
Background: background,
Character: `U+${formattedCodePoint}`,
Attributes: attributes
}
let table = document.createElement('table')
for (let name in data) {
let row = document.createElement('tr')
let label = document.createElement('td')
label.appendChild(new window.Text(name))
label.classList.add('label')
let value = document.createElement('td')
value.appendChild(typeof data[name] === 'string' ? new window.Text(data[name]) : data[name])
value.classList.add('value')
row.appendChild(label)
row.appendChild(value)
table.appendChild(row)
}
tooltip.appendChild(table)
let cellSize = screen.layout.getCellSize()
// add 3 to the position because for some reason the corner is off
let posX = (selectedCell[0] + 1) * cellSize.width + 3
let posY = (selectedCell[1] + 1) * cellSize.height + 3
tooltip.style.transform = `translate(${posX}px, ${posY}px)`
}
let toolbarData = null
let toolbarNodes = {}
// construct the toolbar if it wasn't already
const initToolbar = function () {
if (toolbarData) return
let showUpdates = document.createElement('input')
showUpdates.type = 'checkbox'
showUpdates.addEventListener('change', e => {
drawData.showUpdates = showUpdates.checked
})
let fancyGraphics = document.createElement('input')
fancyGraphics.type = 'checkbox'
fancyGraphics.value = !!screen.layout.renderer.graphics
fancyGraphics.addEventListener('change', e => {
screen.layout.renderer.graphics = +fancyGraphics.checked
})
toolbarData = {
cursor: {
title: 'Cursor',
Position: '',
Style: '',
Visible: true,
Hanging: false
},
internal: {
Flags: '',
'Cursor Attributes': '',
'Code Page': '',
Heap: 0,
Clients: 0
},
drawing: {
title: 'Drawing',
'Last Update': '',
'Show Updates': showUpdates,
'Fancy Graphics': fancyGraphics,
'Redraw Screen': () => {
screen.layout.renderer.resetDrawn()
screen.layout.renderer.draw('debug-redraw')
}
}
}
for (let i in toolbarData) {
let group = toolbarData[i]
let table = document.createElement('table')
table.classList.add('toolbar-group')
toolbarNodes[i] = {}
for (let key in group) {
let item = document.createElement('tr')
let name = document.createElement('td')
name.classList.add('name')
let value = document.createElement('td')
value.classList.add('value')
toolbarNodes[i][key] = { name, value }
if (key === 'title') {
name.textContent = group[key]
name.classList.add('title')
} else {
name.textContent = key
if (group[key] instanceof Function) {
name.textContent = ''
let button = document.createElement('button')
name.classList.add('has-button')
name.appendChild(button)
button.textContent = key
button.addEventListener('click', e => group[key](e))
} else if (group[key] instanceof window.Node) value.appendChild(group[key])
else value.textContent = group[key]
}
item.appendChild(name)
item.appendChild(value)
table.appendChild(item)
}
toolbar.appendChild(table)
}
let heartbeat = toolbarNodes.heartbeat = document.createElement('div')
heartbeat.classList.add('heartbeat')
heartbeat.textContent = '❤'
toolbar.appendChild(heartbeat)
}
connection.on('heartbeat', () => {
if (screenAttached && toolbarNodes.heartbeat) {
toolbarNodes.heartbeat.classList.remove('beat')
window.requestAnimationFrame(() => {
toolbarNodes.heartbeat.classList.add('beat')
})
}
})
updateToolbar = function () {
initToolbar()
Object.assign(toolbarData.cursor, {
Position: `col ${screen.cursor.x + 1}, ln ${screen.cursor.y + 1}`,
Style: screen.cursor.style + (screen.cursor.blinking ? ', blink' : ''),
Visible: screen.cursor.visible,
Hanging: screen.cursor.hanging
})
let drawTime = Math.round((drawData.endTime - drawData.startTime) * 100) / 100
toolbarData.drawing['Last Update'] = `${drawData.reason} (${drawTime}ms)`
toolbarData.drawing['Fancy Graphics'].checked = !!screen.layout.renderer.graphics
for (let i in toolbarData) {
let group = toolbarData[i]
let nodes = toolbarNodes[i]
for (let key in group) {
if (key === 'title') continue
let value = nodes[key].value
if (!(group[key] instanceof window.Node) && !(group[key] instanceof Function)) {
value.textContent = group[key]
}
}
}
}
screen.on('update', updateToolbar)
screen.on('internal', data => {
if (screenAttached && toolbarData) {
Object.assign(toolbarData.internal, {
Flags: data.flags.toString(2),
'Cursor Attributes': data.cursorAttrs.toString(2),
'Code Page': `${data.charsetGx} (${data.charsetG0}, ${data.charsetG1})`,
Heap: data.freeHeap,
Clients: data.clientCount
})
drawData.scrollRegion = [data.regionStart, data.regionEnd]
updateToolbar()
}
})
}

@ -0,0 +1,381 @@
const { mk } = require('../utils')
module.exports = function attachDebugScreen (screen) {
const debugCanvas = mk('canvas')
const ctx = debugCanvas.getContext('2d')
debugCanvas.classList.add('debug-canvas')
let mouseHoverCell = null
let updateToolbar
let onMouseMove = e => {
mouseHoverCell = screen.screenToGrid(e.offsetX, e.offsetY)
startDrawing()
updateToolbar()
}
let onMouseOut = () => (mouseHoverCell = null)
let addCanvas = function () {
if (!debugCanvas.parentNode) {
screen.canvas.parentNode.appendChild(debugCanvas)
screen.canvas.addEventListener('mousemove', onMouseMove)
screen.canvas.addEventListener('mouseout', onMouseOut)
}
}
let removeCanvas = function () {
if (debugCanvas.parentNode) {
debugCanvas.parentNode.removeChild(debugCanvas)
screen.canvas.removeEventListener('mousemove', onMouseMove)
screen.canvas.removeEventListener('mouseout', onMouseOut)
onMouseOut()
}
}
let updateCanvasSize = function () {
let { width, height, devicePixelRatio } = screen.window
let cellSize = screen.getCellSize()
debugCanvas.width = width * cellSize.width * devicePixelRatio
debugCanvas.height = height * cellSize.height * devicePixelRatio
debugCanvas.style.width = `${width * cellSize.width}px`
debugCanvas.style.height = `${height * cellSize.height}px`
}
let drawInfo = mk('div')
drawInfo.classList.add('draw-info')
let startTime, endTime, lastReason
let cells = new Map()
let clippedRects = []
let updateFrames = []
let startDrawing
screen._debug = {
drawStart (reason) {
lastReason = reason
startTime = Date.now()
clippedRects = []
},
drawEnd () {
endTime = Date.now()
console.log(drawInfo.textContent = `Draw: ${lastReason} (${(endTime - startTime)} ms) with graphics=${screen.window.graphics}`)
startDrawing()
},
setCell (cell, flags) {
cells.set(cell, [flags, Date.now()])
},
clipRect (...args) {
clippedRects.push(args)
},
pushFrame (frame) {
frame.push(Date.now())
updateFrames.push(frame)
startDrawing()
}
}
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 lastDrawTime = 0
let t = 0
let drawLoop = function () {
if (isDrawing) window.requestAnimationFrame(drawLoop)
let dt = (Date.now() - lastDrawTime) / 1000
lastDrawTime = Date.now()
t += dt
let { devicePixelRatio, width, height } = screen.window
let { width: cellWidth, height: cellHeight } = screen.getCellSize()
let screenLength = width * height
let now = Date.now()
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
ctx.clearRect(0, 0, width * cellWidth, height * cellHeight)
let activeCells = 0
for (let cell = 0; cell < screenLength; cell++) {
if (!cells.has(cell) || cells.get(cell)[0] === 0) continue
let [flags, timestamp] = cells.get(cell)
let elapsedTime = (now - timestamp) / 1000
if (elapsedTime > 1) continue
activeCells++
ctx.globalAlpha = 0.5 * Math.max(0, 1 - elapsedTime)
let x = cell % width
let y = Math.floor(cell / width)
if (flags & 1) {
// redrawn
ctx.fillStyle = '#f0f'
}
if (flags & 2) {
// updated
ctx.fillStyle = '#0f0'
}
ctx.fillRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight)
if (flags & 4) {
// wide cell
ctx.lineWidth = 2
ctx.strokeStyle = '#f00'
ctx.strokeRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight)
}
}
if (clippedRects.length) {
ctx.globalAlpha = 0.5
ctx.beginPath()
for (let rect of clippedRects) {
ctx.rect(...rect)
}
ctx.fillStyle = clipPattern
ctx.fill()
}
let didDrawUpdateFrames = false
if (updateFrames.length) {
let framesToDelete = []
for (let frame of updateFrames) {
let time = frame[4]
let elapsed = Date.now() - time
if (elapsed > 1000) framesToDelete.push(frame)
else {
didDrawUpdateFrames = true
ctx.globalAlpha = 1 - elapsed / 1000
ctx.strokeStyle = '#ff0'
ctx.lineWidth = 2
ctx.strokeRect(frame[0] * cellWidth, frame[1] * cellHeight, frame[2] * cellWidth, frame[3] * cellHeight)
}
}
for (let frame of framesToDelete) {
updateFrames.splice(updateFrames.indexOf(frame), 1)
}
}
if (mouseHoverCell) {
ctx.save()
ctx.globalAlpha = 1
ctx.lineWidth = 1 + 0.5 * Math.sin(t * 10)
ctx.strokeStyle = '#fff'
ctx.lineJoin = 'round'
ctx.setLineDash([2, 2])
ctx.lineDashOffset = t * 10
ctx.strokeRect(mouseHoverCell[0] * cellWidth, mouseHoverCell[1] * cellHeight, cellWidth, cellHeight)
ctx.lineDashOffset += 2
ctx.strokeStyle = '#000'
ctx.strokeRect(mouseHoverCell[0] * cellWidth, mouseHoverCell[1] * cellHeight, cellWidth, cellHeight)
ctx.restore()
}
if (activeCells === 0 && !mouseHoverCell && !didDrawUpdateFrames) {
isDrawing = false
removeCanvas()
}
}
startDrawing = function () {
if (isDrawing) return
addCanvas()
updateCanvasSize()
isDrawing = true
lastDrawTime = Date.now()
drawLoop()
}
// debug toolbar
const toolbar = mk('div')
toolbar.classList.add('debug-toolbar')
let toolbarAttached = false
const dataDisplay = mk('div')
dataDisplay.classList.add('data-display')
toolbar.appendChild(dataDisplay)
const internalDisplay = mk('div')
internalDisplay.classList.add('internal-display')
toolbar.appendChild(internalDisplay)
toolbar.appendChild(drawInfo)
const buttons = mk('div')
buttons.classList.add('toolbar-buttons')
toolbar.appendChild(buttons)
{
const redraw = mk('button')
redraw.textContent = 'Redraw'
redraw.addEventListener('click', e => {
screen.renderer.resetDrawn()
screen.renderer.draw('debug-redraw')
})
buttons.appendChild(redraw)
const fancyGraphics = mk('button')
fancyGraphics.textContent = 'Toggle Graphics'
fancyGraphics.addEventListener('click', e => {
screen.window.graphics = +!screen.window.graphics
})
buttons.appendChild(fancyGraphics)
}
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()
removeCanvas()
}
}
})
const displayCellAttrs = attrs => {
let result = attrs.toString(16)
if (attrs & 1 || attrs & 2) {
result += ':has('
if (attrs & 1) result += 'fg'
if (attrs & 2) result += (attrs & 1 ? ',' : '') + 'bg'
result += ')'
}
let attributes = []
if (attrs & (1 << 2)) attributes.push('\\[bold]bold\\()')
if (attrs & (1 << 3)) attributes.push('\\[underline]underln\\()')
if (attrs & (1 << 4)) attributes.push('\\[invert]invert\\()')
if (attrs & (1 << 5)) attributes.push('blink')
if (attrs & (1 << 6)) attributes.push('\\[italic]italic\\()')
if (attrs & (1 << 7)) attributes.push('\\[strike]strike\\()')
if (attrs & (1 << 8)) attributes.push('\\[overline]overln\\()')
if (attrs & (1 << 9)) attributes.push('\\[faint]faint\\()')
if (attrs & (1 << 10)) attributes.push('fraktur')
if (attributes.length) result += ':' + attributes.join()
return result.trim()
}
const formatColor = color => color < 256 ? color : `#${`000000${(color - 256).toString(16)}`.substr(-6)}`
const getCellData = cell => {
if (cell < 0 || cell > screen.screen.length) return '(-)'
let cellAttrs = screen.renderer.drawnScreenAttrs[cell] | 0
let cellFG = screen.renderer.drawnScreenFG[cell] | 0
let cellBG = screen.renderer.drawnScreenBG[cell] | 0
let fgText = formatColor(cellFG)
let bgText = formatColor(cellBG)
fgText += `\\[color=${screen.renderer.getColor(cellFG).replace(/ /g, '')}]●\\[]`
bgText += `\\[color=${screen.renderer.getColor(cellBG).replace(/ /g, '')}]●\\[]`
let cellCode = (screen.renderer.drawnScreen[cell] || '').codePointAt(0) | 0
let hexcode = cellCode.toString(16).toUpperCase()
if (hexcode.length < 4) hexcode = `0000${hexcode}`.substr(-4)
hexcode = `U+${hexcode}`
let x = cell % screen.window.width
let y = Math.floor(cell / screen.window.width)
return `((${y},${x})=${cell}:\\[bold]${hexcode}\\[]:F${fgText}:B${bgText}:A(${displayCellAttrs(cellAttrs)}))`
}
const setFormattedText = (node, text) => {
node.innerHTML = ''
let match
let attrs = {}
let pushSpan = content => {
let span = mk('span')
node.appendChild(span)
span.textContent = content
for (let key in attrs) span[key] = attrs[key]
}
while ((match = text.match(/\\\[(.*?)\]/))) {
if (match.index > 0) pushSpan(text.substr(0, match.index))
attrs = { style: '' }
let data = match[1].split(' ')
for (let attr of data) {
if (!attr) continue
let key, value
if (attr.indexOf('=') > -1) {
key = attr.substr(0, attr.indexOf('='))
value = attr.substr(attr.indexOf('=') + 1)
} else {
key = attr
value = true
}
if (key === 'color') console.log(value)
if (key === 'bold') attrs.style += 'font-weight:bold;'
if (key === 'italic') attrs.style += 'font-style:italic;'
if (key === 'underline') attrs.style += 'text-decoration:underline;'
if (key === 'invert') attrs.style += 'background:#000;filter:invert(1);'
if (key === 'strike') attrs.style += 'text-decoration:line-through;'
if (key === 'overline') attrs.style += 'text-decoration:overline;'
if (key === 'faint') attrs.style += 'opacity:0.5;'
else if (key === 'color') attrs.style += `color:${value};`
else attrs[key] = value
}
text = text.substr(match.index + match[0].length)
}
if (text) pushSpan(text)
}
let internalInfo = {}
updateToolbar = () => {
if (!toolbarAttached) return
let text = `C((${screen.cursor.y},${screen.cursor.x}),hang:${screen.cursor.hanging},vis:${screen.cursor.visible})`
if (mouseHoverCell) {
text += ' m' + getCellData(mouseHoverCell[1] * screen.window.width + mouseHoverCell[0])
}
setFormattedText(dataDisplay, text)
if ('flags' in internalInfo) {
// we got ourselves some internal data
let text = ' '
text += ` flags:${internalInfo.flags.toString(2)}`
text += ` curAttrs:${internalInfo.cursorAttrs.toString(2)}`
text += ` Region:${internalInfo.regionStart}->${internalInfo.regionEnd}`
text += ` Charset:${internalInfo.charsetGx} (0:${internalInfo.charsetG0},1:${internalInfo.charsetG1})`
text += ` Heap:${internalInfo.freeHeap}`
text += ` Clients:${internalInfo.clientCount}`
setFormattedText(internalDisplay, text)
}
}
screen.on('draw', updateToolbar)
screen.on('internal', data => {
internalInfo = data
updateToolbar()
})
}

@ -129,7 +129,7 @@ class ScrollingTerminal {
this.height = 25 this.height = 25
this.termScreen = screen this.termScreen = screen
this.parser = new ANSIParser((...args) => this.handleParsed(...args)) this.parser = new ANSIParser((...args) => this.handleParsed(...args))
this.buttonLabels = [] this.buttonLabels = ['', '', '^C', '', 'Help']
this.reset() this.reset()
@ -143,8 +143,6 @@ class ScrollingTerminal {
this.cursor = { x: 0, y: 0, style: 1, visible: true } this.cursor = { x: 0, y: 0, style: 1, visible: true }
this.trackMouse = false this.trackMouse = false
this.theme = 0 this.theme = 0
this.defaultFG = 7
this.defaultBG = 0
this.rainbow = this.superRainbow = false this.rainbow = this.superRainbow = false
this.parser.reset() this.parser.reset()
this.clear() this.clear()
@ -276,8 +274,10 @@ class ScrollingTerminal {
data += encodeAsCodePoint(25) data += encodeAsCodePoint(25)
data += encodeAsCodePoint(80) data += encodeAsCodePoint(80)
data += encodeAsCodePoint(this.theme) data += encodeAsCodePoint(this.theme)
data += this.encodeColor(this.defaultFG) data += encodeAsCodePoint(7)
data += this.encodeColor(this.defaultBG) data += encodeAsCodePoint(0)
data += encodeAsCodePoint(0)
data += encodeAsCodePoint(0)
let attributes = +this.cursor.visible let attributes = +this.cursor.visible
attributes |= (3 << 5) * +this.trackMouse // track mouse controls both attributes |= (3 << 5) * +this.trackMouse // track mouse controls both
attributes |= 3 << 7 // buttons/links always visible attributes |= 3 << 7 // buttons/links always visible
@ -287,8 +287,8 @@ class ScrollingTerminal {
} }
getButtons () { getButtons () {
let data = 'B' let data = 'B'
data += encodeAsCodePoint(this.buttonLabels.length) data += encodeAsCodePoint(5)
data += this.buttonLabels.map(x => `\x01${x}\x01`).join('') data += this.buttonLabels.map(x => x + '\x01').join('')
return data return data
} }
getTitle () { getTitle () {
@ -411,7 +411,22 @@ class Process extends EventEmitter {
} }
let demoData = { let demoData = {
buttons: [], buttons: {
1: '',
2: '',
3: (terminal, shell) => shell.write('\x03'),
4: '',
5: function (terminal, shell) {
if (shell.child) shell.child.destroy()
let chars = 'info\r'
let loop = function () {
shell.write(chars[0])
chars = chars.substr(1)
if (chars) setTimeout(loop, 100)
}
setTimeout(loop, 200)
}
},
mouseReceiver: null mouseReceiver: null
} }
@ -461,22 +476,14 @@ let demoshIndex = {
'local-echo': class LocalEcho extends Process { 'local-echo': class LocalEcho extends Process {
run (...args) { run (...args) {
if (!args.includes('--suppress-note')) { if (!args.includes('--suppress-note')) {
this.emit('write', '\x1b[38;5;239mNote: not all terminal features are supported and may not work as expected in this demo\x1b[m\n') this.emit('write', '\x1b[38;5;239mNote: not all terminal features are supported or and may not work as expected in this demo\x1b[0m\r\n')
} }
this.emit('buttons', [
{
label: 'Exit',
action (shell) {
shell.write('\x03')
}
}
])
} }
write (data) { write (data) {
this.emit('write', data) this.emit('write', data)
} }
}, },
info: class Info extends Process { 'info': class Info extends Process {
run (...args) { run (...args) {
let fast = args.includes('--fast') let fast = args.includes('--fast')
this.showSplash().then(() => { this.showSplash().then(() => {
@ -647,203 +654,37 @@ let demoshIndex = {
} }
}, },
themes: class ShowThemes extends Process { themes: class ShowThemes extends Process {
constructor (shell) { color (hex) {
super() hex = parseInt(hex.substr(1), 16)
this.shell = shell let r = hex >> 16
this.parser = new ANSIParser((...args) => this.handler(...args)) let g = (hex >> 8) & 0xFF
} let b = hex & 0xFF
this.emit('write', `\x1b[48;2;${r};${g};${b}m`)
destroy () { if (((r + g + b) / 3) > 127) {
this.shell.terminal.cursor.style = this.savedCursorStyle this.emit('write', '\x1b[38;5;16m')
this.emit('write', '\n\n') } else {
super.destroy() this.emit('write', '\x1b[38;5;255m')
}
run (...args) {
this.savedCursorStyle = this.shell.terminal.cursor.style
this.shell.terminal.cursor.style = 3
this.fgType = 0
this.bgType = 0
let get24FG = () => this.shell.terminal.defaultFG - 256
let set24FG = v => { this.shell.terminal.defaultFG = v + 256 }
let get24BG = () => this.shell.terminal.defaultBG - 256
let set24BG = v => { this.shell.terminal.defaultBG = v + 256 }
let make24Control = (label, index, getValue, setValue, type) => {
index *= 4
return {
label,
length: 1,
getValue: () => (getValue() >> index) & 0xF,
getDisplay: () => ((getValue() >> index) & 0xF).toString(16),
setValue: value => {
setValue((getValue() & (0xFFFFFF ^ (0xF << index))) | ((value & 0xF) << index))
},
shouldShow: () => this[type + 'Type'] === 1,
parseValue: input => {
return parseInt(input, 16) & 0xF
},
moveAfterInput: index !== 0
}
} }
this.controls = [
{
label: 'Theme: ',
length: themes.length.toString().length,
getValue: () => this.shell.terminal.theme,
setValue: value => {
let count = themes.length
this.shell.terminal.theme = ((value % count) + count) % count
}
},
{
label: ' Default Foreground: ',
length: 6,
getValue: () => this.fgType,
getDisplay: () => this.fgType === 0 ? '256' : '24-bit',
setValue: value => {
this.fgType = ((value % 2) + 2) % 2
}
},
{
label: ' ',
length: 3,
getValue: () => this.shell.terminal.defaultFG & 0xFF,
setValue: value => {
this.shell.terminal.defaultFG = value & 0xFF
},
shouldShow: () => this.fgType === 0,
parseValue: input => parseInt(input, 16)
},
make24Control(' #', 5, get24FG, set24FG, 'fg'),
make24Control('', 4, get24FG, set24FG, 'fg'),
make24Control('', 3, get24FG, set24FG, 'fg'),
make24Control('', 2, get24FG, set24FG, 'fg'),
make24Control('', 1, get24FG, set24FG, 'fg'),
make24Control('', 0, get24FG, set24FG, 'fg'),
{
label: ' Default Background: ',
length: 6,
getValue: () => this.bgType,
getDisplay: () => this.bgType === 0 ? '256' : '24-bit',
setValue: value => {
this.bgType = ((value % 2) + 2) % 2
}
},
{
label: ' ',
length: 3,
getValue: () => this.shell.terminal.defaultBG & 0xFF,
setValue: value => {
this.shell.terminal.defaultBG = value & 0xFF
},
shouldShow: () => this.bgType === 0,
parseValue: input => parseInt(input, 16)
},
make24Control(' #', 5, get24BG, set24BG, 'bg'),
make24Control('', 4, get24BG, set24BG, 'bg'),
make24Control('', 3, get24BG, set24BG, 'bg'),
make24Control('', 2, get24BG, set24BG, 'bg'),
make24Control('', 1, get24BG, set24BG, 'bg'),
make24Control('', 0, get24BG, set24BG, 'bg')
]
this.selection = 0
this.emit('write', '\x1b[1mThemes\x1b[m\n\n\n\n\x1b[2A')
this.render()
} }
run (...args) {
for (let i in themes) {
let theme = themes[i]
render () { let name = ` ${i}`.substr(-2)
this.emit('write', '\x1b[m\r')
// no ^[2K implementation, here's a hack
this.emit('write', ' '.repeat(this.shell.terminal.width - 1) + '\r')
let index = 0
let selectedX = 0
for (let control of this.controls) {
if (control.shouldShow && !control.shouldShow()) continue
if (control.label) {
this.emit('write', `\x1b[1m${control.label}\x1b[m`)
}
// TODO: colors
this.emit('write', '\x1b[38;5;255m\x1b[48;5;16m')
let value = control.getDisplay ? control.getDisplay() : control.getValue().toString()
this.emit('write', (control.fill || ' ').repeat(Math.max(0, control.length - value.length)))
this.emit('write', value.substr(0, control.length))
this.emit('write', '\x1b[m')
if (index === this.selection) { this.emit('write', `Theme ${name}: `)
selectedX = this.shell.terminal.cursor.x - 1
// draw arrows for (let col = 0; col < 16; col++) {
this.emit('write', '\x1b[m\x1b[D\x1b[A▲\x1b[D\x1b[2B▼\x1b[A') let text = ` ${col}`.substr(-2)
} else { this.color(theme[col])
// clear arrows if they were there this.emit('write', text)
this.emit('write', '\x1b[m\x1b[D\x1b[A \x1b[D\x1b[2B \x1b[A') this.emit('write', '\x1b[m ')
} }
index++ this.emit('write', '\n')
} }
this.shell.terminal.cursor.x = selectedX this.destroy()
}
write (data) {
this.parser.write(data)
}
getControlCount () {
let count = 0
for (let control of this.controls) {
if (control.shouldShow && !control.shouldShow()) continue
count++
}
return count
}
getSelectedControl () {
let selected = null
let index = 0
for (let control of this.controls) {
if (control.shouldShow && !control.shouldShow()) continue
if (index === this.selection) {
selected = control
break
}
index++
}
return selected
}
handler (action, ...args) {
console.log(action, ...args)
if (action === 'move-cursor-x') {
this.selection += args[0]
let count = this.getControlCount()
this.selection = ((this.selection % count) + count) % count
} else if (action === 'move-cursor-y') {
let control = this.getSelectedControl()
if (control) control.setValue(control.getValue() - args[0])
} else if (action === 'write') {
let control = this.getSelectedControl()
if (control && control.parseValue) {
let parsed = control.parseValue(args[0])
if (Number.isFinite(parsed)) {
control.setValue(parsed)
if (control.moveAfterInput) {
if (this.selection < this.getControlCount() - 1) this.selection++
}
}
}
}
this.render()
} }
}, },
cursor: class SetCursor extends Process { cursor: class SetCursor extends Process {
@ -897,56 +738,44 @@ let demoshIndex = {
super.destroy() super.destroy()
} }
}, },
mouse: class MouseDemo extends Process { mouse: class ShowMouse extends Process {
constructor (shell) { constructor (shell) {
super() super()
this.shell = shell this.shell = shell
} }
run () { run () {
const self = this
this.emit('buttons', [
{
label: 'Exit',
action (shell) {
shell.write('\x03')
}
},
{
label: 'Add Box',
action () {
self.boxes.push(self.generateRandomBox())
self.render()
}
}
])
this.shell.terminal.trackMouse = true this.shell.terminal.trackMouse = true
demoData.mouseReceiver = this demoData.mouseReceiver = this
this.randomData = []
this.boxes = Array(3).fill(0).map(x => this.generateRandomBox()) this.highlighted = {}
this.grabbedBox = null let characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
this.grabOffset = [0, 0] for (let i = 0; i < 23; i++) {
let line = ''
for (let j = 0; j < 79; j++) {
line += characters[Math.floor(characters.length * Math.random())]
}
this.randomData.push(line)
}
this.scrollOffset = 0
this.render() this.render()
} }
render () { render () {
this.emit('write', '\x1b[m\x1b[2J\x1b[1;1H') this.emit('write', '\x1b[m\x1b[2J\x1b[1;1H')
this.emit('write', '\x1b[97m\x1b[1mMouse Demo\r\n\x1b[mMove boxes around or scroll their contents!') this.emit('write', '\x1b[97m\x1b[1mMouse Demo\r\n\x1b[mMouse movement, clicking, and scrolling!')
// draw boxes // render random data for scrolling
for (let box of this.boxes) { for (let y = 0; y < 23; y++) {
this.emit('write', `\x1b[${box.y + 1};${box.x + 1}H`) let index = y + this.scrollOffset
this.emit('write', `\x1b[m\x1b[48;2;${box.color.join(';')}m`) // proper modulo:
for (let y = box.y; y < box.y + box.height; y++) { index = ((index % this.randomData.length) + this.randomData.length) % this.randomData.length
let drawnX = 0 let line = this.randomData[index]
for (let x = box.x; x < box.x + box.width; x++) { let lineData = `\x1b[${3 + y};1H\x1b[38;5;239m`
if (x < 0 || x >= this.shell.terminal.width - 1) continue for (let x in line) {
if (y < 0 || y >= this.shell.terminal.height) continue if (this.highlighted[(y + 2) * 80 + (+x)]) lineData += '\x1b[97m'
drawnX++ lineData += line[x]
this.emit('write', box.content[y - box.y][x - box.x]) if (this.highlighted[(y + 2) * 80 + (+x)]) lineData += '\x1b[38;5;239m'
}
this.emit('write', '\x1b[D'.repeat(drawnX) + '\x1b[B')
} }
this.emit('write', lineData)
} }
// move cursor to mouse // move cursor to mouse
@ -954,73 +783,19 @@ let demoshIndex = {
this.emit('write', `\x1b[${this.mouse.y + 1};${this.mouse.x + 1}H`) this.emit('write', `\x1b[${this.mouse.y + 1};${this.mouse.x + 1}H`)
} }
} }
generateRandomBox () {
let chars = 'abcdefghijklmnopqrstuvwxyz-*()!@#$%'
let content = []
let width = [5, 10, 15][Math.floor(Math.random() * 3)]
let height = [4, 5, 6][Math.floor(Math.random() * 3)]
for (let y = 0; y < height; y++) {
content.push('')
for (let x = 0; x < width; x++) {
if (Math.random() > 0.6) {
content[y] += chars[Math.floor(Math.random() * chars.length)]
} else content[y] += ' '
}
}
return {
x: Math.floor(Math.random() * (this.shell.terminal.width - 1)) + 1,
y: Math.floor(Math.random() * (this.shell.terminal.height - 1)) + 1,
width,
height,
color: [Math.random(), Math.random(), Math.random()].map(x => Math.floor(x * 255)),
content
}
}
getBoxAt (x, y) {
let boxes = this.boxes.slice().reverse() // top to bottom, like drawing order
for (let box of boxes) {
if (box.x <= x && box.x + box.width > x && box.y <= y && box.y + box.height > y) {
return box
}
}
}
mouseMove (x, y) { mouseMove (x, y) {
this.mouse = { x, y } this.mouse = { x, y }
if (this.grabbedBox) {
this.grabbedBox.x = x + this.grabOffset[0]
this.grabbedBox.y = y + this.grabOffset[1]
}
this.render() this.render()
} }
mouseDown (x, y, button) { mouseDown (x, y, button) {
if (button === 4) this.scrollInsideBox(this.getBoxAt(x, y), -1) if (button === 4) this.scrollOffset--
else if (button === 5) this.scrollInsideBox(this.getBoxAt(x, y), 1) else if (button === 5) this.scrollOffset++
else { else this.highlighted[y * 80 + x] = !this.highlighted[y * 80 + x]
let box = this.getBoxAt(x, y)
if (box) {
this.grabbedBox = box
this.grabOffset = [box.x - x, box.y - y]
// move grabbed box to top of stack
this.boxes.push(...this.boxes.splice(this.boxes.indexOf(box), 1))
}
}
this.render() this.render()
} }
mouseUp (x, y, button) { mouseUp (x, y, button) {}
this.grabbedBox = null
}
scrollInsideBox (box, amount) {
if (!box) return
let content = box.content.slice()
box.content = []
for (let i = 0; i < content.length; i++) {
box.content.push(content[((i + amount % content.length) + content.length) % content.length])
}
}
destroy () { destroy () {
this.shell.terminal.write('\x1b[m\x1b[2J\x1b[1;1H') this.shell.terminal.write('\x1b[2J\x1b[1;1H')
this.shell.terminal.trackMouse = false this.shell.terminal.trackMouse = false
if (demoData.mouseReceiver === this) demoData.mouseReceiver = null if (demoData.mouseReceiver === this) demoData.mouseReceiver = null
super.destroy() super.destroy()
@ -1044,12 +819,9 @@ let demoshIndex = {
let child = this.child = new Process(this.shell, { su: true }) let child = this.child = new Process(this.shell, { su: true })
this.on('in', data => child.write(data)) this.on('in', data => child.write(data))
let write = data => this.emit('write', data) let write = data => this.emit('write', data)
let setButtons = buttons => this.emit('buttons', buttons)
child.on('write', write) child.on('write', write)
child.on('buttons', setButtons)
child.on('exit', code => { child.on('exit', code => {
child.removeListener('write', write) child.removeListener('write', write)
child.removeListener('buttons', setButtons)
this.destroy() this.destroy()
}) })
child.run(...args) child.run(...args)
@ -1166,8 +938,6 @@ class DemoShell {
this.terminal.write('$ \x1b[m') this.terminal.write('$ \x1b[m')
this.history.unshift('') this.history.unshift('')
this.cursorPos = 0 this.cursorPos = 0
this.setButtons()
} }
copyFromHistoryIndex () { copyFromHistoryIndex () {
if (!this.historyIndex) return if (!this.historyIndex) return
@ -1184,7 +954,6 @@ class DemoShell {
prefix = input.substr(0, input.length - newInput.length) prefix = input.substr(0, input.length - newInput.length)
input = newInput input = newInput
} }
if (!input) return null
for (let name in this.index) { for (let name in this.index) {
if (name.startsWith(input) && name !== input) { if (name.startsWith(input) && name !== input) {
if (visual && name in autocompleteIndex) return prefix + autocompleteIndex[name] if (visual && name in autocompleteIndex) return prefix + autocompleteIndex[name]
@ -1240,7 +1009,7 @@ class DemoShell {
this.lastInputLength = this.cursorPos this.lastInputLength = this.cursorPos
let completed = this.getCompleted(true) let completed = this.getCompleted(true)
if (this.historyIndex === 0 && completed && action !== 'return') { if (this.historyIndex === 0 && completed) {
// show closest match faintly // show closest match faintly
let rest = completed.substr(this.history[0].length) let rest = completed.substr(this.history[0].length)
this.terminal.write(`\x1b[2m${rest}\x1b[m${'\b'.repeat(rest.length)}`) this.terminal.write(`\x1b[2m${rest}\x1b[m${'\b'.repeat(rest.length)}`)
@ -1286,17 +1055,11 @@ class DemoShell {
spawn (name, args = []) { spawn (name, args = []) {
let Process = this.index[name] let Process = this.index[name]
if (Process instanceof Function) { if (Process instanceof Function) {
this.setButtons([])
this.child = new Process(this) this.child = new Process(this)
let write = data => this.terminal.write(data) let write = data => this.terminal.write(data)
let setButtons = buttons => this.setButtons(buttons)
this.child.on('write', write) this.child.on('write', write)
this.child.on('buttons', setButtons)
this.child.on('exit', code => { this.child.on('exit', code => {
if (this.child) { if (this.child) this.child.removeListener('write', write)
this.child.removeListener('write', write)
this.child.removeListener('buttons', setButtons)
}
this.child = null this.child = null
this.prompt(!code) this.prompt(!code)
}) })
@ -1306,46 +1069,6 @@ class DemoShell {
this.prompt() this.prompt()
} }
} }
setButtons (buttons) {
if (!buttons) {
const shell = this
let writeChars = chars => {
let loop = () => {
shell.write(chars[0])
chars = chars.substr(1)
if (chars) setTimeout(loop, 100)
}
setTimeout(loop, 200)
}
buttons = [
{
label: 'Open GitHub',
action (shell) {
if (shell.child) shell.child.destroy()
writeChars('github\r')
}
},
{
label: 'Help',
action (shell) {
if (shell.child) shell.child.destroy()
writeChars('info\r')
}
}
]
}
if (!buttons.length) buttons.push({ label: '', action () {} })
this.buttons = buttons
this.terminal.buttonLabels = buttons.map(x => x.label)
}
onButtonPress (index) {
let button = this.buttons[index]
if (!button) return
button.action(this, this.terminal)
}
} }
window.demoInterface = module.exports = { window.demoInterface = module.exports = {
@ -1357,7 +1080,11 @@ window.demoInterface = module.exports = {
this.shell.write(content) this.shell.write(content)
} else if (type === 'b') { } else if (type === 'b') {
let button = content.charCodeAt(0) let button = content.charCodeAt(0)
this.shell.onButtonPress(button - 1) let action = demoData.buttons[button]
if (action) {
if (typeof action === 'string') this.shell.write(action)
else if (action instanceof Function) action(this.terminal, this.shell)
}
} else if (type === 'm' || type === 'p' || type === 'r') { } else if (type === 'm' || type === 'p' || type === 'r') {
let row = parse2B(content, 0) let row = parse2B(content, 0)
let column = parse2B(content, 2) let column = parse2B(content, 2)

@ -1,4 +1,3 @@
const $ = require('../lib/chibi')
const { qs, mk } = require('../utils') const { qs, mk } = require('../utils')
const localize = require('../lang') const localize = require('../lang')
const Notify = require('../notif') const Notify = require('../notif')
@ -7,7 +6,7 @@ const TermConnection = require('./connection')
const TermInput = require('./input') const TermInput = require('./input')
const TermUpload = require('./upload') const TermUpload = require('./upload')
const initSoftKeyboard = require('./soft_keyboard') const initSoftKeyboard = require('./soft_keyboard')
const attachDebugger = require('./debug') const attachDebugScreen = require('./debug_screen')
const initButtons = require('./buttons') const initButtons = require('./buttons')
/** Init the terminal sub-module - called from HTML */ /** Init the terminal sub-module - called from HTML */
@ -16,79 +15,17 @@ module.exports = function (opts) {
const conn = new TermConnection(screen) const conn = new TermConnection(screen)
const input = TermInput(conn, screen) const input = TermInput(conn, screen)
const termUpload = TermUpload(conn, input, screen) const termUpload = TermUpload(conn, input, screen)
screen.input = input
screen.conn = conn
input.termUpload = termUpload input.termUpload = termUpload
// forward screen input events
screen.on('mousedown', (...args) => input.onMouseDown(...args))
screen.on('mousemove', (...args) => input.onMouseMove(...args))
screen.on('mouseup', (...args) => input.onMouseUp(...args))
screen.on('mousewheel', (...args) => input.onMouseWheel(...args))
screen.on('input-alts', (...args) => input.setAlts(...args))
screen.on('mouse-mode', (...args) => input.setMouseMode(...args))
// touch selection menu (the Copy button)
$.ready(() => {
const touchSelectMenu = qs('#touch-select-menu')
screen.on('show-touch-select-menu', (x, y) => {
let rect = touchSelectMenu.getBoundingClientRect()
x -= rect.width / 2
y -= rect.height / 2
touchSelectMenu.classList.add('open')
touchSelectMenu.style.transform = `translate(${x}px,${y}px)`
})
screen.on('hide-touch-select-menu', () => touchSelectMenu.classList.remove('open'))
const copyButton = qs('#touch-select-copy-btn')
if (copyButton) {
copyButton.addEventListener('click', () => {
screen.copySelectionToClipboard()
})
}
})
// buttons
const buttons = initButtons(input) const buttons = initButtons(input)
screen.on('buttons-update', update => { screen.on('button-labels', labels => {
buttons.labels = update.labels // TODO: don't use pointers for this
buttons.colors = update.colors buttons.labels.splice(0, buttons.labels.length, ...labels)
}) buttons.update()
// TODO: don't access the renderer here
buttons.palette = screen.layout.renderer.palette
screen.layout.renderer.on('palette-update', palette => {
buttons.palette = palette
}) })
screen.on('full-load', () => {
let scr = qs('#screen')
let errmsg = qs('#load-failed')
if (scr) scr.classList.remove('failed')
if (errmsg) errmsg.parentNode.removeChild(errmsg)
})
let setLinkVisibility = visible => {
let buttons = [...document.querySelectorAll('.x-term-conf-btn')]
if (visible) buttons.forEach(x => x.classList.remove('hidden'))
else buttons.forEach(x => x.classList.add('hidden'))
}
let setButtonVisibility = visible => {
if (visible) qs('#action-buttons').classList.remove('hidden')
else qs('#action-buttons').classList.add('hidden')
}
screen.on('opts-update', () => {
setLinkVisibility(screen.showLinks)
setButtonVisibility(screen.showButtons)
})
screen.on('title-update', text => {
qs('#screen-title').textContent = text
if (!text) text = 'Terminal'
qs('title').textContent = `${text} :: ESPTerm`
})
// connection status
let showSplashTimeout = null let showSplashTimeout = null
let showSplash = (obj, delay = 250) => { let showSplash = (obj, delay = 250) => {
clearTimeout(showSplashTimeout) clearTimeout(showSplashTimeout)
@ -105,7 +42,7 @@ module.exports = function (opts) {
// console.log('*connect') // console.log('*connect')
showSplash({ title: localize('term_conn.waiting_content'), loading: true }) showSplash({ title: localize('term_conn.waiting_content'), loading: true })
}) })
screen.on('load', () => { conn.on('load', () => {
// console.log('*load') // console.log('*load')
clearTimeout(showSplashTimeout) clearTimeout(showSplashTimeout)
if (screen.window.statusScreen) screen.window.statusScreen = null if (screen.window.statusScreen) screen.window.statusScreen = null
@ -138,39 +75,37 @@ module.exports = function (opts) {
return false return false
} }
qs('#screen').appendChild(screen.layout.canvas) qs('#screen').appendChild(screen.canvas)
initSoftKeyboard(screen, input) initSoftKeyboard(screen, input)
if (attachDebugger) attachDebugger(screen, conn) if (attachDebugScreen) attachDebugScreen(screen)
// fullscreen mode
let fullscreenIcon = {} // dummy let fullscreenIcon = {} // dummy
let isFullscreen = false let isFullscreen = false
let properFullscreen = false let properFullscreen = false
let fitScreen = false let fitScreen = false
let screenPadding = screen.layout.window.padding let screenPadding = screen.window.padding
let fitScreenIfNeeded = function fitScreenIfNeeded () { let fitScreenIfNeeded = function fitScreenIfNeeded () {
if (isFullscreen) { if (isFullscreen) {
fullscreenIcon.className = 'icn-resize-small' fullscreenIcon.className = 'icn-resize-small'
if (properFullscreen) { if (properFullscreen) {
screen.layout.window.fitIntoWidth = window.screen.width screen.window.fitIntoWidth = window.screen.width
screen.layout.window.fitIntoHeight = window.screen.height screen.window.fitIntoHeight = window.screen.height
screen.layout.window.padding = 0 screen.window.padding = 0
} else { } else {
screen.layout.window.fitIntoWidth = window.innerWidth screen.window.fitIntoWidth = window.innerWidth
if (qs('#term-nav').classList.contains('hidden')) { if (qs('#term-nav').classList.contains('hidden')) {
screen.layout.window.fitIntoHeight = window.innerHeight screen.window.fitIntoHeight = window.innerHeight
} else { } else {
screen.layout.window.fitIntoHeight = window.innerHeight - 24 screen.window.fitIntoHeight = window.innerHeight - 24
} }
screen.layout.window.padding = 0 screen.window.padding = 0
} }
} else { } else {
fullscreenIcon.className = 'icn-resize-full' fullscreenIcon.className = 'icn-resize-full'
screen.layout.window.fitIntoWidth = fitScreen ? window.innerWidth - 4 : 0 screen.window.fitIntoWidth = fitScreen ? window.innerWidth - 4 : 0
screen.layout.window.fitIntoHeight = fitScreen ? window.innerHeight : 0 screen.window.fitIntoHeight = fitScreen ? window.innerHeight : 0
screen.layout.window.padding = screenPadding screen.window.padding = screenPadding
} }
} }
fitScreenIfNeeded() fitScreenIfNeeded()
@ -224,11 +159,11 @@ module.exports = function (opts) {
isFullscreen = true isFullscreen = true
fitScreenIfNeeded() fitScreenIfNeeded()
screen.layout.updateSize() screen.updateSize()
if (properFullscreen) { if (properFullscreen) {
if (screen.layout.canvas.requestFullscreen) screen.layout.canvas.requestFullscreen() if (screen.canvas.requestFullscreen) screen.canvas.requestFullscreen()
else screen.layout.canvas.webkitRequestFullscreen() else screen.canvas.webkitRequestFullscreen()
} else { } else {
document.body.classList.add('pseudo-fullscreen') document.body.classList.add('pseudo-fullscreen')
} }
@ -243,7 +178,6 @@ module.exports = function (opts) {
// for debugging // for debugging
window.termScreen = screen window.termScreen = screen
window.buttons = buttons
window.conn = conn window.conn = conn
window.input = input window.input = input
window.termUpl = termUpload window.termUpl = termUpload

@ -1,3 +1,4 @@
const $ = require('../lib/chibi')
const { encode2B } = require('../utils') const { encode2B } = require('../utils')
/** /**

@ -1,19 +1,19 @@
const EventEmitter = require('events') const EventEmitter = require('events')
const { mk } = require('../utils') const $ = require('../lib/chibi')
const { mk, qs } = require('../utils')
const notify = require('../notif') const notify = require('../notif')
const ScreenParser = require('./screen_parser') const ScreenParser = require('./screen_parser')
const ScreenLayout = require('./screen_layout') const ScreenRenderer = require('./screen_renderer')
const { ATTR_BLINK } = require('./screen_attr_bits')
/**
* A terminal screen.
*/
module.exports = class TermScreen extends EventEmitter { module.exports = class TermScreen extends EventEmitter {
constructor () { constructor () {
super() super()
this.parser = new ScreenParser() this.canvas = mk('canvas')
this.layout = new ScreenLayout() this.ctx = this.canvas.getContext('2d')
this.parser = new ScreenParser(this)
this.renderer = new ScreenRenderer(this)
// debug screen handle // debug screen handle
this._debug = null this._debug = null
@ -24,28 +24,21 @@ module.exports = class TermScreen extends EventEmitter {
console.warn('No AudioContext!') console.warn('No AudioContext!')
} }
this._window = { // dummy. Handle for Input
width: 0, this.input = new Proxy({}, {
height: 0, get () {
// two bits. LSB: debug enabled by user, MSB: debug enabled by server return () => console.warn('TermScreen#input not set!')
debug: 0, }
statusScreen: null })
} // dummy. Handle for Conn
this.conn = new Proxy({}, {
// make writing to window update size and draw get () {
this.window = new Proxy(this._window, { return () => console.warn('TermScreen#conn not set!')
set (target, key, value) { },
if (target[key] !== value) { set (a, b) {
target[key] = value return () => console.warn('TermScreen#conn not set!')
self.updateLayout()
self.renderScreen(`window:${key}=${value}`)
self.emit(`update-window:${key}`, value)
}
return true
} }
}) })
this.on('update-window:debug', debug => { this.layout.window.debug = !!debug })
this.cursor = { this.cursor = {
x: 0, x: 0,
@ -56,31 +49,69 @@ module.exports = class TermScreen extends EventEmitter {
style: 'block' style: 'block'
} }
const self = this this._window = {
width: 0,
height: 0,
devicePixelRatio: 1,
fontFamily: '"DejaVu Sans Mono", "Liberation Mono", "Inconsolata", "Menlo", monospace',
fontSize: 20,
padding: 6,
gridScaleX: 1.0,
gridScaleY: 1.2,
fitIntoWidth: 0,
fitIntoHeight: 0,
debug: false,
graphics: 0,
statusScreen: null
}
// scaling caused by fitIntoWidth/fitIntoHeight
this._windowScale = 1
// actual padding, as it may be disabled by fullscreen mode etc.
this._padding = 0
// properties of this.window that require updating size and redrawing
this.windowState = {
width: 0,
height: 0,
devicePixelRatio: 0,
padding: 0,
gridScaleX: 0,
gridScaleY: 0,
fontFamily: '',
fontSize: 0,
fitIntoWidth: 0,
fitIntoHeight: 0
}
// current selection // current selection
this.selection = { this.selection = {
// when false, this will prevent selection in favor of mouse events, // when false, this will prevent selection in favor of mouse events,
// though alt can be held to override it // though alt can be held to override it
selectable: null, selectable: true,
// selection start and end (x, y) tuples // selection start and end (x, y) tuples
start: [0, 0], start: [0, 0],
end: [0, 0], end: [0, 0]
setSelectable (value) {
if (value !== this.selectable) {
this.selectable = self.layout.selectable = value
}
}
} }
// mouse features // mouse features
this.mouseMode = { clicks: false, movement: false } this.mouseMode = { clicks: false, movement: false }
this.showLinks = false // make writing to window update size and draw
this.showButtons = false const self = this
this.title = '' this.window = new Proxy(this._window, {
set (target, key, value, receiver) {
if (target[key] !== value) {
target[key] = value
self.scheduleSizeUpdate()
self.renderer.scheduleDraw(`window:${key}=${value}`)
self.emit(`update-window:${key}`, value)
}
return true
}
})
this.bracketedPaste = false this.bracketedPaste = false
this.blinkingCellCount = 0 this.blinkingCellCount = 0
@ -90,46 +121,38 @@ module.exports = class TermScreen extends EventEmitter {
this.screenFG = [] this.screenFG = []
this.screenBG = [] this.screenBG = []
this.screenAttrs = [] this.screenAttrs = []
this.screenLines = []
// For testing TODO remove
// this.screenLines[0] = 0b001
// this.screenLines[1] = 0b010
// this.screenLines[2] = 0b100
// this.screenLines[3] = 0b011
// this.screenLines[4] = 0b101
let selecting = false let selecting = false
let selectStart = (x, y) => { let selectStart = (x, y) => {
if (selecting) return if (selecting) return
selecting = true selecting = true
this.selection.start = this.selection.end = this.layout.screenToGrid(x, y, true) this.selection.start = this.selection.end = this.screenToGrid(x, y, true)
this.renderScreen('select-start') this.renderer.scheduleDraw('select-start')
} }
let selectMove = (x, y) => { let selectMove = (x, y) => {
if (!selecting) return if (!selecting) return
this.selection.end = this.layout.screenToGrid(x, y, true) this.selection.end = this.screenToGrid(x, y, true)
this.renderScreen('select-move') this.renderer.scheduleDraw('select-move')
} }
let selectEnd = (x, y) => { let selectEnd = (x, y) => {
if (!selecting) return if (!selecting) return
selecting = false selecting = false
this.selection.end = this.layout.screenToGrid(x, y, true) this.selection.end = this.screenToGrid(x, y, true)
this.renderScreen('select-end') this.renderer.scheduleDraw('select-end')
Object.assign(this.selection, this.getNormalizedSelection()) Object.assign(this.selection, this.getNormalizedSelection())
} }
// bind event listeners // bind event listeners
this.layout.on('mousedown', e => { this.canvas.addEventListener('mousedown', e => {
this.emit('hide-touch-select-menu')
if ((this.selection.selectable || e.altKey) && e.button === 0) { if ((this.selection.selectable || e.altKey) && e.button === 0) {
selectStart(e.offsetX, e.offsetY) selectStart(e.offsetX, e.offsetY)
} else { } else {
this.emit('mousedown', ...this.layout.screenToGrid(e.offsetX, e.offsetY), e.button + 1) this.input.onMouseDown(...this.screenToGrid(e.offsetX, e.offsetY),
e.button + 1)
} }
}) })
@ -149,22 +172,17 @@ module.exports = class TermScreen extends EventEmitter {
let touchDidMove = false let touchDidMove = false
let getTouchPositionOffset = touch => { let getTouchPositionOffset = touch => {
let rect = this.layout.canvas.getBoundingClientRect() let rect = this.canvas.getBoundingClientRect()
return [touch.clientX - rect.left, touch.clientY - rect.top] return [touch.clientX - rect.left, touch.clientY - rect.top]
} }
this.layout.on('touchstart', e => { this.canvas.addEventListener('touchstart', e => {
touchPosition = getTouchPositionOffset(e.touches[0]) touchPosition = getTouchPositionOffset(e.touches[0])
touchDidMove = false touchDidMove = false
touchDownTime = Date.now() touchDownTime = Date.now()
}, { passive: true })
if (this.mouseMode.clicks) { this.canvas.addEventListener('touchmove', e => {
this.emit('mousedown', ...this.layout.screenToGrid(...touchPosition), 1)
e.preventDefault()
}
})
this.layout.on('touchmove', e => {
touchPosition = getTouchPositionOffset(e.touches[0]) touchPosition = getTouchPositionOffset(e.touches[0])
if (!selecting && touchDidMove === false) { if (!selecting && touchDidMove === false) {
@ -174,15 +192,12 @@ module.exports = class TermScreen extends EventEmitter {
} else if (selecting) { } else if (selecting) {
e.preventDefault() e.preventDefault()
selectMove(...touchPosition) selectMove(...touchPosition)
} else if (this.mouseMode.movement && !selecting) {
this.emit('mousemove', ...this.layout.screenToGrid(...touchPosition))
e.preventDefault()
} }
touchDidMove = true touchDidMove = true
}) })
this.layout.on('touchend', e => { this.canvas.addEventListener('touchend', e => {
if (e.touches[0]) { if (e.touches[0]) {
touchPosition = getTouchPositionOffset(e.touches[0]) touchPosition = getTouchPositionOffset(e.touches[0])
} }
@ -192,16 +207,19 @@ module.exports = class TermScreen extends EventEmitter {
selectEnd(...touchPosition) selectEnd(...touchPosition)
// selection ended; show touch select menu // 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 // use middle position for x and one line above for y
let selectionPos = this.layout.gridToScreen( let selectionPos = this.gridToScreen(
(this.selection.start[0] + this.selection.end[0]) / 2, (this.selection.start[0] + this.selection.end[0]) / 2,
this.selection.start[1] - 1 this.selection.start[1] - 1
) )
selectionPos[0] -= rect.width / 2
this.emit('show-touch-select-menu', selectionPos[0], selectionPos[1]) selectionPos[1] -= rect.height / 2
} else if (this.mouseMode.clicks) { touchSelectMenu.style.transform = `translate(${selectionPos[0]}px, ${
this.emit('mouseup', ...this.layout.screenToGrid(...touchPosition), 1) selectionPos[1]}px)`
e.preventDefault()
} }
if (!touchDidMove && !this.mouseMode.clicks) { if (!touchDidMove && !this.mouseMode.clicks) {
@ -209,7 +227,7 @@ module.exports = class TermScreen extends EventEmitter {
x: touchPosition[0], x: touchPosition[0],
y: touchPosition[1] y: touchPosition[1]
})) }))
} else if (!touchDidMove) this.resetSelection() }
touchPosition = null touchPosition = null
}) })
@ -218,37 +236,49 @@ module.exports = class TermScreen extends EventEmitter {
if (this.selection.start[0] !== this.selection.end[0] || if (this.selection.start[0] !== this.selection.end[0] ||
this.selection.start[1] !== this.selection.end[1]) { this.selection.start[1] !== this.selection.end[1]) {
// selection is not empty // selection is not empty
this.resetSelection() // reset selection
this.selection.start = this.selection.end = [0, 0]
qs('#touch-select-menu').classList.remove('open')
this.renderer.scheduleDraw('select-reset')
} else { } else {
e.preventDefault() e.preventDefault()
this.emit('open-soft-keyboard') this.emit('open-soft-keyboard')
} }
}) })
this.layout.on('mousemove', e => { $.ready(() => {
let copyButton = qs('#touch-select-copy-btn')
if (copyButton) {
copyButton.addEventListener('click', () => {
this.copySelectionToClipboard()
})
}
})
this.canvas.addEventListener('mousemove', e => {
if (!selecting) { if (!selecting) {
this.emit('mousemove', ...this.layout.screenToGrid(e.offsetX, e.offsetY)) this.input.onMouseMove(...this.screenToGrid(e.offsetX, e.offsetY))
} }
}) })
this.layout.on('mouseup', e => { this.canvas.addEventListener('mouseup', e => {
if (!selecting) { if (!selecting) {
this.emit('mouseup', ...this.layout.screenToGrid(e.offsetX, e.offsetY), this.input.onMouseUp(...this.screenToGrid(e.offsetX, e.offsetY),
e.button + 1) e.button + 1)
} }
}) })
let aggregateWheelDelta = 0 let aggregateWheelDelta = 0
this.layout.on('wheel', e => { this.canvas.addEventListener('wheel', e => {
if (this.mouseMode.clicks) { if (this.mouseMode.clicks) {
if (Math.abs(e.wheelDeltaY) === 120) { if (Math.abs(e.wheelDeltaY) === 120) {
// mouse wheel scrolling // mouse wheel scrolling
this.emit('mousewheel', ...this.layout.screenToGrid(e.offsetX, e.offsetY), e.deltaY > 0 ? 1 : -1) this.input.onMouseWheel(...this.screenToGrid(e.offsetX, e.offsetY), e.deltaY > 0 ? 1 : -1)
} else { } else {
// smooth scrolling // smooth scrolling
aggregateWheelDelta -= e.wheelDeltaY aggregateWheelDelta -= e.wheelDeltaY
if (Math.abs(aggregateWheelDelta) >= 40) { if (Math.abs(aggregateWheelDelta) >= 40) {
this.emit('mousewheel', ...this.layout.screenToGrid(e.offsetX, e.offsetY), aggregateWheelDelta > 0 ? 1 : -1) this.input.onMouseWheel(...this.screenToGrid(e.offsetX, e.offsetY), aggregateWheelDelta > 0 ? 1 : -1)
aggregateWheelDelta = 0 aggregateWheelDelta = 0
} }
} }
@ -258,7 +288,7 @@ module.exports = class TermScreen extends EventEmitter {
} }
}) })
this.layout.on('contextmenu', e => { this.canvas.addEventListener('contextmenu', e => {
if (this.mouseMode.clicks) { if (this.mouseMode.clicks) {
// prevent mouse keys getting stuck // prevent mouse keys getting stuck
e.preventDefault() e.preventDefault()
@ -267,48 +297,156 @@ module.exports = class TermScreen extends EventEmitter {
}) })
} }
resetScreen () { /**
const { width, height } = this.window * Schedule a size update in the next millisecond
this.blinkingCellCount = 0 */
this.screen.screen = new Array(width * height).fill(' ') scheduleSizeUpdate () {
this.screen.screenFG = new Array(width * height).fill(0) clearTimeout(this._scheduledSizeUpdate)
this.screen.screenBG = new Array(width * height).fill(0) this._scheduledSizeUpdate = setTimeout(() => this.updateSize(), 1)
this.screen.screenAttrs = new Array(width * height).fill(0)
this.screen.screenLines = new Array(height).fill(0)
} }
updateLayout () { /**
this.layout.window.width = this.window.width * Returns a CSS font string with this TermScreen's font settings and the
this.layout.window.height = this.window.height * 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}`
} }
renderScreen (reason) { /**
let selection = [] * 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()
x = x / this._windowScale - this._padding
y = y / this._windowScale - this._padding
x = Math.floor((x + (rounded ? cellSize.width / 2 : 0)) / cellSize.width)
y = Math.floor(y / cellSize.height)
x = Math.max(0, Math.min(this.window.width - 1, x))
y = Math.max(0, Math.min(this.window.height - 1, y))
for (let cell = 0; cell < this.screen.length; cell++) { return [x, y]
selection.push(this.isInSelection(cell % this.window.width, Math.floor(cell / this.window.width))) }
/**
* 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 => this._padding + (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
} }
}
this.layout.render(reason, { /**
width: this.window.width, * The cell size, which is the character size multiplied by the grid scale.
height: this.window.height, * @returns {Object} the cell size with `width` and `height` in pixels
screen: this.screen, */
screenFG: this.screenFG, getCellSize () {
screenBG: this.screenBG, let charSize = this.getCharSize()
screenSelection: selection,
screenAttrs: this.screenAttrs, return {
screenLines: this.screenLines, width: Math.ceil(charSize.width * this.window.gridScaleX),
cursor: this.cursor, height: Math.ceil(charSize.height * this.window.gridScaleY)
statusScreen: this.window.statusScreen, }
reverseVideo: this.reverseVideo,
hasBlinkingCells: !!this.blinkingCellCount
})
} }
resetSelection () { /**
this.selection.start = this.selection.end = [0, 0] * Updates the canvas size if it changed
this.emit('hide-touch-select-menu') */
this.renderScreen('select-reset') updateSize () {
// see below (this is just updating it)
this._window.devicePixelRatio = Math.ceil(this._windowScale * (window.devicePixelRatio || 1))
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,
padding
} = this.window
const cellSize = this.getCellSize()
// real height of the canvas element in pixels
let realWidth = width * cellSize.width
let realHeight = height * cellSize.height
let originalWidth = realWidth
if (fitIntoWidth && fitIntoHeight) {
let terminalAspect = realWidth / realHeight
let fitAspect = fitIntoWidth / fitIntoHeight
if (terminalAspect < fitAspect) {
// align heights
realHeight = fitIntoHeight - 2 * padding
realWidth = realHeight * terminalAspect
} else {
// align widths
realWidth = fitIntoWidth - 2 * padding
realHeight = realWidth / terminalAspect
}
}
// store new window scale
this._windowScale = realWidth / originalWidth
realWidth += 2 * padding
realHeight += 2 * padding
// store padding
this._padding = padding * (originalWidth / realWidth)
// the DPR must be rounded to a very nice value to prevent gaps between cells
let devicePixelRatio = this._window.devicePixelRatio = Math.ceil(this._windowScale * (window.devicePixelRatio || 1))
this.canvas.width = (width * cellSize.width + 2 * Math.round(this._padding)) * devicePixelRatio
this.canvas.style.width = `${realWidth}px`
this.canvas.height = (height * cellSize.height + 2 * Math.round(this._padding)) * devicePixelRatio
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')
}
} }
/** /**
@ -467,124 +605,6 @@ module.exports = class TermScreen extends EventEmitter {
} }
load (...args) { load (...args) {
const updates = this.parser.parse(...args) this.parser.load(...args)
for (let update of updates) {
switch (update.topic) {
case 'screen-opts':
if (update.width !== this.window.width || update.height !== this.window.height) {
this.window.width = update.width
this.window.height = update.height
this.resetScreen()
}
this.layout.renderer.loadTheme(update.theme)
this.layout.renderer.setDefaultColors(update.defFG, update.defBG)
this.cursor.visible = update.cursorVisible
this.emit('input-alts', ...update.inputAlts)
this.mouseMode.clicks = update.trackMouseClicks
this.mouseMode.movement = update.trackMouseMovement
this.emit('mouse-mode', update.trackMouseClicks, update.trackMouseMovement)
this.selection.setSelectable(!update.trackMouseClicks && !update.trackMouseMovement)
if (this.cursor.blinking !== update.cursorBlinking) {
this.cursor.blinking = update.cursorBlinking
this.layout.renderer.resetCursorBlink()
}
this.cursor.style = update.cursorStyle
this.bracketedPaste = update.bracketedPaste
this.reverseVideo = update.reverseVideo
this.window.debug &= 0b01
this.window.debug |= (+update.debugEnabled << 1)
this.showLinks = update.showConfigLinks
this.showButtons = update.showButtons
this.emit('opts-update')
break
case 'double-lines':
this.screenLines = update.lines
this.renderScreen('double-lines')
break
case 'static-opts':
this.layout.window.fontFamily = update.fontStack || null
this.layout.window.fontSize = update.fontSize
break
case 'cursor':
if (this.cursor.x !== update.x || this.cursor.y !== update.y || this.cursor.hanging !== update.hanging) {
this.cursor.x = update.x
this.cursor.y = update.y
this.cursor.hanging = update.hanging
this.layout.renderer.resetCursorBlink()
this.emit('cursor-moved')
this.renderScreen('cursor-moved')
}
break
case 'title':
this.emit('title-update', this.title = update.title)
break
case 'buttons-update':
this.emit('buttons-update', update)
break
case 'backdrop':
this.backgroundImage = update.image
break
case 'bell':
this.beep()
break
case 'internal':
this.emit('internal', update)
break
case 'content':
const { frameX, frameY, frameWidth, frameHeight, cells } = update
if (this._debug && this.window.debug) {
this._debug.pushFrame([frameX, frameY, frameWidth, frameHeight])
}
for (let cell = 0; cell < cells.length; cell++) {
let data = cells[cell]
let cellXInFrame = cell % frameWidth
let cellYInFrame = Math.floor(cell / frameWidth)
let index = (frameY + cellYInFrame) * this.window.width + frameX + cellXInFrame
if ((this.screenAttrs[index] & ATTR_BLINK) !== (data[3] & ATTR_BLINK)) {
if (data[3] & ATTR_BLINK) this.blinkingCellCount++
else this.blinkingCellCount--
}
this.screen[index] = data[0]
this.screenFG[index] = data[1]
this.screenBG[index] = data[2]
this.screenAttrs[index] = data[3]
}
if (this.window.debug) console.log(`Blinking cells: ${this.blinkingCellCount}`)
this.renderScreen('load')
this.emit('load')
break
case 'full-load-complete':
this.emit('full-load')
break
case 'notification':
this.showNotification(update.content)
break
default:
console.warn('Unhandled update', update)
}
}
this.emit('update')
} }
} }

@ -1,15 +0,0 @@
// Bits in the cell attribs word
/* eslint-disable no-multi-spaces */
exports.ATTR_FG = (1 << 0) // 1 if not using default background color (ignore cell bg) - color extension bit
exports.ATTR_BG = (1 << 1) // 1 if not using default foreground color (ignore cell fg) - color extension bit
exports.ATTR_BOLD = (1 << 2) // Bold font
exports.ATTR_UNDERLINE = (1 << 3) // Underline decoration
exports.ATTR_INVERSE = (1 << 4) // Invert colors - this is useful so we can clear then with SGR manipulation commands
exports.ATTR_BLINK = (1 << 5) // Blinking
exports.ATTR_ITALIC = (1 << 6) // Italic font
exports.ATTR_STRIKE = (1 << 7) // Strike-through decoration
exports.ATTR_OVERLINE = (1 << 8) // Over-line decoration
exports.ATTR_FAINT = (1 << 9) // Faint foreground color (reduced alpha)
exports.ATTR_FRAKTUR = (1 << 10) // Fraktur font (unicode substitution)
/* eslint-enable no-multi-spaces */

@ -1,285 +0,0 @@
const EventEmitter = require('events')
const CanvasRenderer = require('./screen_renderer')
const DEFAULT_FONT = '"DejaVu Sans Mono", "Liberation Mono", "Inconsolata", "Menlo", monospace'
/**
* Manages terminal screen layout and sizing
*/
module.exports = class ScreenLayout extends EventEmitter {
constructor () {
super()
this.canvas = document.createElement('canvas')
this.renderer = new CanvasRenderer(this.canvas)
this._window = {
width: 0,
height: 0,
devicePixelRatio: 1,
fontFamily: DEFAULT_FONT,
fontSize: 20,
padding: 6,
gridScaleX: 1.0,
gridScaleY: 1.2,
fitIntoWidth: 0,
fitIntoHeight: 0,
debug: false
}
// scaling caused by fitIntoWidth/fitIntoHeight
this._windowScale = 1
// actual padding, as it may be disabled by fullscreen mode etc.
this._padding = 0
// properties of this.window that require updating size and redrawing
this.windowState = {
width: 0,
height: 0,
devicePixelRatio: 0,
padding: 0,
gridScaleX: 0,
gridScaleY: 0,
fontFamily: '',
fontSize: 0,
fitIntoWidth: 0,
fitIntoHeight: 0
}
this.charSize = { width: 0, height: 0 }
const self = this
// make writing to window update size and draw
this.window = new Proxy(this._window, {
set (target, key, value) {
if (target[key] !== value) {
target[key] = value
self.scheduleSizeUpdate()
self.renderer.scheduleDraw(`window:${key}=${value}`)
self.emit(`update-window:${key}`, value)
}
return true
}
})
this.on('update-window:debug', debug => { this.renderer.debug = debug })
this.canvas.addEventListener('mousedown', e => this.emit('mousedown', e))
this.canvas.addEventListener('mousemove', e => this.emit('mousemove', e))
this.canvas.addEventListener('mouseup', e => this.emit('mouseup', e))
this.canvas.addEventListener('touchstart', e => this.emit('touchstart', e))
this.canvas.addEventListener('touchmove', e => this.emit('touchmove', e))
this.canvas.addEventListener('touchend', e => this.emit('touchend', e))
this.canvas.addEventListener('wheel', e => this.emit('wheel', e))
this.canvas.addEventListener('contextmenu', e => this.emit('contextmenu', e))
}
/**
* Schedule a size update in the next millisecond
*/
scheduleSizeUpdate () {
clearTimeout(this._scheduledSizeUpdate)
this._scheduledSizeUpdate = setTimeout(() => this.updateSize(), 1)
}
get backgroundImage () {
return this.canvas.style.backgroundImage
}
set backgroundImage (value) {
this.canvas.style.backgroundImage = value ? `url(${value})` : ''
if (this.renderer.backgroundImage !== !!value) {
this.renderer.backgroundImage = !!value
this.renderer.resetDrawn()
this.renderer.scheduleDraw('background-image')
}
}
get selectable () {
return this.canvas.classList.contains('selectable')
}
set selectable (selectable) {
if (selectable) this.canvas.classList.add('selectable')
else this.canvas.classList.remove('selectable')
}
/**
* Returns a CSS font string with the current font settings and the
* specified 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'
let fontFamily = this.window.fontFamily || ''
if (fontFamily.length > 0) fontFamily += ','
fontFamily += DEFAULT_FONT
return `${fontStyle} normal ${fontWeight} ${this.window.fontSize}px ${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()
x = x / this._windowScale - this._padding
y = y / this._windowScale - this._padding
y = Math.floor(y / cellSize.height)
if (this.renderer.drawnScreenLines[y]) x /= 2 // double size
x = Math.floor((x + (rounded ? cellSize.width / 2 : 0)) / cellSize.width)
x = Math.max(0, Math.min(this.window.width - 1, x))
y = Math.max(0, Math.min(this.window.height - 1, y))
return [x, y]
}
/**
* 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()
if (this.renderer.drawnScreenLines[y]) x *= 2 // double size
return [x * cellSize.width, y * cellSize.height].map(v => this._padding + (withScale ? v * this._windowScale : v))
}
/**
* Update 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
*/
updateCharSize () {
this.charSize = {
width: this.renderer.getCharWidthFor(this.getFont()),
height: this.window.fontSize
}
return this.charSize
}
/**
* 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 () {
if (!this.charSize.height && this.window.fontSize) this.updateCharSize()
return {
width: Math.ceil(this.charSize.width * this.window.gridScaleX),
height: Math.ceil(this.charSize.height * this.window.gridScaleY)
}
}
/**
* Updates the canvas size if it changed
*/
updateSize () {
// see below (this is just updating it)
this._window.devicePixelRatio = Math.ceil(this._windowScale * (window.devicePixelRatio || 1))
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,
padding
} = this.window
this.updateCharSize()
const cellSize = this.getCellSize()
// real height of the canvas element in pixels
let realWidth = width * cellSize.width
let realHeight = height * cellSize.height
let originalWidth = realWidth
if (fitIntoWidth && fitIntoHeight) {
let terminalAspect = realWidth / realHeight
let fitAspect = fitIntoWidth / fitIntoHeight
if (terminalAspect < fitAspect) {
// align heights
realHeight = fitIntoHeight - 2 * padding
realWidth = realHeight * terminalAspect
} else {
// align widths
realWidth = fitIntoWidth - 2 * padding
realHeight = realWidth / terminalAspect
}
}
// store new window scale
this._windowScale = realWidth / originalWidth
realWidth += 2 * padding
realHeight += 2 * padding
// store padding
this._padding = padding * (originalWidth / realWidth)
// the DPR must be rounded to a very nice value to prevent gaps between cells
let devicePixelRatio = this._window.devicePixelRatio = Math.ceil(this._windowScale * (window.devicePixelRatio || 1))
this.canvas.width = (width * cellSize.width + 2 * Math.round(this._padding)) * devicePixelRatio
this.canvas.style.width = `${realWidth}px`
this.canvas.height = (height * cellSize.height + 2 * Math.round(this._padding)) * devicePixelRatio
this.canvas.style.height = `${realHeight}px`
// the screen has been cleared (by changing canvas width)
this.renderer.resetDrawn()
this.renderer.render('update-size', this.serializeRenderData())
this.emit('size-update')
}
}
serializeRenderData () {
return {
padding: Math.round(this._padding),
devicePixelRatio: this.window.devicePixelRatio,
charSize: this.charSize,
cellSize: this.getCellSize(),
fonts: [
this.getFont(),
this.getFont({ weight: 'bold' }),
this.getFont({ style: 'italic' }),
this.getFont({ weight: 'bold', style: 'italic' })
]
}
}
render (reason, data) {
this.window.width = data.width
this.window.height = data.height
Object.assign(data, this.serializeRenderData())
this.renderer.render(reason, data)
}
}

@ -1,13 +1,5 @@
const { const $ = require('../lib/chibi')
ATTR_FG, const { qs } = require('../utils')
ATTR_BG,
ATTR_BOLD,
ATTR_UNDERLINE,
ATTR_BLINK,
ATTR_STRIKE,
ATTR_OVERLINE,
ATTR_FAINT
} = require('./screen_attr_bits')
// constants for decoding the update blob // constants for decoding the update blob
const SEQ_SKIP = 1 const SEQ_SKIP = 1
@ -18,26 +10,20 @@ const SEQ_SET_FG = 5
const SEQ_SET_BG = 6 const SEQ_SET_BG = 6
const SEQ_SET_ATTR_0 = 7 const SEQ_SET_ATTR_0 = 7
// decode a number encoded as a unicode code point
function du (str) { function du (str) {
if (!str) return NaN
let num = str.codePointAt(0) let num = str.codePointAt(0)
if (num > 0xDFFF) num -= 0x800 if (num > 0xDFFF) num -= 0x800
return num - 1 return num - 1
} }
/* eslint-disable no-multi-spaces */ /* eslint-disable no-multi-spaces */
// mnemonic const TOPIC_SCREEN_OPTS = 'O'
const TOPIC_SCREEN_OPTS = 'O' // O-ptions const TOPIC_CONTENT = 'S'
const TOPIC_STATIC_OPTS = 'P' // P-arams const TOPIC_TITLE = 'T'
const TOPIC_CONTENT = 'S' // S-creen const TOPIC_BUTTONS = 'B'
const TOPIC_TITLE = 'T' // T-itle const TOPIC_CURSOR = 'C'
const TOPIC_BUTTONS = 'B' // B-uttons const TOPIC_INTERNAL = 'D'
const TOPIC_CURSOR = 'C' // C-ursor const TOPIC_BELL = '!'
const TOPIC_INTERNAL = 'D' // D-ebug
const TOPIC_BELL = '!' // !!!
const TOPIC_BACKDROP = 'W' // W-allpaper
const TOPIC_DOUBLE_LINES = 'H' // H-uge
const OPT_CURSOR_VISIBLE = (1 << 0) const OPT_CURSOR_VISIBLE = (1 << 0)
const OPT_DEBUGBAR = (1 << 1) const OPT_DEBUGBAR = (1 << 1)
@ -53,75 +39,78 @@ const OPT_CRLF_MODE = (1 << 12)
const OPT_BRACKETED_PASTE = (1 << 13) const OPT_BRACKETED_PASTE = (1 << 13)
const OPT_REVERSE_VIDEO = (1 << 14) const OPT_REVERSE_VIDEO = (1 << 14)
const ATTR_FG = (1 << 0) // 1 if not using default background color (ignore cell bg) - color extension bit
const ATTR_BG = (1 << 1) // 1 if not using default foreground color (ignore cell fg) - color extension bit
const ATTR_BOLD = (1 << 2) // Bold font
const ATTR_UNDERLINE = (1 << 3) // Underline decoration
const ATTR_INVERSE = (1 << 4) // Invert colors - this is useful so we can clear then with SGR manipulation commands
const ATTR_BLINK = (1 << 5) // Blinking
const ATTR_ITALIC = (1 << 6) // Italic font
const ATTR_STRIKE = (1 << 7) // Strike-through decoration
const ATTR_OVERLINE = (1 << 8) // Over-line decoration
const ATTR_FAINT = (1 << 9) // Faint foreground color (reduced alpha)
const ATTR_FRAKTUR = (1 << 10) // Fraktur font (unicode substitution)
/* eslint-enable no-multi-spaces */ /* eslint-enable no-multi-spaces */
/**
* A parser for screen update messages
*/
module.exports = class ScreenParser { module.exports = class ScreenParser {
constructor () { constructor (screen) {
// true if full content was loaded this.screen = screen
// true if TermScreen#load was called at least once
this.contentLoaded = false this.contentLoaded = false
} }
parseUpdate (str) { /**
// console.log(`update ${str}`) * Hide the warning message about failed data load
*/
hideLoadFailedMsg () {
if (!this.contentLoaded) {
let errmsg = qs('#load-failed')
if (errmsg) errmsg.parentNode.removeChild(errmsg)
this.contentLoaded = true
}
}
loadUpdate (str) {
// console.log(`update ${str}`)
// current index // current index
let ci = 0 let ci = 0
let strArray = Array.from ? Array.from(str) : str.split('') let strArray = Array.from ? Array.from(str) : str.split('')
let text let text
let resized = false
const topics = du(strArray[ci++]) const topics = du(strArray[ci++])
// this.screen.cursor.hanging = !!(attributes & (1 << 1))
let collectOneTerminatedString = () => {
// TODO optimize this
text = ''
while (ci < strArray.length) {
let c = strArray[ci++]
if (c !== '\x01') {
text += c
} else {
break
}
}
return text
}
let collectColor = () => {
let c = du(strArray[ci++])
if (c & 0x10000) { // support for trueColor
c &= 0xFFF
c |= (du(strArray[ci++]) & 0xFFF) << 12
c += 256
}
return c
}
const updates = []
while (ci < strArray.length) { while (ci < strArray.length) {
const topic = strArray[ci++] const topic = strArray[ci++]
if (topic === TOPIC_SCREEN_OPTS) { if (topic === TOPIC_SCREEN_OPTS) {
const height = du(strArray[ci++]) const newHeight = du(strArray[ci++])
const width = du(strArray[ci++]) const newWidth = du(strArray[ci++])
const theme = du(strArray[ci++]) const theme = du(strArray[ci++])
const defFG = collectColor() const defFg = (du(strArray[ci++]) & 0xFFFF) | ((du(strArray[ci++]) & 0xFFFF) << 16)
const defBG = collectColor() const defBg = (du(strArray[ci++]) & 0xFFFF) | ((du(strArray[ci++]) & 0xFFFF) << 16)
// process attributes
const attributes = du(strArray[ci++]) const attributes = du(strArray[ci++])
const cursorVisible = !!(attributes & OPT_CURSOR_VISIBLE) // theming
this.screen.renderer.loadTheme(theme)
this.screen.renderer.setDefaultColors(defFg, defBg)
// apply size
resized = (this.screen.window.height !== newHeight) || (this.screen.window.width !== newWidth)
this.screen.window.height = newHeight
this.screen.window.width = newWidth
// process attributes
this.screen.cursor.visible = !!(attributes & OPT_CURSOR_VISIBLE)
// HACK: input alts are formatted as arguments for Input#setAlts this.screen.input.setAlts(
const inputAlts = [
!!(attributes & OPT_CURSORS_ALT_MODE), !!(attributes & OPT_CURSORS_ALT_MODE),
!!(attributes & OPT_NUMPAD_ALT_MODE), !!(attributes & OPT_NUMPAD_ALT_MODE),
!!(attributes & OPT_FN_ALT_MODE), !!(attributes & OPT_FN_ALT_MODE),
!!(attributes & OPT_CRLF_MODE) !!(attributes & OPT_CRLF_MODE)
] )
const trackMouseClicks = !!(attributes & OPT_CLICK_TRACKING) const trackMouseClicks = !!(attributes & OPT_CLICK_TRACKING)
const trackMouseMovement = !!(attributes & OPT_MOVE_TRACKING) const trackMouseMovement = !!(attributes & OPT_MOVE_TRACKING)
@ -133,100 +122,98 @@ module.exports = class ScreenParser {
// if it's not zero, decrement such that the two most significant bits // 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 // are the type and the least significant bit is the blink state
if (cursorShape > 0) cursorShape-- if (cursorShape > 0) cursorShape--
let cursorStyle = cursorShape >> 1 const cursorStyle = cursorShape >> 1
const cursorBlinking = !(cursorShape & 1) const cursorBlinking = !(cursorShape & 1)
if (cursorStyle === 0) cursorStyle = 'block' if (cursorStyle === 0) this.screen.cursor.style = 'block'
else if (cursorStyle === 1) cursorStyle = 'line' else if (cursorStyle === 1) this.screen.cursor.style = 'line'
else cursorStyle = 'bar' 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
}
const showButtons = !!(attributes & OPT_SHOW_BUTTONS) const showButtons = !!(attributes & OPT_SHOW_BUTTONS)
const showConfigLinks = !!(attributes & OPT_SHOW_CONFIG_LINKS) const showConfigLinks = !!(attributes & OPT_SHOW_CONFIG_LINKS)
const bracketedPaste = !!(attributes & OPT_BRACKETED_PASTE) $('.x-term-conf-btn').toggleClass('hidden', !showConfigLinks)
const reverseVideo = !!(attributes & OPT_REVERSE_VIDEO) $('#action-buttons').toggleClass('hidden', !showButtons)
const debugEnabled = !!(attributes & OPT_DEBUGBAR) this.screen.bracketedPaste = !!(attributes & OPT_BRACKETED_PASTE)
this.screen.reverseVideo = !!(attributes & OPT_REVERSE_VIDEO)
updates.push({
topic: 'screen-opts', const debugbar = !!(attributes & OPT_DEBUGBAR)
width, // TODO do something with debugbar
height,
theme,
defFG,
defBG,
cursorVisible,
cursorBlinking,
cursorStyle,
inputAlts,
trackMouseClicks,
trackMouseMovement,
showButtons,
showConfigLinks,
bracketedPaste,
reverseVideo,
debugEnabled
})
} else if (topic === TOPIC_CURSOR) { } else if (topic === TOPIC_CURSOR) {
// cursor position // cursor position
const y = du(strArray[ci++]) const cursorY = du(strArray[ci++])
const x = du(strArray[ci++]) const cursorX = du(strArray[ci++])
const hanging = !!du(strArray[ci++]) const hanging = du(strArray[ci++])
updates.push({
topic: 'cursor',
x,
y,
hanging
})
} else if (topic === TOPIC_STATIC_OPTS) { const cursorMoved = (
const fontStack = collectOneTerminatedString() hanging !== this.screen.cursor.hanging ||
const fontSize = du(strArray[ci++]) cursorX !== this.screen.cursor.x ||
cursorY !== this.screen.cursor.y)
updates.push({ this.screen.cursor.x = cursorX
topic: 'static-opts', this.screen.cursor.y = cursorY
fontStack,
fontSize
})
} else if (topic === TOPIC_DOUBLE_LINES) { this.screen.cursor.hanging = !!hanging
let lines = []
const count = du(strArray[ci++]) if (cursorMoved) {
for (let i = 0; i < count; i++) { this.screen.renderer.resetCursorBlink()
// format: INDEX<<3 | (dbl-h-bot : dbl-h-top : dbl-w) this.screen.emit('cursor-moved')
let n = du(strArray[ci++])
lines[n >> 3] = n & 0b111
} }
updates.push({ topic: 'double-lines', lines: lines })
this.screen.renderer.scheduleDraw('cursor-moved')
} else if (topic === TOPIC_TITLE) { } else if (topic === TOPIC_TITLE) {
updates.push({ topic: 'title', title: collectOneTerminatedString() })
// TODO optimize this
text = ''
while (ci < strArray.length) {
let c = strArray[ci++]
if (c !== '\x01') {
text += c
} else {
break
}
}
qs('#screen-title').textContent = text
if (text.length === 0) text = 'Terminal'
qs('title').textContent = `${text} :: ESPTerm`
} else if (topic === TOPIC_BUTTONS) { } else if (topic === TOPIC_BUTTONS) {
const count = du(strArray[ci++]) const count = du(strArray[ci++])
let labels = [] let labels = []
let colors = []
for (let j = 0; j < count; j++) { for (let j = 0; j < count; j++) {
colors.push(collectColor()) text = ''
labels.push(collectOneTerminatedString()) while (ci < strArray.length) {
let c = strArray[ci++]
if (c === '\x01') break
text += c
}
labels.push(text)
} }
updates.push({ this.screen.emit('button-labels', labels)
topic: 'buttons-update',
labels,
colors
})
} else if (topic === TOPIC_BACKDROP) {
updates.push({ topic: 'backdrop', image: collectOneTerminatedString() })
} else if (topic === TOPIC_BELL) { } else if (topic === TOPIC_BELL) {
updates.push({ topic: 'bell' })
this.screen.beep()
} else if (topic === TOPIC_INTERNAL) { } else if (topic === TOPIC_INTERNAL) {
// debug info // debug info
const flags = du(strArray[ci++]) const flags = du(strArray[ci++])
const cursorAttrs = du(strArray[ci++]) const cursorAttrs = du(strArray[ci++])
const regionStart = du(strArray[ci++]) const regionStart = du(strArray[ci++])
@ -234,15 +221,10 @@ module.exports = class ScreenParser {
const charsetGx = du(strArray[ci++]) const charsetGx = du(strArray[ci++])
const charsetG0 = strArray[ci++] const charsetG0 = strArray[ci++]
const charsetG1 = strArray[ci++] const charsetG1 = strArray[ci++]
let cursorFg = collectColor()
let cursorBg = collectColor()
const freeHeap = du(strArray[ci++]) const freeHeap = du(strArray[ci++])
const clientCount = du(strArray[ci++]) const clientCount = du(strArray[ci++])
updates.push({ this.screen.emit('internal', {
topic: 'internal',
flags, flags,
cursorAttrs, cursorAttrs,
regionStart, regionStart,
@ -250,19 +232,21 @@ module.exports = class ScreenParser {
charsetGx, charsetGx,
charsetG0, charsetG0,
charsetG1, charsetG1,
cursorFg,
cursorBg,
freeHeap, freeHeap,
clientCount clientCount
}) })
} else if (topic === TOPIC_CONTENT) { } else if (topic === TOPIC_CONTENT) {
// set screen content // set screen content
const frameY = du(strArray[ci++]) const frameY = du(strArray[ci++])
const frameX = du(strArray[ci++]) const frameX = du(strArray[ci++])
const frameHeight = du(strArray[ci++]) const frameHeight = du(strArray[ci++])
const frameWidth = du(strArray[ci++]) const frameWidth = du(strArray[ci++])
if (this.screen._debug && this.screen.window.debug) {
this.screen._debug.pushFrame([frameX, frameY, frameWidth, frameHeight])
}
// content // content
let fg = 7 let fg = 7
let bg = 0 let bg = 0
@ -270,39 +254,59 @@ module.exports = class ScreenParser {
let cell = 0 // cell index let cell = 0 // cell index
let lastChar = ' ' let lastChar = ' '
let frameLength = frameWidth * frameHeight let frameLength = frameWidth * frameHeight
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 = ATTR_UNDERLINE | ATTR_OVERLINE | ATTR_STRIKE const MASK_LINE_ATTR = ATTR_UNDERLINE | ATTR_OVERLINE | ATTR_STRIKE
const MASK_BLINK = ATTR_BLINK const MASK_BLINK = ATTR_BLINK
const cells = []
let pushCell = () => { let pushCell = () => {
// Remove blink attribute if it wouldn't have any effect
let myAttrs = attrs
let hasFG = attrs & ATTR_FG let hasFG = attrs & ATTR_FG
let hasBG = attrs & ATTR_BG let hasBG = attrs & ATTR_BG
let cellFG = fg let cellFG = fg
let cellBG = bg let cellBG = bg
let cellAttrs = attrs
// use 0,0 if no fg/bg. this is to match back-end implementation // use 0,0 if no fg/bg. this is to match back-end implementation
// and allow leaving out fg/bg setting for cells with none // and allow leaving out fg/bg setting for cells with none
if (!hasFG) cellFG = 0 if (!hasFG) cellFG = 0
if (!hasBG) cellBG = 0 if (!hasBG) cellBG = 0
// Remove blink attribute if it wouldn't have any effect if ((myAttrs & MASK_BLINK) !== 0 &&
if ((cellAttrs & MASK_BLINK) && ((lastChar === ' ' && ((myAttrs & MASK_LINE_ATTR) === 0)) || // no line styles
((lastChar === ' ' && ((cellAttrs & MASK_LINE_ATTR) === 0)) || // no line styles
(fg === bg && hasFG && hasBG) // invisible text (fg === bg && hasFG && hasBG) // invisible text
) )
) { ) {
cellAttrs ^= MASK_BLINK 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--
}
let cellXInFrame = cell % frameWidth
let cellYInFrame = Math.floor(cell / frameWidth)
let index = (frameY + cellYInFrame) * this.screen.window.width + frameX + cellXInFrame
// 8 dark system colors turn bright when bold // 8 dark system colors turn bright when bold
if ((cellAttrs & ATTR_BOLD) && !(cellAttrs & ATTR_FAINT) && hasFG && cellFG < 8) { if ((myAttrs & ATTR_BOLD) && !(myAttrs & ATTR_FAINT) && hasFG && cellFG < 8) {
cellFG += 8 cellFG += 8
} }
cells.push([lastChar, cellFG, cellBG, cellAttrs]) this.screen.screen[index] = lastChar
this.screen.screenFG[index] = cellFG
this.screen.screenBG[index] = cellBG
this.screen.screenAttrs[index] = myAttrs
} }
while (ci < strArray.length && cell < frameLength) { while (ci < strArray.length && cell < frameLength) {
@ -366,51 +370,38 @@ module.exports = class ScreenParser {
} }
} }
updates.push({ if (this.screen.window.debug) console.log(`Blinky cells: ${this.screen.blinkingCellCount}`)
topic: 'content',
frameX, this.screen.renderer.scheduleDraw('load', 16)
frameY, this.screen.conn.emit('load')
frameWidth,
frameHeight,
cells
})
}
if (topics & 0x3B && !this.contentLoaded) {
updates.push({ topic: 'full-load-complete' })
this.contentLoaded = true
} }
}
return updates if ((topics & 0x3B) !== 0) this.hideLoadFailedMsg()
}
} }
/** /**
* Parses a message from the server * Loads a message from the server, and optionally a theme.
* @param {string} message - the message * @param {string} str - the message
*/ */
parse (message) { load (str) {
const content = message.substr(1) const content = str.substr(1)
const updates = []
// This is a good place for debugging the message // This is a good place for debugging the message
// console.log(message) // console.log(str)
switch (message[0]) { switch (str[0]) {
case 'U': case 'U':
updates.push(...this.parseUpdate(content)) this.loadUpdate(content)
break break
case 'G': case 'G':
return [{ this.screen.showNotification(content)
topic: 'notification', break
content
}]
default: default:
console.warn(`Bad data message type; ignoring.\n${JSON.stringify(message)}`) console.warn(`Bad data message type; ignoring.\n${JSON.stringify(str)}`)
} }
return updates
} }
} }

@ -1,22 +1,4 @@
const EventEmitter = require('events') const { themes, buildColorTable, SELECTION_FG, SELECTION_BG } = require('./themes')
const {
themes,
getColor
} = require('./themes')
const {
ATTR_FG,
ATTR_BG,
ATTR_BOLD,
ATTR_UNDERLINE,
ATTR_INVERSE,
ATTR_BLINK,
ATTR_ITALIC,
ATTR_STRIKE,
ATTR_OVERLINE,
ATTR_FAINT,
ATTR_FRAKTUR
} = require('./screen_attr_bits')
// Some non-bold Fraktur symbols are outside the contiguous block // Some non-bold Fraktur symbols are outside the contiguous block
const frakturExceptions = { const frakturExceptions = {
@ -27,44 +9,33 @@ const frakturExceptions = {
'Z': '\u2128' 'Z': '\u2128'
} }
/** // TODO do not repeat - this is also defined in screen_parser ...
* A terminal screen renderer, using canvas 2D /* eslint-disable no-multi-spaces */
*/ const ATTR_FG = (1 << 0) // 1 if not using default background color (ignore cell bg) - color extension bit
module.exports = class CanvasRenderer extends EventEmitter { const ATTR_BG = (1 << 1) // 1 if not using default foreground color (ignore cell fg) - color extension bit
constructor (canvas) { const ATTR_BOLD = (1 << 2) // Bold font
super() const ATTR_UNDERLINE = (1 << 3) // Underline decoration
const ATTR_INVERSE = (1 << 4) // Invert colors - this is useful so we can clear then with SGR manipulation commands
this.canvas = canvas const ATTR_BLINK = (1 << 5) // Blinking
this.ctx = this.canvas.getContext('2d') const ATTR_ITALIC = (1 << 6) // Italic font
const ATTR_STRIKE = (1 << 7) // Strike-through decoration
this._palette = null // colors 0-15 const ATTR_OVERLINE = (1 << 8) // Over-line decoration
this.defaultBG = 0 const ATTR_FAINT = (1 << 9) // Faint foreground color (reduced alpha)
this.defaultFG = 7 const ATTR_FRAKTUR = (1 << 10) // Fraktur font (unicode substitution)
/* eslint-enable no-multi-spaces */
this.debug = false
this._debug = null module.exports = class ScreenRenderer {
constructor (screen) {
this.graphics = 0 this.screen = screen
this.ctx = screen.ctx
this.statusFont = "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Roboto, Arial, sans-serif"
this._palette = null // colors 0-15
// screen data, considered immutable this.defaultBgNum = 0
this.width = 0 this.defaultFgNum = 7
this.height = 0
this.padding = 0 // 256color lookup table
this.charSize = { width: 0, height: 0 } // should not be used to look up 0-15 (will return transparent)
this.cellSize = { width: 0, height: 0 } this.colorTable256 = buildColorTable()
this.fonts = ['', '', '', ''] // normal, bold, italic, bold-italic
this.screen = []
this.screenFG = []
this.screenBG = []
this.screenAttrs = []
this.screenSelection = []
this.screenLines = []
this.cursor = {}
this.reverseVideo = false
this.hasBlinkingCells = false
this.statusScreen = null
this.resetDrawn() this.resetDrawn()
@ -78,27 +49,17 @@ module.exports = class CanvasRenderer extends EventEmitter {
this.resetCursorBlink() this.resetCursorBlink()
} }
render (reason, data) {
if ('hasBlinkingCells' in data && data.hasBlinkingCells !== this.hasBlinkingCells) {
if (data.hasBlinkingCells) this.resetBlink()
else clearInterval(this.blinkInterval)
}
Object.assign(this, data)
this.scheduleDraw(reason)
}
resetDrawn () { resetDrawn () {
// used to determine if a cell should be redrawn; storing the current state // used to determine if a cell should be redrawn; storing the current state
// as it is on screen // as it is on screen
if (this.debug) console.log('Resetting drawn screen') if (this.screen.window && this.screen.window.debug) {
console.log('Resetting drawn screen')
}
this.drawnScreen = [] this.drawnScreen = []
this.drawnScreenFG = [] this.drawnScreenFG = []
this.drawnScreenBG = [] this.drawnScreenBG = []
this.drawnScreenAttrs = [] this.drawnScreenAttrs = []
this.drawnScreenLines = [] this.drawnCursor = [-1, -1, '']
this.drawnCursor = [-1, -1, '', false]
} }
/** /**
@ -114,29 +75,20 @@ module.exports = class CanvasRenderer extends EventEmitter {
if (this._palette !== palette) { if (this._palette !== palette) {
this._palette = palette this._palette = palette
this.resetDrawn() this.resetDrawn()
this.emit('palette-update', palette)
this.scheduleDraw('palette') this.scheduleDraw('palette')
} }
} }
getCharWidthFor (font) {
this.ctx.font = font
return Math.floor(this.ctx.measureText(' ').width)
}
loadTheme (i) { loadTheme (i) {
if (i in themes) this.palette = themes[i] if (i in themes) this.palette = themes[i]
} }
setDefaultColors (fg, bg) { setDefaultColors (fg, bg) {
if (fg !== this.defaultFG || bg !== this.defaultBG) { if (fg !== this.defaultFgNum || bg !== this.defaultBgNum) {
this.resetDrawn() this.resetDrawn()
this.defaultFG = fg this.defaultFgNum = fg
this.defaultBG = bg this.defaultBgNum = bg
this.scheduleDraw('default-colors') this.scheduleDraw('default-colors')
// full bg with default color (goes behind the image)
this.canvas.style.backgroundColor = this.getColor(bg)
} }
} }
@ -160,7 +112,27 @@ module.exports = class CanvasRenderer extends EventEmitter {
* @returns {string} the CSS color * @returns {string} the CSS color
*/ */
getColor (i) { getColor (i) {
return getColor(i, this.palette) // 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'
} }
/** /**
@ -170,8 +142,10 @@ module.exports = class CanvasRenderer extends EventEmitter {
this.cursorBlinkOn = true this.cursorBlinkOn = true
clearInterval(this.cursorBlinkInterval) clearInterval(this.cursorBlinkInterval)
this.cursorBlinkInterval = setInterval(() => { this.cursorBlinkInterval = setInterval(() => {
this.cursorBlinkOn = this.cursor.blinking ? !this.cursorBlinkOn : true this.cursorBlinkOn = this.screen.cursor.blinking
if (this.cursor.blinking) this.scheduleDraw('cursor-blink') ? !this.cursorBlinkOn
: true
if (this.screen.cursor.blinking) this.scheduleDraw('cursor-blink')
}, 500) }, 500)
} }
@ -183,7 +157,7 @@ module.exports = class CanvasRenderer extends EventEmitter {
clearInterval(this.blinkInterval) clearInterval(this.blinkInterval)
let intervals = 0 let intervals = 0
this.blinkInterval = setInterval(() => { this.blinkInterval = setInterval(() => {
if (this.blinkingCellCount <= 0) return if (this.screen.blinkingCellCount <= 0) return
intervals++ intervals++
if (intervals >= 4 && this.blinkStyleOn) { if (intervals >= 4 && this.blinkStyleOn) {
@ -206,20 +180,15 @@ module.exports = class CanvasRenderer extends EventEmitter {
* @param {number} options.cellWidth - cell width in pixels * @param {number} options.cellWidth - cell width in pixels
* @param {number} options.cellHeight - cell height in pixels * @param {number} options.cellHeight - cell height in pixels
* @param {number} options.bg - the background color * @param {number} options.bg - the background color
* @param {number} options.isDefaultBG - if true, will draw image background if available
*/ */
drawBackground ({ x, y, cellWidth, cellHeight, bg, isDefaultBG }) { drawBackground ({ x, y, cellWidth, cellHeight, bg }) {
const { ctx, width, height, padding } = this const ctx = this.ctx
const { width, height } = this.screen.window
// is a double-width/double-height line const padding = Math.round(this.screen._padding)
if (this.screenLines[y] & 0b001) cellWidth *= 2
ctx.fillStyle = this.getColor(bg) ctx.fillStyle = this.getColor(bg)
let screenX = x * cellWidth + padding let screenX = x * cellWidth + padding
let screenY = y * cellHeight + padding let screenY = y * cellHeight + padding
let isBorderCell = x === 0 || y === 0 || x === width - 1 || y === height - 1 let isBorderCell = x === 0 || y === 0 || x === width - 1 || y === height - 1
let fillX, fillY, fillWidth, fillHeight
if (isBorderCell) { if (isBorderCell) {
let left = screenX let left = screenX
let top = screenY let top = screenY
@ -229,25 +198,80 @@ module.exports = class CanvasRenderer extends EventEmitter {
else if (x === width - 1) right += padding else if (x === width - 1) right += padding
if (y === 0) top -= padding if (y === 0) top -= padding
else if (y === height - 1) bottom += padding else if (y === height - 1) bottom += padding
ctx.clearRect(left, top, right - left, bottom - top)
fillX = left ctx.fillRect(left, top, right - left, bottom - top)
fillY = top
fillWidth = right - left
fillHeight = bottom - top
} else { } else {
fillX = screenX ctx.clearRect(screenX, screenY, cellWidth, cellHeight)
fillY = screenY ctx.fillRect(screenX, screenY, cellWidth, cellHeight)
fillWidth = cellWidth
fillHeight = cellHeight
} }
}
ctx.clearRect(fillX, fillY, fillWidth, fillHeight) drawBoxLine (x, y, dx, dy, type, normalType) {
const ctx = this.ctx
if (!isDefaultBG || bg < 0 || !this.backgroundImage) { let normalOffset = 0
ctx.fillRect(fillX, fillY, fillWidth, fillHeight) if (normalType === 1) {
// thin
normalOffset = 1 / 2
} else if (normalType === 2) {
// thick
normalOffset = 3 / 2
} else if (normalType === 3) {
// double
normalOffset = -1
}
if (type === 1 || type === 2) {
// thin or thick line
let lineWidth = type === 2 ? 3 : 1
let startX = x - Math.sign(dx) * normalOffset
let startY = y - Math.sign(dy) * normalOffset
let width = dx
let height = dy
if (Math.abs(dx) > 0) {
// horizontal line
startY -= lineWidth / 2
startY = Math.round(startY)
height = lineWidth
} else {
// vertical line
startX -= lineWidth / 2
startX = Math.round(startX)
width = lineWidth
}
ctx.fillRect(startX, startY, width, height)
} else if (type === 3) {
// double-struck line
ctx.lineWidth = 1
ctx.lineCap = 'butt'
ctx.beginPath()
let nx = ctx.lineWidth * Math.sign(dy) // normal x
let ny = ctx.lineWidth * Math.sign(dx) // normal y
ctx.moveTo(x + nx - Math.sign(dx) * normalOffset, y + ny - Math.sign(dy) * normalOffset)
ctx.lineTo(x + dx + nx, y + dy + ny)
ctx.moveTo(x - nx - Math.sign(dx) * normalOffset, y - ny - Math.sign(dy) * normalOffset)
ctx.lineTo(x + dx - nx, y + dy - ny)
ctx.stroke()
} }
} }
drawBoxLines ({ x, y, cellWidth, cellHeight, up, left, right, down }) {
const padding = Math.round(this.screen._padding)
let centerX = (x + 0.5) * cellWidth + padding
let centerY = (y + 0.5) * cellHeight + padding
let verticalType = Math.max(up, down)
let horizontalType = Math.max(left, right)
if (up) this.drawBoxLine(centerX, centerY, 0, -cellHeight / 2, up, horizontalType)
if (left) this.drawBoxLine(centerX, centerY, -cellWidth / 2, 0, left, verticalType)
if (right) this.drawBoxLine(centerX, centerY, cellWidth / 2, 0, right, verticalType)
if (down) this.drawBoxLine(centerX, centerY, 0, cellHeight / 2, down, horizontalType)
}
/** /**
* Draws a cell's character with the given parameters. Won't do anything if * Draws a cell's character with the given parameters. Won't do anything if
* text is an empty string. * text is an empty string.
@ -265,14 +289,15 @@ module.exports = class CanvasRenderer extends EventEmitter {
drawCharacter ({ x, y, charSize, cellWidth, cellHeight, text, fg, attrs }) { drawCharacter ({ x, y, charSize, cellWidth, cellHeight, text, fg, attrs }) {
if (!text) return if (!text) return
const { ctx, padding } = this const ctx = this.ctx
const padding = Math.round(this.screen._padding)
let underline = false let underline = false
let strike = false let strike = false
let overline = false let overline = false
if (attrs & ATTR_FAINT) ctx.globalAlpha = 0.5 if (attrs & ATTR_FAINT) ctx.globalAlpha = 0.5
if (attrs & ATTR_UNDERLINE) underline = true if (attrs & ATTR_UNDERLINE) underline = true
if (attrs & ATTR_FRAKTUR) text = CanvasRenderer.alphaToFraktur(text) if (attrs & ATTR_FRAKTUR) text = ScreenRenderer.alphaToFraktur(text)
if (attrs & ATTR_STRIKE) strike = true if (attrs & ATTR_STRIKE) strike = true
if (attrs & ATTR_OVERLINE) overline = true if (attrs & ATTR_OVERLINE) overline = true
@ -281,41 +306,203 @@ module.exports = class CanvasRenderer extends EventEmitter {
let screenX = x * cellWidth + padding let screenX = x * cellWidth + padding
let screenY = y * cellHeight + padding let screenY = y * cellHeight + padding
const dblWidth = this.screenLines[y] & 0b001 let codePoint = text.codePointAt(0)
const dblHeightTop = this.screenLines[y] & 0b010 if (codePoint >= 0x2500 && codePoint <= 0x257F) {
const dblHeightBot = this.screenLines[y] & 0b100 // box drawing
// 0 1 2 3 4 5 6 7 8 9 a b c d e f
if (this.screenLines[y]) { // 250 ─ ━ │ ┃ ┄ ┅ ┆ ┇ ┈ ┉ ┊ ┋ ┌ ┍ ┎ ┏
// is a double-width/double-height line // 251 ┐ ┑ ┒ ┓ └ ┕ ┖ ┗ ┘ ┙ ┚ ┛ ├ ┝ ┞ ┟
if (dblWidth) cellWidth *= 2 // 252 ┠ ┡ ┢ ┣ ┤ ┥ ┦ ┧ ┨ ┩ ┪ ┫ ┬ ┭ ┮ ┯
// 253 ┰ ┱ ┲ ┳ ┴ ┵ ┶ ┷ ┸ ┹ ┺ ┻ ┼ ┽ ┾ ┿
// 254 ╀ ╁ ╂ ╃ ╄ ╅ ╆ ╇ ╈ ╉ ╊ ╋ ╌ ╍ ╎ ╏
// 255 ═ ║ ╒ ╓ ╔ ╕ ╖ ╗ ╘ ╙ ╚ ╛ ╜ ╝ ╞ ╟
// 256 ╠ ╡ ╢ ╣ ╤ ╥ ╦ ╧ ╨ ╩ ╪ ╫ ╬ ╭ ╮ ╯
// 257 ╰ ╱ ╲ ╳ ╴ ╵ ╶ ╷ ╸ ╹ ╺ ╻ ╼ ╽ ╾ ╿
ctx.strokeStyle = ctx.fillStyle
if (codePoint <= 0x250B || (codePoint >= 0x254C && codePoint <= 0x254F)) {
// single long line
// direction. 0 for horizontal, 1 for vertical
let direction, thick, dashes
if (codePoint <= 0x250B) {
direction = Math.floor((codePoint - 0x2500) / 2) % 2
thick = codePoint % 2 === 1
dashes = codePoint < 0x2504 ? 0 : codePoint < 0x2508 ? 3 : 4
} else {
direction = (codePoint - 0x254C) < 3 ? 0 : 1
thick = codePoint % 2 === 1
dashes = 2
}
ctx.save() ctx.lineWidth = thick ? 3 : 1
ctx.translate(padding, screenY + 0.5 * cellHeight) if (dashes) {
if (dblWidth) ctx.scale(2, 1) let length = direction === 0 ? cellWidth : cellHeight
if (dblHeightTop) { ctx.setLineDash([(length / dashes) - 1, 1])
// top half }
ctx.scale(1, 2)
ctx.translate(0, cellHeight / 4)
} else if (dblHeightBot) {
// bottom half
ctx.scale(1, 2)
ctx.translate(0, -cellHeight / 4)
}
ctx.translate(-padding, -screenY - 0.5 * cellHeight)
if (dblWidth) ctx.translate(-cellWidth / 4, 0)
if (dblHeightBot || dblHeightTop) {
// characters overflow -- needs clipping
// TODO: clipping is really expensive
ctx.beginPath() ctx.beginPath()
if (dblHeightTop) ctx.rect(screenX, screenY, cellWidth, cellHeight / 2) if (direction === 0) {
else ctx.rect(screenX, screenY + cellHeight / 2, cellWidth, cellHeight / 2) ctx.moveTo(screenX, screenY + 0.5 * cellHeight)
ctx.clip() ctx.lineTo(screenX + cellWidth, screenY + 0.5 * cellHeight)
} else {
ctx.moveTo(screenX + 0.5 * cellWidth, screenY)
ctx.lineTo(screenX + 0.5 * cellWidth, screenY + cellHeight)
}
ctx.stroke()
if (dashes) ctx.setLineDash([])
} else if (codePoint <= 0x251B) {
// two lines
// horizontal line direction
let directionX = (codePoint - 0x250B - 1) % 8 < 4 ? 1 : -1
// vertical line direction
let directionY = codePoint < 0x2514 ? 1 : -1
let typeX = (1 - (codePoint - 0x250B) % 2) + 1
let typeY = Math.floor((codePoint - 0x250B - 1) / 2) % 2 + 1
this.drawBoxLines({
x,
y,
cellWidth,
cellHeight,
up: directionY === -1 ? typeY : 0,
down: directionY === 1 ? typeY : 0,
left: directionX === -1 ? typeX : 0,
right: directionX === 1 ? typeX : 0
})
} else if (codePoint <= 0x253B) {
// three lines
// TODO: figure out the pattern
let up = [1, 1, 2, 1, 2, 2, 1, 2, 1, 1, 2, 1, 2, 2, 1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2]
let left = [0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 1, 1, 1, 2, 2, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2]
let right = [1, 2, 1, 1, 1, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 2, 1, 1, 2, 2, 1, 1, 2, 2, 1, 1, 2, 2]
let down = [1, 1, 1, 2, 2, 1, 2, 2, 1, 1, 1, 2, 2, 1, 2, 2, 1, 1, 1, 1, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0]
let index = codePoint - 0x251C
this.drawBoxLines({
x,
y,
cellWidth,
cellHeight,
up: up[index],
left: left[index],
right: right[index],
down: down[index]
})
} else if (codePoint <= 0x254B) {
// four lines
let up = [1, 1, 1, 1, 2, 1, 2, 2, 2, 1, 1, 2, 1, 2, 2, 2]
let left = [1, 2, 1, 2, 1, 1, 1, 2, 1, 2, 1, 2, 2, 2, 1, 2]
let right = [1, 1, 2, 2, 1, 1, 1, 1, 2, 1, 2, 2, 2, 1, 2, 2]
let down = [1, 1, 1, 1, 1, 2, 2, 1, 1, 2, 2, 1, 2, 2, 2, 2]
let index = codePoint - 0x253C
this.drawBoxLines({
x,
y,
cellWidth,
cellHeight,
up: up[index],
left: left[index],
right: right[index],
down: down[index]
})
} else if (codePoint <= 0x2551) {
// double struck line
this.drawBoxLines({
x,
y,
cellWidth,
cellHeight,
up: codePoint === 0x2551 ? 3 : 0,
down: codePoint === 0x2551 ? 3 : 0,
left: codePoint === 0x2550 ? 3 : 0,
right: codePoint === 0x2550 ? 3 : 0
})
} else if (codePoint <= 0x256C) {
// double struck
// TODO: figure out the pattern
let up = [0, 0, 0, 0, 0, 0, 1, 3, 3, 1, 3, 3, 1, 3, 3, 1, 3, 3, 0, 0, 0, 1, 3, 3, 1, 3, 3]
let left = [0, 0, 0, 3, 1, 3, 0, 0, 0, 3, 1, 3, 0, 0, 0, 3, 1, 3, 3, 1, 3, 3, 1, 3, 3, 1, 3]
let right = [3, 1, 3, 0, 0, 0, 3, 1, 3, 0, 0, 0, 3, 1, 3, 0, 0, 0, 3, 1, 3, 3, 1, 3, 3, 1, 3]
let down = [1, 3, 3, 1, 3, 3, 0, 0, 0, 0, 0, 0, 1, 3, 3, 1, 3, 3, 1, 3, 3, 0, 0, 0, 1, 3, 3]
let index = codePoint - 0x2552
this.drawBoxLines({
x,
y,
cellWidth,
cellHeight,
up: up[index],
left: left[index],
right: right[index],
down: down[index]
})
} else if (codePoint <= 0x2570) {
// arcs
let centerX = screenX + 0.5 * cellWidth
let centerY = screenY + 0.5 * cellHeight
let radius = Math.min(cellWidth, cellHeight) / 2
let endX = (codePoint - 0x256D) % 3 === 0 ? 1 : -1
let startY = (codePoint - 0x256D) < 2 ? 1 : -1
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(centerX, centerY + startY * cellHeight / 2)
ctx.arcTo(centerX, centerY, centerX + endX * radius, centerY, radius)
ctx.lineTo(centerX + endX * cellWidth / 2, centerY)
ctx.stroke()
} else if (codePoint <= 0x2573) {
// diagonals
ctx.lineWidth = 1
ctx.beginPath()
if (codePoint === 0x2571 || codePoint === 0x2573) {
// diagonal /
ctx.moveTo(screenX, screenY + cellHeight)
ctx.lineTo(screenX + cellWidth, screenY)
}
if (codePoint === 0x2572 || codePoint === 0x2573) {
// diagonal \
ctx.moveTo(screenX, screenY)
ctx.lineTo(screenX + cellWidth, screenY + cellHeight)
}
ctx.stroke()
} else if (codePoint <= 0x257B) {
// single lines
// 0: left, 1: up, 2: right, 3: down
let direction = (codePoint - 0x2574) % 4
let type = codePoint < 0x2578 ? 1 : 2
this.drawBoxLines({
x,
y,
cellWidth,
cellHeight,
left: direction === 0 ? type : 0,
up: direction === 1 ? type : 0,
right: direction === 2 ? type : 0,
down: direction === 3 ? type : 0
})
} else {
let index = codePoint - 0x257C
this.drawBoxLines({
x,
y,
cellWidth,
cellHeight,
left: index === 0 ? 1 : index === 2 ? 2 : 0,
up: index === 1 ? 1 : index === 3 ? 2 : 0,
right: index === 0 ? 2 : index === 2 ? 1 : 0,
down: index === 1 ? 2 : index === 3 ? 1 : 0
})
} }
} } else if (codePoint >= 0x2580 && codePoint <= 0x259F) {
let codePoint = text.codePointAt(0)
if (codePoint >= 0x2580 && codePoint <= 0x259F) {
// block elements // block elements
ctx.beginPath() ctx.beginPath()
const left = screenX const left = screenX
@ -449,18 +636,6 @@ module.exports = class CanvasRenderer extends EventEmitter {
ctx.fillText(text, screenX + 0.5 * cellWidth, screenY + 0.5 * cellHeight) ctx.fillText(text, screenX + 0.5 * cellWidth, screenY + 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) { if (underline || strike || overline) {
ctx.strokeStyle = this.getColor(fg) ctx.strokeStyle = this.getColor(fg)
ctx.lineWidth = 1 ctx.lineWidth = 1
@ -488,8 +663,6 @@ module.exports = class CanvasRenderer extends EventEmitter {
ctx.stroke() ctx.stroke()
} }
if (this.screenLines[y]) ctx.restore()
ctx.globalAlpha = 1 ctx.globalAlpha = 1
} }
@ -500,7 +673,7 @@ module.exports = class CanvasRenderer extends EventEmitter {
* @returns {number[]} an array of cell indices * @returns {number[]} an array of cell indices
*/ */
getAdjacentCells (cell, radius = 1) { getAdjacentCells (cell, radius = 1) {
const { width, height } = this const { width, height } = this.screen.window
const screenLength = width * height const screenLength = width * height
let cells = [] let cells = []
@ -526,7 +699,7 @@ module.exports = class CanvasRenderer extends EventEmitter {
height, height,
devicePixelRatio, devicePixelRatio,
statusScreen statusScreen
} = this } = this.screen.window
if (statusScreen) { if (statusScreen) {
// draw status screen instead // draw status screen instead
@ -535,15 +708,15 @@ module.exports = class CanvasRenderer extends EventEmitter {
return return
} else this.stopDrawLoop() } else this.stopDrawLoop()
const charSize = this.charSize const charSize = this.screen.getCharSize()
const { width: cellWidth, height: cellHeight } = this.cellSize const { width: cellWidth, height: cellHeight } = this.screen.getCellSize()
const screenLength = width * height const screenLength = width * height
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0) ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
if (this.debug && this._debug) this._debug.drawStart(why) if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawStart(why)
ctx.font = this.fonts[0] ctx.font = this.screen.getFont()
ctx.textAlign = 'center' ctx.textAlign = 'center'
ctx.textBaseline = 'middle' ctx.textBaseline = 'middle'
@ -560,33 +733,29 @@ module.exports = class CanvasRenderer extends EventEmitter {
let x = cell % width let x = cell % width
let y = Math.floor(cell / width) let y = Math.floor(cell / width)
let isCursor = this.cursorBlinkOn && let isCursor = this.cursorBlinkOn &&
this.cursor.x === x && this.screen.cursor.x === x &&
this.cursor.y === y && this.screen.cursor.y === y &&
this.cursor.visible this.screen.cursor.visible
let wasCursor = x === this.drawnCursor[0] && y === this.drawnCursor[1] let wasCursor = x === this.drawnCursor[0] && y === this.drawnCursor[1]
let text = this.screen[cell] let inSelection = this.screen.isInSelection(x, y)
let fg = this.screenFG[cell] | 0
let bg = this.screenBG[cell] | 0
let attrs = this.screenAttrs[cell] | 0
let inSelection = this.screenSelection[cell]
let isDefaultBG = false 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 & ATTR_FG)) fg = this.defaultFG if (!(attrs & ATTR_FG)) fg = this.defaultFgNum
if (!(attrs & ATTR_BG)) { if (!(attrs & ATTR_BG)) bg = this.defaultBgNum
bg = this.defaultBG
isDefaultBG = true
}
if (attrs & ATTR_INVERSE) [fg, bg] = [bg, fg] // swap - reversed character colors if (attrs & ATTR_INVERSE) [fg, bg] = [bg, fg] // swap - reversed character colors
if (this.reverseVideo) [fg, bg] = [bg, fg] // swap - reversed all screen if (this.screen.reverseVideo) [fg, bg] = [bg, fg] // swap - reversed all screen
if (attrs & ATTR_BLINK && !this.blinkStyleOn) { if (attrs & ATTR_BLINK && !this.blinkStyleOn) {
// blinking is enabled and blink style is off // blinking is enabled and blink style is off
// set text to nothing so drawCharacter only draws decoration // set text to nothing so drawCharacter doesn't draw anything
text = ' ' text = ''
} }
if (inSelection) { if (inSelection) {
@ -598,54 +767,29 @@ module.exports = class CanvasRenderer extends EventEmitter {
fg !== this.drawnScreenFG[cell] || // foreground updated, and this cell has text fg !== this.drawnScreenFG[cell] || // foreground updated, and this cell has text
bg !== this.drawnScreenBG[cell] || // background updated bg !== this.drawnScreenBG[cell] || // background updated
attrs !== this.drawnScreenAttrs[cell] || // attributes updated attrs !== this.drawnScreenAttrs[cell] || // attributes updated
this.screenLines[y] !== this.drawnScreenLines[y] || // line updated isCursor !== wasCursor || // cursor blink/position updated
// TODO: fix artifacts or keep this hack: (isCursor && this.screen.cursor.style !== this.drawnCursor[2]) || // cursor style updated
isCursor || wasCursor || // cursor blink/position updated (isCursor && this.screen.cursor.hanging !== this.drawnCursor[3]) // cursor hanging updated
(isCursor && this.cursor.style !== this.drawnCursor[2]) || // cursor style updated
(isCursor && this.cursor.hanging !== this.drawnCursor[3]) // cursor hanging updated
let font = attrs & FONT_MASK let font = attrs & FONT_MASK
if (!fontGroups.has(font)) fontGroups.set(font, []) if (!fontGroups.has(font)) fontGroups.set(font, [])
fontGroups.get(font).push({ cell, x, y, text, fg, bg, attrs, isCursor, inSelection, isDefaultBG }) fontGroups.get(font).push({ cell, x, y, text, fg, bg, attrs, isCursor, inSelection })
updateMap.set(cell, didUpdate) updateMap.set(cell, didUpdate)
} }
// set drawn screen lines
this.drawnScreenLines = this.screenLines.slice()
let debugFilledUpdates = []
if (this.graphics >= 1) {
// fancy graphics gets really slow when there's a lot of masks
// so here's an algorithm that fills in holes in the update map
for (let cell of updateMap.keys()) {
if (updateMap.get(cell)) continue
let previous = updateMap.get(cell - 1) || false
let next = updateMap.get(cell + 1) || false
if (previous && next) {
// set cell to true of horizontally adjacent updated
updateMap.set(cell, true)
if (this.debug && this._debug) debugFilledUpdates.push(cell)
}
}
}
// Map of (cell index) -> boolean, whether or not a cell should be redrawn // Map of (cell index) -> boolean, whether or not a cell should be redrawn
const redrawMap = new Map() const redrawMap = new Map()
const maskedCells = new Map()
let isTextWide = text => let isTextWide = text =>
text !== ' ' && ctx.measureText(text).width >= (cellWidth + 0.05) text !== ' ' && ctx.measureText(text).width >= (cellWidth + 0.05)
// decide for each cell if it should be redrawn // decide for each cell if it should be redrawn
for (let cell of updateMap.keys()) { let updateRedrawMapAt = cell => {
let shouldUpdate = updateMap.get(cell) || redrawMap.get(cell) || false let shouldUpdate = updateMap.get(cell) || redrawMap.get(cell) || false
// TODO: fonts (necessary?) // TODO: fonts (necessary?)
let text = this.screen[cell] let text = this.screen.screen[cell]
let isWideCell = isTextWide(text) let isWideCell = isTextWide(text)
let checkRadius = isWideCell ? 2 : 1 let checkRadius = isWideCell ? 2 : 1
@ -657,16 +801,8 @@ module.exports = class CanvasRenderer extends EventEmitter {
// update this cell if: // 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 (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 // - the adjacent cell updated and this cell or the adjacent cell is wide
// - this or the adjacent cell is not double-sized if (updateMap.get(adjacentCell) && (this.screen.window.graphics < 2 || isWideCell || isTextWide(this.screen.screen[adjacentCell]))) {
if (updateMap.get(adjacentCell) &&
(this.graphics < 2 || isWideCell || isTextWide(this.screen[adjacentCell])) &&
(!this.screenLines[Math.floor(cell / this.width)] && !this.screenLines[Math.floor(adjacentCell / this.width)])) {
adjacentDidUpdate = true adjacentDidUpdate = true
if (this.getAdjacentCells(cell, 1).includes(adjacentCell)) {
// this is within a radius of 1, therefore this cell should be included in the mask as well
maskedCells.set(cell, true)
}
break break
} }
} }
@ -674,117 +810,67 @@ module.exports = class CanvasRenderer extends EventEmitter {
if (adjacentDidUpdate) shouldUpdate = true if (adjacentDidUpdate) shouldUpdate = true
} }
if (updateMap.get(cell)) {
// this was updated, it should definitely be included in the mask
maskedCells.set(cell, true)
}
redrawMap.set(cell, shouldUpdate) redrawMap.set(cell, shouldUpdate)
} }
// mask to masked regions only for (let cell of updateMap.keys()) updateRedrawMapAt(cell)
if (this.graphics >= 1) {
// TODO: include padding in border cells
const padding = this.padding
let regions = []
// mask to redrawing regions only
if (this.screen.window.graphics >= 1) {
let debug = this.screen.window.debug && this.screen._debug
let padding = Math.round(this.screen._padding)
ctx.save()
ctx.beginPath()
for (let y = 0; y < height; y++) { for (let y = 0; y < height; y++) {
let regionStart = null let regionStart = null
for (let x = 0; x < width; x++) { for (let x = 0; x < width; x++) {
let cell = y * width + x let cell = y * width + x
let masked = maskedCells.get(cell) let redrawing = redrawMap.get(cell)
if (masked && regionStart === null) regionStart = x if (redrawing && regionStart === null) regionStart = x
if (!masked && regionStart !== null) { if (!redrawing && regionStart !== null) {
regions.push([regionStart, y, x, y + 1]) ctx.rect(padding + regionStart * cellWidth, padding + y * cellHeight, (x - regionStart) * cellWidth, cellHeight)
if (debug) this.screen._debug.clipRect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight)
regionStart = null regionStart = null
} }
} }
if (regionStart !== null) { if (regionStart !== null) {
regions.push([regionStart, y, width, y + 1]) ctx.rect(padding + regionStart * cellWidth, padding + y * cellHeight, (width - regionStart) * cellWidth, cellHeight)
if (debug) this.screen._debug.clipRect(regionStart * cellWidth, y * cellHeight, (width - regionStart) * cellWidth, cellHeight)
} }
} }
// join regions if possible (O(n^2-1), sorry)
let i = 0
while (i < regions.length) {
let region = regions[i]
let j = 0
while (j < regions.length) {
let other = regions[j]
if (other === region) {
j++
continue
}
if (other[0] === region[0] && other[2] === region[2] && other[3] === region[1]) {
region[1] = other[1]
regions.splice(j, 1)
if (i > j) i--
j--
}
j++
}
i++
}
ctx.save()
ctx.beginPath()
for (let region of regions) {
let [regionStart, y, endX, endY] = region
let rectX = padding + regionStart * cellWidth
let rectY = padding + y * cellHeight
let rectWidth = (endX - regionStart) * cellWidth
let rectHeight = (endY - y) * cellHeight
// compensate for padding
if (regionStart === 0) {
rectX -= padding
rectWidth += padding
}
if (y === 0) {
rectY -= padding
rectHeight += padding
}
if (endX === width - 1) rectWidth += padding
if (y === height - 1) rectHeight += padding
ctx.rect(rectX, rectY, rectWidth, rectHeight)
}
ctx.clip() ctx.clip()
} }
// pass 1: backgrounds // pass 1: backgrounds
for (let font of fontGroups.keys()) { for (let font of fontGroups.keys()) {
for (let data of fontGroups.get(font)) { for (let data of fontGroups.get(font)) {
let { cell, x, y, text, bg, isDefaultBG } = data let { cell, x, y, text, bg } = data
if (redrawMap.get(cell)) { if (redrawMap.get(cell)) {
this.drawBackground({ x, y, cellWidth, cellHeight, bg, isDefaultBG }) this.drawBackground({ x, y, cellWidth, cellHeight, bg })
if (this.debug) { if (this.screen.window.debug && this.screen._debug) {
// set cell flags // set cell flags
let flags = (+redrawMap.get(cell)) let flags = (+redrawMap.get(cell))
flags |= (+updateMap.get(cell)) << 1 flags |= (+updateMap.get(cell)) << 1
flags |= (+maskedCells.get(cell)) << 2 flags |= (+isTextWide(text)) << 2
flags |= (+isTextWide(text)) << 3 this.screen._debug.setCell(cell, flags)
flags |= (+debugFilledUpdates.includes(cell)) << 4
this._debug.setCell(cell, flags)
} }
} }
} }
} }
// reset drawn cursor // reset drawn cursor
this.drawnCursor = [-1, -1, '', false] this.drawnCursor = [-1, -1, -1]
// pass 2: characters // pass 2: characters
for (let font of fontGroups.keys()) { for (let font of fontGroups.keys()) {
// set font once because in Firefox, this is a really slow action for some // set font once because in Firefox, this is a really slow action for some
// reason // reason
let fontIndex = 0 let modifiers = {}
if (font & ATTR_BOLD) fontIndex |= 1 if (font & ATTR_BOLD) modifiers.weight = 'bold'
if (font & ATTR_ITALIC) fontIndex |= 2 if (font & ATTR_ITALIC) modifiers.style = 'italic'
ctx.font = this.fonts[fontIndex] ctx.font = this.screen.getFont(modifiers)
for (let data of fontGroups.get(font)) { for (let data of fontGroups.get(font)) {
let { cell, x, y, text, fg, bg, attrs, isCursor, inSelection } = data let { cell, x, y, text, fg, bg, attrs, isCursor, inSelection } = data
@ -799,7 +885,7 @@ module.exports = class CanvasRenderer extends EventEmitter {
this.drawnScreenBG[cell] = bg this.drawnScreenBG[cell] = bg
this.drawnScreenAttrs[cell] = attrs this.drawnScreenAttrs[cell] = attrs
if (isCursor) this.drawnCursor = [x, y, this.cursor.style, this.cursor.hanging] if (isCursor) this.drawnCursor = [x, y, this.screen.cursor.style, this.screen.cursor.hanging]
// draw cursor // draw cursor
if (isCursor && !inSelection) { if (isCursor && !inSelection) {
@ -808,30 +894,25 @@ module.exports = class CanvasRenderer extends EventEmitter {
let cursorX = x let cursorX = x
let cursorY = y let cursorY = y
let cursorWidth = cellWidth // JS doesn't allow same-name assignment
if (this.cursor.hanging) { if (this.screen.cursor.hanging) {
// draw hanging cursor in the margin // draw hanging cursor in the margin
cursorX += 1 cursorX += 1
} }
// double-width lines let screenX = cursorX * cellWidth + this.screen._padding
if (this.screenLines[cursorY] & 0b001) cursorWidth *= 2 let screenY = cursorY * cellHeight + this.screen._padding
if (this.screen.cursor.style === 'block') {
let screenX = cursorX * cursorWidth + this.padding
let screenY = cursorY * cellHeight + this.padding
if (this.cursor.style === 'block') {
// block // block
ctx.rect(screenX, screenY, cursorWidth, cellHeight) ctx.rect(screenX, screenY, cellWidth, cellHeight)
} else if (this.cursor.style === 'bar') { } else if (this.screen.cursor.style === 'bar') {
// vertical bar // vertical bar
let barWidth = 2 let barWidth = 2
ctx.rect(screenX, screenY, barWidth, cellHeight) ctx.rect(screenX, screenY, barWidth, cellHeight)
} else if (this.cursor.style === 'line') { } else if (this.screen.cursor.style === 'line') {
// underline // underline
let lineHeight = 2 let lineHeight = 2
ctx.rect(screenX, screenY + charSize.height, cursorWidth, lineHeight) ctx.rect(screenX, screenY + charSize.height, cellWidth, lineHeight)
} }
ctx.clip() ctx.clip()
@ -851,29 +932,35 @@ module.exports = class CanvasRenderer extends EventEmitter {
} }
} }
if (this.graphics >= 1) ctx.restore() if (this.screen.window.graphics >= 1) ctx.restore()
if (this.debug && this._debug) this._debug.drawEnd() if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawEnd()
this.emit('draw', why) this.screen.emit('draw', why)
} }
drawStatus (statusScreen) { drawStatus (statusScreen) {
const { ctx, width, height, devicePixelRatio } = this const ctx = this.ctx
const {
fontFamily,
width,
height,
devicePixelRatio
} = this.screen.window
// reset drawnScreen to force redraw when statusScreen is disabled // reset drawnScreen to force redraw when statusScreen is disabled
this.drawnScreen = [] this.drawnScreen = []
const cellSize = this.cellSize const cellSize = this.screen.getCellSize()
const screenWidth = width * cellSize.width + 2 * this.padding const screenWidth = width * cellSize.width + 2 * this.screen._padding
const screenHeight = height * cellSize.height + 2 * this.padding const screenHeight = height * cellSize.height + 2 * this.screen._padding
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0) ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
ctx.fillStyle = this.getColor(this.defaultBG) ctx.fillStyle = this.getColor(this.defaultBgNum)
ctx.fillRect(0, 0, screenWidth, screenHeight) ctx.fillRect(0, 0, screenWidth, screenHeight)
ctx.font = `24px ${this.statusFont}` ctx.font = `24px ${fontFamily}`
ctx.fillStyle = this.getColor(this.defaultFG) ctx.fillStyle = this.getColor(this.defaultFgNum)
ctx.textAlign = 'center' ctx.textAlign = 'center'
ctx.textBaseline = 'middle' ctx.textBaseline = 'middle'
ctx.fillText(statusScreen.title || '', screenWidth / 2, screenHeight / 2 - 50) ctx.fillText(statusScreen.title || '', screenWidth / 2, screenHeight / 2 - 50)
@ -883,7 +970,7 @@ module.exports = class CanvasRenderer extends EventEmitter {
ctx.save() ctx.save()
ctx.translate(screenWidth / 2, screenHeight / 2 + 20) ctx.translate(screenWidth / 2, screenHeight / 2 + 20)
ctx.strokeStyle = this.getColor(this.defaultFG) ctx.strokeStyle = this.getColor(this.defaultFgNum)
ctx.lineWidth = 5 ctx.lineWidth = 5
ctx.lineCap = 'round' ctx.lineCap = 'round'

@ -18,7 +18,7 @@ module.exports = function (screen, input) {
let updateInputPosition = function () { let updateInputPosition = function () {
if (!keyboardOpen) return if (!keyboardOpen) return
let [x, y] = screen.layout.gridToScreen(screen.cursor.x, screen.cursor.y, true) let [x, y] = screen.gridToScreen(screen.cursor.x, screen.cursor.y, true)
keyInput.style.transform = `translate(${x}px, ${y}px)` keyInput.style.transform = `translate(${x}px, ${y}px)`
} }

@ -73,7 +73,7 @@ exports.buildColorTable = function () {
if (colorTable256 !== null) return colorTable256 if (colorTable256 !== null) return colorTable256
// 256color lookup table // 256color lookup table
// should not be used to look up 0-15 // should not be used to look up 0-15 (will return transparent)
colorTable256 = new Array(16).fill('#000000') colorTable256 = new Array(16).fill('#000000')
// fill color table // fill color table
@ -113,39 +113,13 @@ exports.themePreview = function (themeN) {
}) })
} }
exports.colorTable256 = null
exports.ensureColorTable256 = function () {
if (!exports.colorTable256) exports.colorTable256 = exports.buildColorTable()
}
exports.getColor = function (i, palette = []) {
// return palette color if it exists
if (i < 16 && i in palette) return palette[i]
// -1 for selection foreground, -2 for selection background
if (i === -1) return exports.SELECTION_FG
if (i === -2) return exports.SELECTION_BG
// 256 color
if (i > 15 && i < 256) {
exports.ensureColorTable256()
return exports.colorTable256[i]
}
// 24-bit color, encoded as (hex) + 256 (such that #000000 == 256)
if (i > 255) {
i -= 256
return '#' + `000000${i.toString(16)}`.substr(-6)
}
// return error color
return Math.floor(Date.now() / 1000) % 2 === 0 ? '#ff0ff' : '#00ff00'
}
exports.toHex = function (shade, themeN) { exports.toHex = function (shade, themeN) {
if (/^\d+$/.test(shade)) { if (/^\d+$/.test(shade)) {
shade = +shade shade = +shade
return exports.getColor(shade, themes[themeN]) if (shade < 16) shade = themes[themeN][shade]
else {
shade = exports.buildColorTable()[shade]
}
} }
return shade return shade
} }

@ -9,7 +9,9 @@ function selectedTheme () {
exports.init = function () { exports.init = function () {
$('#theme').on('change', showColor) $('#theme').on('change', showColor)
$('#default_fg,#default_bg').on('input', showColor)
$('#default_fg').on('input', showColor)
$('#default_bg').on('input', showColor)
let opts = { let opts = {
padding: 10, padding: 10,
@ -25,9 +27,6 @@ exports.init = function () {
ColorTriangle.initInput(qs('#default_fg'), opts) ColorTriangle.initInput(qs('#default_fg'), opts)
ColorTriangle.initInput(qs('#default_bg'), opts) ColorTriangle.initInput(qs('#default_bg'), opts)
for (let i = 1; i <= 5; i++) {
ColorTriangle.initInput(qs(`#bc${i}`), opts)
}
$('.colorprev.bg span').on('click', function () { $('.colorprev.bg span').on('click', function () {
const bg = this.dataset.bg const bg = this.dataset.bg

@ -2,8 +2,6 @@ const $ = require('./lib/chibi')
const { mk } = require('./utils') const { mk } = require('./utils')
const tr = require('./lang') const tr = require('./lang')
const HTTPS = window.location.protocol.match(/s:/)
{ {
const w = window.WiFi = {} const w = window.WiFi = {}
@ -133,7 +131,7 @@ const HTTPS = window.location.protocol.match(/s:/)
if (window._demo) { if (window._demo) {
onScan(window._demo_aps, 200) onScan(window._demo_aps, 200)
} else { } else {
$.get(`${HTTPS ? 'https' : 'http'}://${window._root}/cfg/wifi/scan`, onScan) $.get('http://' + window._root + '/cfg/wifi/scan', onScan)
} }
} }

@ -1,14 +0,0 @@
#! /usr/bin/env php
<?php
require_once __DIR__ . '/../base.php';
$selected = array_slice($argv, 1);
$output = [];
foreach ($selected as $key) {
$output[$key] = tr($key);
}
fwrite(STDOUT, json_encode($output, JSON_UNESCAPED_UNICODE));

@ -1,54 +0,0 @@
/*
* This is a Webpack loader that loads the language data by running
* dump_selected.php.
*/
const { spawnSync } = require('child_process')
const path = require('path')
const selectedKeys = require('./js-keys')
module.exports = function (source) {
let child = spawnSync(path.resolve(__dirname, '_js-dump.php'), selectedKeys, {
timeout: 1000
})
let data
try {
data = JSON.parse(child.stdout.toString().trim())
} catch (err) {
console.error(`\x1b[31;1m[lang-loader] Failed to parse JSON:`)
console.error(child.stdout.toString().trim())
console.error(`\x1b[m`)
if (err) throw err
}
// adapted from webpack/loader-utils
let remainingRequest = this.remainingRequest
if (!remainingRequest) {
remainingRequest = this.loaders.slice(this.loaderIndex + 1)
.map(obj => obj.request)
.concat([this.resource]).join('!')
}
let currentRequest = this.currentRequest
if (!currentRequest) {
remainingRequest = this.loaders.slice(this.loaderIndex)
.map(obj => obj.request)
.concat([this.resource]).join('!')
}
let map = {
version: 3,
file: currentRequest,
sourceRoot: '',
sources: [remainingRequest],
sourcesContent: [source],
names: [],
mappings: 'AAAA;AAAA'
}
this.callback(null,
`/* Generated language file */\n` +
`module.exports=${JSON.stringify(data)}\n`, map)
}

@ -16,6 +16,4 @@ return [
'menu.restore_hard' => '', 'menu.restore_hard' => '',
'menu.reset_screen' => '', 'menu.reset_screen' => '',
'menu.index' => '', 'menu.index' => '',
'menu.ini_export' => '',
'menu.ini_import' => '',
]; ];

@ -4,7 +4,7 @@ return [
'menu.cfg_wifi' => 'Nastavení WiFi', 'menu.cfg_wifi' => 'Nastavení WiFi',
'menu.cfg_network' => 'Nastavení sítě', 'menu.cfg_network' => 'Nastavení sítě',
'menu.cfg_term' => 'Nastavení terminalu', 'menu.cfg_term' => 'Nastavení terminalu',
'menu.about' => 'O programu', 'menu.about' => 'About',
'menu.help' => 'Nápověda', 'menu.help' => 'Nápověda',
'menu.term' => 'Zpět k terminálu', 'menu.term' => 'Zpět k terminálu',
'menu.cfg_system' => 'Nastavení systému', 'menu.cfg_system' => 'Nastavení systému',
@ -59,17 +59,17 @@ return [
'term.term_title' => 'Nadpis', 'term.term_title' => 'Nadpis',
'term.term_width' => 'Šířka', 'term.term_width' => 'Šířka',
'term.term_height' => 'Výška', 'term.term_height' => 'Výška',
'term.buttons' => 'Text tlačítek', 'term.buttons' => 'Text tlačítke',
'term.theme' => 'Barevná paleta', 'term.theme' => 'Barevná paleta',
'term.cursor_shape' => 'Styl kurzoru', 'term.cursor_shape' => 'Styl kurzoru',
'term.parser_tout_ms' => 'Timeout parseru', 'term.parser_tout_ms' => 'Timeout parseru',
'term.display_tout_ms' => 'Prodleva překreslení', 'term.display_tout_ms' => 'Prodleva překreslení',
'term.display_cooldown_ms' => 'Min. čas překreslení', 'term.display_cooldown_ms' => 'Min. čas překreslení',
'term.allow_decopt_12' => 'Povolit \e?12h/l', 'term.allow_decopt_12' => 'Povolit \e?12h/l',
'term.fn_alt_mode' => 'SS3 Fn klávesy', 'term.fn_alt_mode' => 'SS3 Fx klávesy',
'term.show_config_links' => 'Menu pod obrazovkou', 'term.show_config_links' => 'Menu pod obrazovkou',
'term.show_buttons' => 'Zobrazit tlačítka', 'term.show_buttons' => 'Zobrazit tlačítka',
'term.loopback' => 'Lokální echo (<span style="text-decoration:overline">SRM</span>)', 'term.loopback' => 'Loopback (<span style="text-decoration:overline">SRM</span>)',
'term.crlf_mode' => 'Enter = CR+LF (LNM)', 'term.crlf_mode' => 'Enter = CR+LF (LNM)',
'term.want_all_fn' => 'Zachytávat F5, F11, F12', 'term.want_all_fn' => 'Zachytávat F5, F11, F12',
'term.button_msgs' => 'Reporty tlačítek<br>(dek. ASCII CSV)', 'term.button_msgs' => 'Reporty tlačítek<br>(dek. ASCII CSV)',
@ -78,13 +78,8 @@ return [
'term.color_fg_prev' => 'Barva textu', 'term.color_fg_prev' => 'Barva textu',
'term.color_bg_prev' => 'Barva pozadí', 'term.color_bg_prev' => 'Barva pozadí',
'term.colors_preview' => '', 'term.colors_preview' => '',
'term.debugbar' => 'Rozšířené ladění', // 'term.debugbar' => 'Ladění ',
'term.ascii_debug' => 'Ladění vstupních dat', // 'term.ascii_debug' => 'Použít debug parser',
'term.backdrop' => 'URL obrázku na pozadí',
'term.button_count' => 'Počet tlačítek',
'term.button_colors' => 'Barvy tlačítek',
'term.font_stack' => 'Font',
'term.font_size' => 'Velikost písma',
'cursor.block_blink' => 'Blok, blikající', 'cursor.block_blink' => 'Blok, blikající',
'cursor.block_steady' => 'Blok, stálý', 'cursor.block_steady' => 'Blok, stálý',
@ -235,11 +230,6 @@ return [
'persist.restore_hard_explain' => 'persist.restore_hard_explain' =>
'(Tímto vymažete nastavení WiFi! Záloha a systémové heslo zůstanou beze změny.)', '(Tímto vymažete nastavení WiFi! Záloha a systémové heslo zůstanou beze změny.)',
'backup.title' => 'Záloha do souboru',
'backup.explain' => 'Všechna nastavení kromě systémového hesla je možné uložit do a obnovit z INI souboru.',
'backup.export' => 'Zálohovat do souboru',
'backup.import' => 'Nahrát soubor!',
// UART settings form // UART settings form
'uart.title' => 'Sériový port', 'uart.title' => 'Sériový port',
@ -265,20 +255,10 @@ return [
obrazovky a stránky se budou načítat rychleji. Nevýhodou je vyšší spotřeba a citlivost k~rušení. obrazovky a stránky se budou načítat rychleji. Nevýhodou je vyšší spotřeba a citlivost k~rušení.
', ',
'hwtuning.overclock' => 'Přetaktovat na 160~MHz', 'hwtuning.overclock' => 'Přetaktovat na 160~MHz',
'gpio2_config' => 'Funkce GPIO2',
'gpio4_config' => 'Funkce GPIO4',
'gpio5_config' => 'Funkce GPIO5',
'gpio_config.off' => 'Vypnuto',
'gpio_config.off_2' => 'Debug UART Tx',
'gpio_config.out_initial0' => 'Výstup (výchozí stav 0)',
'gpio_config.out_initial1' => 'Výstup (výchozí stav 1)',
'gpio_config.in_pull' => 'Vstup (s pull-upem)',
'gpio_config.in_nopull' => 'Vstup (plovoucí)',
// Generic button / dialog labels // Generic button / dialog labels
'apply' => 'Použít!', 'apply' => 'Uložit!',
'start' => 'Start', 'start' => 'Start',
'cancel' => 'Zrušit', 'cancel' => 'Zrušit',
'enabled' => 'Zapnuto', 'enabled' => 'Zapnuto',

@ -79,11 +79,6 @@ return [
'term.colors_preview' => '', 'term.colors_preview' => '',
'term.debugbar' => 'Debug-Leiste anzeigen', 'term.debugbar' => 'Debug-Leiste anzeigen',
'term.ascii_debug' => 'Kontrollcodes anzeigen', 'term.ascii_debug' => 'Kontrollcodes anzeigen',
'term.backdrop' => 'Hintergrundbild-URL',
'term.button_count' => 'Tastenanzahl',
'term.button_colors' => 'Tastenfarben',
'term.font_stack' => 'Schriftstapel',
'term.font_size' => 'Schriftgröße',
'cursor.block_blink' => 'Block, blinkend', 'cursor.block_blink' => 'Block, blinkend',
'cursor.block_steady' => 'Block, ruhig', 'cursor.block_steady' => 'Block, ruhig',
@ -184,7 +179,7 @@ return [
'pwlock.title' => 'Zugriffsbeschränkungen', 'pwlock.title' => 'Zugriffsbeschränkungen',
'pwlock.explain' => ' 'pwlock.explain' => '
Manche, oder alle Teile des Web-Interface können mit einem Passwort geschützt werden. Manche, oder alle Teile des Web-Interface können mit einem Passwort geschützt werden.
Lass die Passwortfelder leer wenn du es nicht verändern möchtest.<br> Lass die Passwortfelder leer wenn du es sie verändern möchtest.<br>
Das voreingestellte Passwort ist "%def_access_pw%".', Das voreingestellte Passwort ist "%def_access_pw%".',
'pwlock.region' => 'Geschützte Seiten', 'pwlock.region' => 'Geschützte Seiten',
'pwlock.region.none' => 'Keine, alles offen', 'pwlock.region.none' => 'Keine, alles offen',
@ -233,14 +228,9 @@ return [
(Dies löscht die WLAN-Konfiguration! Beeinflusst die gespeicherten Voreinstellungen (Dies löscht die WLAN-Konfiguration! Beeinflusst die gespeicherten Voreinstellungen
oder das Systempasswort nicht.)', oder das Systempasswort nicht.)',
'backup.title' => 'Konfigurationsdatei sichern',
'backup.explain' => 'Die ganze Konfiguration außer dem Systempasswort können mit einer INI-Datei gesichert und wiederhergestellt werden.',
'backup.export' => 'Datei exportieren',
'backup.import' => 'Importieren!',
// UART settings form // UART settings form
'uart.title' => 'Serieller Port Parameter', 'uart.title' => 'Serienportparameter',
'uart.explain' => ' 'uart.explain' => '
Dies steuert den Kommunikations-UART. Der Debug-UART ist auf 115.200 baud fest Dies steuert den Kommunikations-UART. Der Debug-UART ist auf 115.200 baud fest
eingestellt mit einem Stop-Bit und keiner Parität. eingestellt mit einem Stop-Bit und keiner Parität.
@ -259,22 +249,11 @@ return [
'hwtuning.title' => 'Hardware-Tuning', 'hwtuning.title' => 'Hardware-Tuning',
'hwtuning.explain' => ' 'hwtuning.explain' => '
Der ESP8266 kann von 80&nbsp;MHz auf 160&nbsp;MHz übertaktet werden. ESP8266 kann übertaktet werden von 80&nbsp;MHz auf 160&nbsp;MHz.
Alles wird etwas schneller sein, aber mit höherem Stromverbrauch, Alles wird etwas schneller sein, aber mit höherem Stromverbrauch,
und eventuell auch mit mehr Interferenz. und eventuell auch mit höherer Interferenz. Mit Sorgfalt benutzen.
Mit Sorgfalt benutzen.
', ',
'hwtuning.overclock' => 'Auf 160MHz übertakten', 'hwtuning.overclock' => 'Übertakten',
'gpio2_config' => 'GPIO2 Funktion',
'gpio4_config' => 'GPIO4 Funktion',
'gpio5_config' => 'GPIO5 Funktion',
'gpio_config.off' => 'Deaktiviert',
'gpio_config.off_2' => 'UART Tx Debuggen',
'gpio_config.out_initial0' => 'Output (Anfangslevel 0)',
'gpio_config.out_initial1' => 'Output (Anfangslevel 1)',
'gpio_config.in_pull' => 'Input (pull-up)',
'gpio_config.in_nopull' => 'Input (floating)',
// Generic button / dialog labels // Generic button / dialog labels

@ -79,11 +79,6 @@ return [
'term.colors_preview' => '', 'term.colors_preview' => '',
'term.debugbar' => 'Debug internal state', 'term.debugbar' => 'Debug internal state',
'term.ascii_debug' => 'Display control codes', 'term.ascii_debug' => 'Display control codes',
'term.backdrop' => 'Background image URL',
'term.button_count' => 'Button count',
'term.button_colors' => 'Button colors',
'term.font_stack' => 'Font stack',
'term.font_size' => 'Font size',
'cursor.block_blink' => 'Block, blinking', 'cursor.block_blink' => 'Block, blinking',
'cursor.block_steady' => 'Block, steady', 'cursor.block_steady' => 'Block, steady',
@ -233,11 +228,6 @@ return [
'persist.restore_hard_explain' => 'persist.restore_hard_explain' =>
'(This clears the WiFi config! Does not affect saved defaults or admin password.)', '(This clears the WiFi config! Does not affect saved defaults or admin password.)',
'backup.title' => 'Back-up Config File',
'backup.explain' => 'All config except the admin password can be backed up and restored using an INI file.',
'backup.export' => 'Export to file',
'backup.import' => 'Import!',
// UART settings form // UART settings form
'uart.title' => 'Serial Port Parameters', 'uart.title' => 'Serial Port Parameters',
@ -266,16 +256,6 @@ return [
', ',
'hwtuning.overclock' => 'Overclock to 160MHz', 'hwtuning.overclock' => 'Overclock to 160MHz',
'gpio2_config' => 'GPIO2 function',
'gpio4_config' => 'GPIO4 function',
'gpio5_config' => 'GPIO5 function',
'gpio_config.off' => 'Disabled',
'gpio_config.off_2' => 'Debug UART Tx',
'gpio_config.out_initial0' => 'Output (initial 0)',
'gpio_config.out_initial1' => 'Output (initial 1)',
'gpio_config.in_pull' => 'Input (pull-up)',
'gpio_config.in_nopull' => 'Input (floating)',
// Generic button / dialog labels // Generic button / dialog labels
'apply' => 'Apply!', 'apply' => 'Apply!',

@ -1,292 +0,0 @@
<?php
return [
'menu.cfg_wifi' => 'WiFi Beállítások',
'menu.cfg_network' => 'Hálózati beállítások',
'menu.cfg_term' => 'Terminál beállítások',
'menu.about' => 'Az ESPTerm-ről',
'menu.help' => 'Gyors referencia',
'menu.term' => 'Vissza a terminálba',
'menu.cfg_system' => 'Rendszer beállítások',
'menu.cfg_wifi_conn' => 'Csatlakozás a hálózathoz',
'menu.settings' => 'Beállítások',
// Terminal page
'title.term' => 'Terminál', // page title of the terminal page
'term_nav.fullscreen' => 'Teljesképernyő',
'term_nav.config' => 'Beállítás',
'term_nav.wifi' => 'WiFi',
'term_nav.help' => 'Segítség',
'term_nav.about' => 'Info',
'term_nav.paste' => 'Beillesztés',
'term_nav.upload' => 'Feltöltés',
'term_nav.keybd' => 'Billentyűzet',
'term_nav.paste_prompt' => 'Szöveg beillesztése és küldése:',
'term_conn.connecting' => 'Csatlakozás',
'term_conn.waiting_content' => 'Várakozás a csatlakozásra',
'term_conn.disconnected' => 'Kapcsolat bontva',
'term_conn.waiting_server' => 'Várakozás a kiszolgálóra',
'term_conn.reconnecting' => 'Újracsatlakozás',
// Terminal settings page
'term.defaults' => 'Alap beállítások',
'term.expert' => 'Haladó beállítások',
'term.explain_initials' => '
Ezek az alap beállítások amik az ESPTerm bekapcsolása után,
vagy amikor képernyő reset parancsa érkezikd (<code>\ec</code>).
Ezek megváltoztathatóak egy terminál alkalmzás és escape szekveciák segítségével.
',
'term.explain_expert' => '
Ezek haladó beállítási opciók amiket általában nem kell megváltoztatni.
Csak akkor változtass rajta ha tudod mit csinálsz!',
'term.example' => 'Alapértelmezet színek előnézete',
'term.explain_scheme' => '
Az alapértelmezett szöveg és háttér szín kiválasztásához kattints a
paletta előnézet gombra. Alternatíva: használd a 0-15 számokat a téma színekhez,
16-255 számokat a normál színekhez és hexa (#FFFFFF) a True Color (24-bit) színekhez.
',
'term.fgbg_presets' => 'Alapértelmezett beállítások',
'term.color_scheme' => 'Szín séma',
'term.reset_screen' => 'A képernyő olvasó alapállapotba állítása',
'term.term_title' => 'Fejléc szöveg',
'term.term_width' => 'Szélesség',
'term.term_height' => 'Magasség',
'term.buttons' => 'Gomb cimkék',
'term.theme' => 'Szín paletta',
'term.cursor_shape' => 'Kurzor stílus',
'term.parser_tout_ms' => 'Olvasó időtúllépés',
'term.display_tout_ms' => 'Újrarajzolás késleltetése',
'term.display_cooldown_ms' => 'Újrarajzolás cooldown',
'term.allow_decopt_12' => '\e?12h/l engedélyezés',
'term.fn_alt_mode' => 'SS3 Fn gombok',
'term.show_config_links' => 'Navigációs linkek mutatása',
'term.show_buttons' => 'Gombok mutatása',
'term.loopback' => 'Helyi visszajelzés (<span style="text-decoration:overline">SRM</span>)',
'term.crlf_mode' => 'Enter = CR+LF (LNM)',
'term.want_all_fn' => 'F5, F11, F12 elfogása',
'term.button_msgs' => 'Gomb kódok<br>(ASCII, dec, CSV)',
'term.color_fg' => 'Alap előtér.',
'term.color_bg' => 'Alap háttér',
'term.color_fg_prev' => 'Előtér',
'term.color_bg_prev' => 'Háttér',
'term.colors_preview' => '',
'term.debugbar' => 'Belső állapot hibakeresés',
'term.ascii_debug' => 'Kontroll kódok mutatása',
'term.backdrop' => 'Háttérkép URL.je',
'term.button_count' => 'Gomb szám',
'term.button_colors' => 'Gomb színek',
'term.font_stack' => 'Betű típus',
'term.font_size' => 'Betű méret',
'cursor.block_blink' => 'Blokk, villog',
'cursor.block_steady' => 'Blokk, fix',
'cursor.underline_blink' => 'Aláhúzás, villog',
'cursor.underline_steady' => 'Aláhúzás, fix',
'cursor.bar_blink' => 'I, villog',
'cursor.bar_steady' => 'I, fix',
// Text upload dialog
'upload.title' => 'Szöveg feltöltése',
'upload.prompt' => 'Szöveg fájl betöltése:',
'upload.endings' => 'Sor vége:',
'upload.endings.cr' => 'CR (Enter gomb)',
'upload.endings.crlf' => 'CR LF (Windows)',
'upload.endings.lf' => 'LF (Linux)',
'upload.chunk_delay' => 'Chunk késleltetés (ms):',
'upload.chunk_size' => 'Chunk méret (0=line):',
'upload.progress' => 'Feltöltés:',
// Network config page
'net.explain_sta' => '
Kapcsold ki a dinamikus IP címet a statikus cím beállításához.',
'net.explain_ap' => '
Ezek a beállítások a beépített DHCP szervet és az AP módot befolyásolják.',
'net.ap_dhcp_time' => 'Lízing idő',
'net.ap_dhcp_start' => 'Kezdő IP cím',
'net.ap_dhcp_end' => 'Záró IP cím',
'net.ap_addr_ip' => 'Saját IP cím',
'net.ap_addr_mask' => 'Hálózati maszk',
'net.sta_dhcp_enable' => 'Dinamikus IP cím használata',
'net.sta_addr_ip' => 'ESPTerm statikus IP címe',
'net.sta_addr_mask' => 'Hálózati maszk',
'net.sta_addr_gw' => 'Útválasztó IP címe',
'net.ap' => 'DHCP Szerver (AP)',
'net.sta' => 'DHCP Kliens (Station)',
'net.sta_mac' => 'Állomás MAC címe',
'net.ap_mac' => 'AP MAC címe',
'net.details' => 'MAC címek',
// Wifi config page
'wifi.ap' => 'Beépített Access Point',
'wifi.sta' => 'Kapcsolódás létező hálózathoz',
'wifi.enable' => 'Engedélyezve',
'wifi.tpw' => 'Adás teljesítmény',
'wifi.ap_channel' => 'Csatorna',
'wifi.ap_ssid' => 'AP SSID',
'wifi.ap_password' => 'Jelszó',
'wifi.ap_hidden' => 'SSID rejtése',
'wifi.sta_info' => 'Kiválasztott',
'wifi.not_conn' => 'Nincs csatlkoztatva.',
'wifi.sta_none' => 'Egyiksem',
'wifi.sta_active_pw' => '🔒 Jelszó elmentve',
'wifi.sta_active_nopw' => '🔓 Szabad hozzáférés',
'wifi.connected_ip_is' => 'Csatlakozva, az IP cím ',
'wifi.sta_password' => 'Jelszó:',
'wifi.scanning' => 'Keresés',
'wifi.scan_now' => 'Kattints a keresés indításához!',
'wifi.cant_scan_no_sta' => 'Kattints a kliens mód engedélyezéséhez és a keresés indításához!',
'wifi.select_ssid' => 'Elérhető hálózatok:',
'wifi.enter_passwd' => 'Jelszó a(z) ":ssid:" hálózathoz',
'wifi.sta_explain' => 'A hálózat kiválasztása után nyomdj meg az Alkamaz gombot a csatlakozáshoz.',
// Wifi connecting status page
'wificonn.status' => 'Státusz:',
'wificonn.back_to_config' => 'Vissza a WiFi beállításhoz',
'wificonn.telemetry_lost' => 'Telemetria megszakadt; valami hiba történt, vagy az eszközöd elvesztette a kapcsolatot.',
'wificonn.explain_android_sucks' => '
Ha okostelefonon kapcsolódsz az ESPTerm-hez, vagy amikor csatlakozol
egy másik hálózatról, az eszközöd elveszítheti a kapcsolatot és
ez az indikátor nem fog működni. Kérlek várj egy keveset (~ 15 másodpercet),
és ellenőrizd, hogy a kapcsolat helyrejött-e.',
'wificonn.explain_reset' => '
Az beépített AP engedélyezéséhez tarts lenyomva a BOOT gombot amíg a kék led
villogni nem kezd. Tartsd addig lenyomva amíg a led el nem kezd gyorsan villogni
a gyári alapállapot visszaállításához".',
'wificonn.disabled' =>"Station mode letiltva.",
'wificonn.idle' =>"Alapállapot, nincs csatlakozva és nincs IP címe.",
'wificonn.success' => "Csatlakozva! Kaptam IP címet",
'wificonn.working' => "Csatlakozás a beállított AP-hez",
'wificonn.fail' => "Csatlakozás nem sikerült, ellenőrizd a beállítások és próbáld újra. A hibaok: ",
// Access restrictions form
'pwlock.title' => 'Hozzáférés korlátozása',
'pwlock.explain' => '
A web interfész néhany része vagy a teljes interfész jelszavas védelemmel látható el.
Hagyd a jelszó mezőt üresen ha nem akarod megváltoztatni.<br>
Az alapértelmezett jelszó "%def_access_pw%".
',
'pwlock.region' => 'Védett oldalak',
'pwlock.region.none' => 'Egyiksem, minden hozzáférhető',
'pwlock.region.settings_noterm' => 'WiFi, Hálózat és Rendszer beállítások',
'pwlock.region.settings' => 'Minden beállítás oldal',
'pwlock.region.menus' => 'Ez a teljes menű rész',
'pwlock.region.all' => 'Minden, még a terminál is',
'pwlock.new_access_pw' => 'Új jelszó',
'pwlock.new_access_pw2' => 'Jelszó ismét',
'pwlock.admin_pw' => 'Admin jelszó',
'pwlock.access_name' => 'Felhasználó név',
// Setting admin password
'adminpw.title' => 'Admin jelszó megváltoztatása',
'adminpw.explain' =>
'
Az "admin jelszo" a tárolt alap beállítások módosításához és a hozzáférések
változtatásához kell. Ez a jelszó nincs a többi beállítással egy helyre mentve,
tehát a mentés és visszaállítás műveletek nem befolyásolják.
Ha az admin jelszó elveszik akkor a legegyszerűbb módja a hozzáférés
visszaszerzésére a chip újraflashselésere.<br>
Az alap jelszó: "%def_admin_pw%".
',
'adminpw.new_admin_pw' => 'Új admin jelszó',
'adminpw.new_admin_pw2' => 'Jelszó ismét',
'adminpw.old_admin_pw' => 'Régi admin jelszó',
// Persist form
'persist.title' => 'Mentés & Visszaállítás',
'persist.explain' => '
ESPTerm az összes beállítást Flash-be menti. Az aktív beállítások at lehet másolni
a "alapértelmezett" területre és az később a lenti kék gombbal visszaállítható.
',
'persist.confirm_restore' => 'Minden beállítást visszaállítasz az "alap" értékre?',
'persist.confirm_restore_hard' =>
'Visszaállítod a rendszer alap beállításait? Ez minden aktív ' .
'beállítást törölni fog és AP módban az alap SSID-vel for újraindulni.',
'persist.confirm_store_defaults' =>
'Add meg az admin jelszót az alapállapotba állítás megerősítéshez.',
'persist.password' => 'Admin jelszó:',
'persist.restore_defaults' => 'Mentett beállítások visszaállítása',
'persist.write_defaults' => 'Aktív beállítások mentése alapértelmezetnek',
'persist.restore_hard' => 'Gyári alapbeállítások betöltése',
'persist.restore_hard_explain' =>
'(Ez törli a Wifi beállításokat, de nincs hatása az admin jelszóra.)',
'backup.title' => 'Configurációs fájl biztonsági másolat készítés',
'backup.explain' => 'Minden beállítás menthető és visszaállítható az admin jelszó kivételévelAll config except the admin password can be backed up and restored using egy .INI fájllal.',
'backup.export' => 'Fáljbe exportálás',
'backup.import' => 'Importálás!',
// UART settings form
'uart.title' => 'Soros port paraméterek',
'uart.explain' => '
Ez a beállítás szabályozza a kommunikációs UART-ot. A hibakereső UART fix
115.200 baud-val, egy stop-bittel és paritás bit nélkül működik.
',
'uart.baud' => 'Baud rate',
'uart.parity' => 'Parity',
'uart.parity.none' => 'Egyiksem',
'uart.parity.odd' => 'Páratlan',
'uart.parity.even' => 'Páros',
'uart.stop_bits' => 'Stop-bit',
'uart.stop_bits.one' => 'Egy',
'uart.stop_bits.one_and_half' => 'Másfél',
'uart.stop_bits.two' => 'Kettő',
// HW tuning form
'hwtuning.title' => 'Hardware Tuning',
'hwtuning.explain' => '
ESP8266-t órajelét lehetséges 80&nbsp;MHz-ről 160&nbsp;MHz-re emelni. Ettől
jobb válaszidők és gyakoribb képernyő frissítések várhatóak, viszont megnövekszik
az energia felhasználás. Az interferencia esélye is megnő.
Ovatosan használd!.
',
'hwtuning.overclock' => 'Órajel emelése 160MHz-re',
'gpio2_config' => 'GPIO2 function', // TODO translate
'gpio4_config' => 'GPIO4 function',
'gpio5_config' => 'GPIO5 function',
'gpio_config.off' => 'Disabled',
'gpio_config.off_2' => 'Debug UART Tx',
'gpio_config.out_initial0' => 'Output (initial 0)',
'gpio_config.out_initial1' => 'Output (initial 1)',
'gpio_config.in_pull' => 'Input (pull-up)',
'gpio_config.in_nopull' => 'Input (floating)',
// Generic button / dialog labels
'apply' => 'Alkalmaz',
'start' => 'Start',
'cancel' => 'Mégse',
'enabled' => 'Engedélyezve',
'disabled' => 'Letiltva',
'yes' => 'Igen',
'no' => 'Nem',
'confirm' => 'OK',
'copy' => 'Másolás',
'form_errors' => 'Validációs hiba:',
];

@ -1,12 +0,0 @@
// define language keys used by JS here
module.exports = [
'wifi.connected_ip_is',
'wifi.not_conn',
'wifi.enter_passwd',
'term_nav.fullscreen',
'term_conn.connecting',
'term_conn.waiting_content',
'term_conn.disconnected',
'term_conn.waiting_server',
'term_conn.reconnecting'
]

@ -8,14 +8,12 @@
"babel-loader": "^7.1.2", "babel-loader": "^7.1.2",
"babel-preset-env": "^1.6.0", "babel-preset-env": "^1.6.0",
"babel-preset-minify": "^0.2.0", "babel-preset-minify": "^0.2.0",
"html-minifier": "^3.5.5",
"node-sass": "^4.5.3", "node-sass": "^4.5.3",
"standard": "^10.0.3", "standard": "^10.0.3",
"webpack": "^3.6.0" "webpack": "^3.6.0"
}, },
"scripts": { "scripts": {
"webpack": "webpack --display-modules $@", "webpack": "webpack --display-modules $@",
"sass": "node-sass $@", "sass": "node-sass $@"
"html-minifier": "html-minifier $@"
} }
} }

@ -33,6 +33,5 @@ if (strpos($_GET['BODYCLASS'], 'cfg') !== false) {
</div> </div>
<div class="Box message hidden"></div> <div class="Box message hidden"></div>
<div class="Box errmessage hidden"></div>
<?php endif; ?> <?php endif; ?>

@ -10,8 +10,8 @@
</p> </p>
<p> <p>
Vyvinuto na <a href="http://measure.feld.cvut.cz/" target="blank">Katedře měření, FEL ČVUT</a><br> <a href="http://measure.feld.cvut.cz/" target="blank">Katedra měření, FEL ČVUT</a><br>
Developed at the Department of Measurement, FEE CTU in Prague Department of Measurement, FEE CTU
</p> </p>
</div> </div>

@ -8,7 +8,7 @@
<div class="Row buttons2"> <div class="Row buttons2">
<a class="button icn-restore" <a class="button icn-restore"
onclick="return confirm('<?= e(tr('persist.confirm_restore')) ?>');" onclick="return confirm('<?= tr('persist.confirm_restore') ?>');"
href="<?= e(url('restore_defaults')) ?>"> href="<?= e(url('restore_defaults')) ?>">
<?= tr('persist.restore_defaults') ?> <?= tr('persist.restore_defaults') ?>
</a> </a>
@ -19,7 +19,7 @@
</div> </div>
<div class="Row buttons2"> <div class="Row buttons2">
<a onclick="return confirm('<?= e(tr('persist.confirm_restore_hard')) ?>');" <a onclick="return confirm('<?= tr('persist.confirm_restore_hard') ?>');"
href="<?= e(url('restore_hard')) ?>"> href="<?= e(url('restore_hard')) ?>">
<?= tr('persist.restore_hard') ?> <?= tr('persist.restore_hard') ?>
</a><br> </a><br>
@ -27,29 +27,6 @@
</div> </div>
</div> </div>
<!-- Backup -->
<div class="Box str mobcol">
<h2 tabindex=0><?= tr('backup.title') ?></h2>
<div class="Row explain nomargintop">
<?= tr('backup.explain') ?>
</div>
<div class="Row buttons2">
<a class="button"
href="<?= e(url('ini_export')) ?>">
<?= tr('backup.export') ?>
</a>
</div>
<div class="Row buttons2">
<form method="POST" action="<?= e(url('ini_import')) ?>" enctype='multipart/form-data'>
<span class="filewrap"><input accept=".ini,text/plain" type="file" name="file"></span><!--
--><input type="submit" value="<?= tr('backup.import') ?>">
</form>
</div>
</div>
<!-- Overclock --> <!-- Overclock -->
<form class="Box str mobcol" action="<?= e(url('system_set')) ?>" method="GET" id="form-hw"> <form class="Box str mobcol" action="<?= e(url('system_set')) ?>" method="GET" id="form-hw">
<h2 tabindex=0><?= tr('hwtuning.title') ?></h2> <h2 tabindex=0><?= tr('hwtuning.title') ?></h2>
@ -63,39 +40,6 @@
--><span class="box" tabindex=0 role=checkbox></span> --><span class="box" tabindex=0 role=checkbox></span>
<input type="hidden" id="overclock" name="overclock" value="%overclock%"> <input type="hidden" id="overclock" name="overclock" value="%overclock%">
</div> </div>
<div class="Row">
<label for="gpio2_conf"><?= tr("gpio2_config") ?></label>
<select name="gpio2_conf" id="gpio2_conf">
<option value="0"><?= tr("gpio_config.off_2") ?></option>
<option value="1"><?= tr("gpio_config.out_initial0") ?></option>
<option value="2"><?= tr("gpio_config.out_initial1") ?></option>
<option value="3"><?= tr("gpio_config.in_pull") ?></option>
<option value="4"><?= tr("gpio_config.in_nopull") ?></option>
</select>
</div>
<div class="Row">
<label for="gpio4_conf"><?= tr("gpio4_config") ?></label>
<select name="gpio4_conf" id="gpio4_conf">
<option value="0"><?= tr("gpio_config.off") ?></option>
<option value="1"><?= tr("gpio_config.out_initial0") ?></option>
<option value="2"><?= tr("gpio_config.out_initial1") ?></option>
<option value="3"><?= tr("gpio_config.in_pull") ?></option>
<option value="4"><?= tr("gpio_config.in_nopull") ?></option>
</select>
</div>
<div class="Row">
<label for="gpio5_conf"><?= tr("gpio5_config") ?></label>
<select name="gpio5_conf" id="gpio5_conf">
<option value="0"><?= tr("gpio_config.off") ?></option>
<option value="1"><?= tr("gpio_config.out_initial0") ?></option>
<option value="2"><?= tr("gpio_config.out_initial1") ?></option>
<option value="3"><?= tr("gpio_config.in_pull") ?></option>
<option value="4"><?= tr("gpio_config.in_nopull") ?></option>
</select>
</div>
<div class="Row buttons"> <div class="Row buttons">
<a class="button icn-ok" href="#" onclick="qs('#form-hw').submit()"><?= tr('apply') ?></a> <a class="button icn-ok" href="#" onclick="qs('#form-hw').submit()"><?= tr('apply') ?></a>
@ -187,7 +131,4 @@ $NOFILL = 'readonly onfocus="this.removeAttribute(\'readonly\')" style="cursor:t
} }
$('#pwlock').val(%pwlock%); $('#pwlock').val(%pwlock%);
$('#gpio2_conf').val(%gpio2_conf%);
$('#gpio4_conf').val(%gpio4_conf%);
$('#gpio5_conf').val(%gpio5_conf%);
</script> </script>

@ -134,18 +134,18 @@
<div class="Row"> <div class="Row">
<div class="SubRow"> <div class="SubRow">
<label for="width"><?= tr('term.term_width') ?></label> <label for="term_width"><?= tr('term.term_width') ?></label>
<input type="number" step=1 min=1 max=255 name="width" id="width" value="%width%" required> <input type="number" step=1 min=1 max=255 name="term_width" id="term_width" value="%term_width%" required>
</div> </div>
<div class="SubRow"> <div class="SubRow">
<label for="height"><?= tr('term.term_height') ?></label> <label for="term_height"><?= tr('term.term_height') ?></label>
<input type="number" step=1 min=1 max=255 name="height" id="height" value="%height%" required> <input type="number" step=1 min=1 max=255 name="term_height" id="term_height" value="%term_height%" required>
</div> </div>
</div> </div>
<div class="Row"> <div class="Row">
<label for="title"><?= tr('term.term_title') ?></label> <label for="term_title"><?= tr('term.term_title') ?></label>
<input type="text" name="title" id="title" value="%h:title%" required> <input type="text" name="term_title" id="term_title" value="%h:term_title%" required>
</div> </div>
<div class="Row checkbox" > <div class="Row checkbox" >
@ -154,11 +154,6 @@
<input type="hidden" id="show_buttons" name="show_buttons" value="%show_buttons%"> <input type="hidden" id="show_buttons" name="show_buttons" value="%show_buttons%">
</div> </div>
<div class="Row">
<label for="button_count"><?= tr('term.button_count') ?></label>
<input type="number" step=1 min=0 max=5 name="button_count" id="button_count" value="%h:button_count%" required>
</div>
<div class="Row"> <div class="Row">
<label><?= tr("term.buttons") ?></label> <label><?= tr("term.buttons") ?></label>
<input class="tiny" type="text" name="btn1" id="btn1" value="%h:btn1%"> <input class="tiny" type="text" name="btn1" id="btn1" value="%h:btn1%">
@ -177,20 +172,6 @@
<input class="tiny" type="text" name="bm5" id="bm5" value="%h:bm5%"> <input class="tiny" type="text" name="bm5" id="bm5" value="%h:bm5%">
</div> </div>
<div class="Row">
<label><?= tr("term.button_colors") ?></label>
<input class="tiny" type="text" name="bc1" id="bc1" value="%h:bc1%">
<input class="tiny" type="text" name="bc2" id="bc2" value="%h:bc2%">
<input class="tiny" type="text" name="bc3" id="bc3" value="%h:bc3%">
<input class="tiny" type="text" name="bc4" id="bc4" value="%h:bc4%">
<input class="tiny" type="text" name="bc5" id="bc5" value="%h:bc5%">
</div>
<div class="Row">
<label for="backdrop"><?= tr('term.backdrop') ?></label>
<input type="text" name="backdrop" id="backdrop" value="%h:backdrop%" required>
</div>
<div class="Row checkbox" > <div class="Row checkbox" >
<label><?= tr('term.crlf_mode') ?></label><!-- <label><?= tr('term.crlf_mode') ?></label><!--
--><span class="box" tabindex=0 role=checkbox></span> --><span class="box" tabindex=0 role=checkbox></span>
@ -217,8 +198,8 @@
</div> </div>
<div class="Row"> <div class="Row">
<label for="uart_baudrate"><?= tr('uart.baud') ?><span class="mq-phone">&nbsp;(bps)</span></label> <label for="uart_baud"><?= tr('uart.baud') ?><span class="mq-phone">&nbsp;(bps)</span></label>
<select name="uart_baudrate" id="uart_baudrate" class="short"> <select name="uart_baud" id="uart_baud" class="short">
<?php <?php
foreach([ foreach([
300, 600, 1200, 2400, 4800, 9600, 19200, 38400, 300, 600, 1200, 2400, 4800, 9600, 19200, 38400,
@ -271,17 +252,6 @@
<?= tr('term.explain_expert') ?> <?= tr('term.explain_expert') ?>
</div> </div>
<div class="Row">
<label for="font_stack"><?= tr('term.font_stack') ?></label>
<input type="text" name="font_stack" id="font_stack" value="%h:font_stack%" required>
</div>
<div class="Row">
<label for="font_size"><?= tr('term.font_size') ?><span class="mq-phone">&nbsp;(px)</span></label>
<input type="number" step=1 min=0 name="font_size" id="font_size" value="%font_size%" required>
<span class="mq-no-phone">&nbsp;px</span>
</div>
<div class="Row"> <div class="Row">
<label for="parser_tout_ms"><?= tr('term.parser_tout_ms') ?><span class="mq-phone">&nbsp;(ms)</span></label> <label for="parser_tout_ms"><?= tr('term.parser_tout_ms') ?><span class="mq-phone">&nbsp;(ms)</span></label>
<input type="number" step=1 min=0 name="parser_tout_ms" id="parser_tout_ms" value="%parser_tout_ms%" required> <input type="number" step=1 min=0 name="parser_tout_ms" id="parser_tout_ms" value="%parser_tout_ms%" required>
@ -312,6 +282,7 @@
<input type="hidden" id="ascii_debug" name="ascii_debug" value="%ascii_debug%"> <input type="hidden" id="ascii_debug" name="ascii_debug" value="%ascii_debug%">
</div> </div>
<div class="Row checkbox" > <div class="Row checkbox" >
<label><?= tr('term.fn_alt_mode') ?></label><!-- <label><?= tr('term.fn_alt_mode') ?></label><!--
--><span class="box" tabindex=0 role=checkbox></span> --><span class="box" tabindex=0 role=checkbox></span>
@ -345,7 +316,7 @@
$.ready(function () { $.ready(function () {
$('#cursor_shape').val('%cursor_shape%'); $('#cursor_shape').val('%cursor_shape%');
$('#theme').val('%theme%'); $('#theme').val('%theme%');
$('#uart_baudrate').val('%uart_baudrate%'); $('#uart_baud').val('%uart_baud%');
$('#uart_parity').val('%uart_parity%'); $('#uart_parity').val('%uart_parity%');
$('#uart_stopbits').val('%uart_stopbits%'); $('#uart_stopbits').val('%uart_stopbits%');

@ -18,7 +18,6 @@
<?php require __DIR__ . "/help/cmd_screen.php"; ?> <?php require __DIR__ . "/help/cmd_screen.php"; ?>
<?php require __DIR__ . "/help/cmd_d2d.php"; ?> <?php require __DIR__ . "/help/cmd_d2d.php"; ?>
<?php require __DIR__ . "/help/cmd_system.php"; ?> <?php require __DIR__ . "/help/cmd_system.php"; ?>
<?php require __DIR__ . "/help/iocontrol.php"; ?>
<script> <script>
function hpfold(yes) { function hpfold(yes) {

@ -21,8 +21,6 @@
<li>`A` - UK ASCII: # replaced with £</li> <li>`A` - UK ASCII: # replaced with £</li>
<li>`0` - Symbols and basic line drawing (standard DEC alternate character set)</li> <li>`0` - Symbols and basic line drawing (standard DEC alternate character set)</li>
<li>`1` - Symbols and advanced line drawing (based on DOS codepage 437, ESPTerm specific)</li> <li>`1` - Symbols and advanced line drawing (based on DOS codepage 437, ESPTerm specific)</li>
<li>`2` - Block characters and thick line drawing (ESPTerm specific)</li>
<li>`3` - Extra line drawing (ESPTerm specific)</li>
</ul> </ul>
<p> <p>
@ -30,30 +28,16 @@
stays unchanged. stays unchanged.
</p> </p>
<script>
function bchst(start, str) {
var ar = str.split(' ');
for(var i=0;i<ar.length;i++) {
var a = String.fromCharCode(start+i);
var r = ar[i];
document.write('<div'+(r===a?' class="none"':'')+'><span>'+(start+i)+'</span><span>'+$.htmlEscape(a)+'</span><span>'+$.htmlEscape(r)+'</span></div>');
}
}
</script>
<?php <?php
$codepages = load_esp_charsets(); $codepages = load_esp_charsets();
foreach($codepages as $name => $cp) { foreach($codepages as $name => $cp) {
echo "<h4>Codepage `$name`</h4>\n"; echo "<h4>Codepage `$name`</h4>\n";
echo '<div class="charset">'; echo '<div class="charset">';
foreach($cp as $point) {
$t = implode("\x01", $cp['chars']); $dis = $point[1]==$point[2]?' class="none"' : '';
$t = json_encode($t, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); echo "<div$dis><span>$point[0]</span><span>$point[1]</span><span>$point[2]</span></div>";
$t = str_replace('\u0001', " ", $t); // space is never included }
$t = htmlspecialchars($t,ENT_HTML5); echo '</div>';
echo '<script>bchst('.$cp['start'].','.$t.')';
echo '</script></div>';
} }
?> ?>

@ -10,7 +10,7 @@
<p> <p>
Networking commands use the format `\e^...\a`, a Privacy Message (PM). Networking commands use the format `\e^...\a`, a Privacy Message (PM).
PM is similar to OSC, which uses `]` in place of `^`. The PM payload (text between `\e^` and `\a`) PM is similar to OSC, which uses `]` in place of `^`. The PM payload (text between `\e^` and `\a`)
must be shorter than 256 bytes, and should not contain any control characters (ASCII &lt; 32). must be shorter than 256 bytes, and should not contain any control characters (ASCII < 32).
</p> </p>
<h3>Device-to-device Messaging</h3> <h3>Device-to-device Messaging</h3>
@ -57,8 +57,6 @@
<li>`H` - get response headers <li>`H` - get response headers
<li>`B` - get response body <li>`B` - get response body
<li>`X` - ignore the response, return nothing <li>`X` - ignore the response, return nothing
<li>`N=<i>nonce</i>` - a custom string that will be added in the options field of the response message.
Use this to keep track of which request a response belongs to.
<li>`T=<i>ms</i>` - request timeout (default 5000~ms), in milliseconds <li>`T=<i>ms</i>` - request timeout (default 5000~ms), in milliseconds
<li>`L=<i>bytes</i>` - limit response length (default 0 = don't limit). Applies to the head, body, or both combined, depending on the `H` and `B` flags <li>`L=<i>bytes</i>` - limit response length (default 0 = don't limit). Applies to the head, body, or both combined, depending on the `H` and `B` flags
<li>`l=<i>bytes</i>` - limit the response buffer size (default 5000~B). <li>`l=<i>bytes</i>` - limit the response buffer size (default 5000~B).
@ -77,7 +75,7 @@
<ul> <ul>
<li>`_status_` - a HTTP status code, eg. 200 is OK, 404 Not found. <li>`_status_` - a HTTP status code, eg. 200 is OK, 404 Not found.
<li>`_options_` - similar to those in the request, here describing the response data. <li>`_options_` - similar to those in the request, here describing the response data.
This field can contain comma-separated `B`, `H` and `L=<i>bytes</i>` and `N=<i>nonce</i>`. This field can contain comma-separated `B`, `H` and `L=<i>bytes</i>`.
<li>`_response_` - the response, as requested. If both headers and body are received, <li>`_response_` - the response, as requested. If both headers and body are received,
they will be separated by an empty line (i.e. `\r\n\r\n`). Response can be up to several they will be separated by an empty line (i.e. `\r\n\r\n`). Response can be up to several
kilobytes long, depending on the `L=` and `l=` options. kilobytes long, depending on the `L=` and `l=` options.

@ -8,124 +8,60 @@
If an argument is left out, it's treated as 0 or 1, depending on what makes sense for the command. If an argument is left out, it's treated as 0 or 1, depending on what makes sense for the command.
</p> </p>
<h3>Erasing &amp; Inserting</h3>
<table class="ansiref w100"> <table class="ansiref w100">
<thead><tr><th>Code</th><th>Meaning</th></tr></thead> <thead><tr><th>Code</th><th>Meaning</th></tr></thead>
<tbody> <tbody>
<tr> <tr>
<td>`\e[<i>m</i>J`</td>
<td> <td>
Clear part of screen. _m_: 0 - from cursor, 1 - to cursor, 2 - all `\e[<i>m</i>J`
</td> </td>
</tr>
<tr>
<td>`\e[<i>m</i>K`</td>
<td> <td>
Erase part of line. _m_: 0 - from cursor, 1 - to cursor, 2 - all Clear part of screen. _m_: 0 - from cursor, 1 - to cursor, 2 - all
</td>
</tr>
<tr>
<td>`\e[<i>n</i>X`</td>
<td>
Erase _n_ characters in line.
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code>
\e[<i>n</i>L \\
\e[<i>n</i>M
</code></td>
<td> <td>
Insert (`L`) or delete (`M`) _n_ lines. Following lines are pulled up or pushed down. `\e[<i>m</i>K`
</td> </td>
</tr>
<tr>
<td><code>
\e[<i>n</i>@ \\
\e[<i>n</i>P
</code></td>
<td> <td>
Insert (`@`) or delete (`P`) _n_ characters. The rest of the line is pulled left or pushed right. Erase part of line. _m_: 0 - from cursor, 1 - to cursor, 2 - all
Characters going past the end of line are lost.
</td> </td>
</tr> </tr>
</tbody>
</table>
<h3>Supersized lines</h3>
<table class="ansiref w100">
<thead><tr><th>Code</th><th>Meaning</th></tr></thead>
<tbody>
<tr> <tr>
<td>`\e#1`, `\e#2`</td>
<td> <td>
Make the current line part of a double-height line. `\e[<i>n</i>X`</td>
Use `1` for the top, `2` for the bottom half.
</td>
</tr>
<tr>
<td>`\e#3`, `\e#4`</td>
<td> <td>
Make the current line part of a double-width, double-height line. Erase _n_ characters in line.
Use `3` for the top, `4` for the bottom half.
</td> </td>
</tr> </tr>
<tr> <tr>
<td>`\e#6`</td>
<td> <td>
Make the current line double-width. `\e[<i>n</i>b`</td>
</td>
</tr>
<tr>
<td>`\e#5`</td>
<td> <td>
Reset the current line to normal size. Repeat last printed characters _n_ times (moving cursor and using the current style).
</td> </td>
</tr> </tr>
</tbody>
</table>
<h3>Other</h3>
<table class="ansiref w100">
<thead><tr><th>Code</th><th>Meaning</th></tr></thead>
<tbody>
<tr> <tr>
<td>`\ec`</td>
<td> <td>
Clear screen, reset attributes and cursor. This command also restores the default <code>
screen size, title, button labels and messages and the background URL. \e[<i>n</i>L \\
\e[<i>n</i>M
</code>
</td> </td>
</tr>
<tr>
<td><code>
\e[?1049h \\
\e[?1049l
</code></td>
<td> <td>
Switch to (`h`) or from (`l`) an alternate screen. Insert (`L`) or delete (`M`) _n_ lines. Following lines are pulled up or pushed down.
ESPTerm can't implement this fully, so the original screen content is not saved,
but it will remember the cursor, screen size, terminal title, button labels and messages.
</td> </td>
</tr> </tr>
<tr>
<td>`\e[8;<i>r</i>;<i>c</i>t`</td>
<td>Set screen size to _r_ rows and _c_ columns (this is a command borrowed from Xterm)</td>
</tr>
<tr> <tr>
<td> <td>
`\e[<i>n</i>b`</td> <code>
<td> \e[<i>n</i>@ \\
Repeat last printed characters _n_ times (moving cursor and using the current style). \e[<i>n</i>P
</code>
</td> </td>
</tr>
<tr>
<td>`\e#8`</td>
<td> <td>
Reset all screen attributes to default and fill the screen with the letter "E". This was Insert (`@`) or delete (`P`) _n_ characters. The rest of the line is pulled left or pushed right.
historically used for aligning CRT displays, now can be useful e.g. for testing erasing commands. Characters going past the end of line are lost.
</td> </td>
</tr> </tr>
</tbody> </tbody>

@ -8,27 +8,19 @@
Those changes are not retained after restart. Those changes are not retained after restart.
</p> </p>
<h3>Single-byte commands &amp; queries</h3>
<table class="ansiref w100"> <table class="ansiref w100">
<thead><tr><th>Code</th><th>Meaning</th></tr></thead> <thead><tr><th>Code</th><th>Meaning</th></tr></thead>
<tbody> <tbody>
<tr> <tr>
<td>_CAN_ (24)</td> <td>`\ec`</td>
<td> <td>
This ASCII code is sent by ESPTerm when it becomes ready to receive commands. Clear screen, reset attributes and cursor. This command also restores the default
When this code is received on the UART, it means ESPTerm has restarted and is ready. screen size, title, button labels and messages.
Use this to detect spontaneous restarts which require a full screen repaint.
As a control character sent to ESPTerm, CAN aborts any currently received commands
and clears the parser.
</td> </td>
</tr> </tr>
<tr> <tr>
<td>_ENQ_ (5)</td> <td>`\e[8;<i>r</i>;<i>c</i>t`</td>
<td> <td>Set screen size to _r_ rows and _c_ columns (this is a command borrowed from Xterm)</td>
ESPTerm responds to this control characters with an "answerback message".
This message contains the curretn version, unique ID, and the IP address if in Client mode.
</td>
</tr> </tr>
<tr> <tr>
<td>`\e[5n`</td> <td>`\e[5n`</td>
@ -37,14 +29,14 @@
Can be used to check if the terminal has booted up and is ready to receive commands. Can be used to check if the terminal has booted up and is ready to receive commands.
</td> </td>
</tr> </tr>
</tbody> <tr>
</table> <td>_CAN_ (24)</td>
<td>
<h3>Setting parameters</h3> This ASCII code is not a command, but is sent by ESPTerm when it becomes ready to receive commands.
When this code is received on the UART, it means ESPTerm has restarted and is ready. Use this to detect
<table class="ansiref w100"> spontaneous restarts which require a full screen repaint.
<thead><tr><th>Code</th><th>Meaning</th></tr></thead> </td>
<tbody> </tr>
<tr> <tr>
<td>`\e[<i>n</i> q`</td> <td>`\e[<i>n</i> q`</td>
<td> <td>
@ -59,75 +51,81 @@
<td>Set screen title to _t_ (this is a standard OSC command)</td> <td>Set screen title to _t_ (this is a standard OSC command)</td>
</tr> </tr>
<tr> <tr>
<td>`\e]27;1;<i>u</i>\a`</td>
<td> <td>
Set background image to URL _u_ (including protocol) <code>
that can be resolved by the user's browser. The image will be scaled \e]<i>8x</i>;<i>t</i>\a
to fit the screen, preserving aspect ratio. A certain border must be added </code>
to account for the screen margins. Use empty string to disable the image feature.
Note that this *won't work for users connected to the built-in AP*.
</td> </td>
</tr>
<tr>
<td>`\e]27;2;<i>n</i>\a`</td>
<td> <td>
Set number of visible buttons to _n_ (0-5). To hide/show the entire buttons bar, Set label for button 1-5 (code 81-85) to _t_ - e.g.`\e]81;Yes\a`
use the dedicated hiding commands (see below) sets the first button text to "Yes".
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code>
\e]28;<i>x</i>;<i>t</i>\a
</code></td>
<td> <td>
Set label for button _x_ (1-5) to _t_ - e.g.`\e]28;1;Yes\a` <code>
sets the first button text to "Yes". \e]<i>9x</i>;<i>m</i>\a
</code>
</td>
<td>
Set message for button 1-5 (code 91-95) to _m_ - e.g.`\e]94;*\a`
sets the 3rd button to send "*" when pressed. The message can be up to
10 bytes long.
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code>
\e]29;<i>x</i>;<i>m</i>\a
</code></td>
<td> <td>
Set message for button _x_ (1-5) to _m_ - e.g.`\e]29;3;+\a` <code>
sets the 3rd button to send "+" when pressed. The message can be up to \e]9;<i>t</i>\a
10 bytes long. </code>
</td>
<td>
Show a notification with text _t_. This will be either a desktop notification
or a pop-up balloon.
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code>
\e]30;<i>x</i>;<i>c</i>\a
</code></td>
<td> <td>
Set button _x_ (1-5) color to _c_ - e.g.`\e]30;2;#00FF00\a` <code>
makes the 2nd button green. Supported are SGR colors 1-255 \e[?<i>n</i>s \\
and TrueColor in the format `#RRGGBB`. Use 0 to \e[?<i>n</i>r
reset to the default color. </code>
</td>
<td>
Save (`s`) and restore (`r`) any option set using `CSI ? <i>n</i> h`.
This is used by some applications to back up the original state before
making changes.
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code> <td>
\e[?800h \\ <code>
\e[?800l \e[?800h \\
</code></td> \e[?800l
</code>
</td>
<td> <td>
Show (`h`) or hide (`l`) the action buttons (the blue buttons under the screen). Show (`h`) or hide (`l`) the action buttons (the blue buttons under the screen).
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code> <td>
\e[?801h \\ <code>
\e[?801l \e[?801h \\
</code></td> \e[?801l
</code>
</td>
<td> <td>
Show (`h`) or hide (`l`) menu/help links under the screen. Show (`h`) or hide (`l`) menu/help links under the screen.
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code> <td>
\e[?2004h \\ <code>
\e[?2004l \e[?2004h \\
</code></td> \e[?2004l
</code>
</td>
<td> <td>
Enable (`h`) or disable (`l`) Bracketed Paste mode. Enable (`h`) or disable (`l`) Bracketed Paste mode.
This mode makes any text sent using the Upload Tool be preceded by `\e[200\~` This mode makes any text sent using the Upload Tool be preceded by `\e[200\~`
@ -136,41 +134,28 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code>
\e[12h \\
\e[12l
</code></td>
<td> <td>
Enable (`h`) or disable (`l`) Send-Receive Mode (SRM). <code>
SRM is the opposite of Local Echo, meaning `\e[12h` disables and `\e[12l` enables Local Echo. \e[?1049h \\
\e[?1049l
</code>
</td> </td>
</tr>
</tbody>
</table>
<h3>Other</h3>
<table class="ansiref w100">
<thead><tr><th>Code</th><th>Meaning</th></tr></thead>
<tbody>
<tr>
<td><code>
\e]9;<i>t</i>\a
</code></td>
<td> <td>
Show a notification with text _t_. This will be either a desktop notification Switch to (`h`) or from (`l`) an alternate screen.
or a pop-up balloon. ESPTerm can't implement this fully, so the original screen content is not saved,
but it will remember the cursor, screen size, terminal title, button labels and messages.
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code>
\e[?<i>n</i>s \\
\e[?<i>n</i>r
</code></td>
<td> <td>
Save (`s`) and restore (`r`) any option set using `CSI ? <i>n</i> h`. <code>
This is used by some applications to back up the original state before \e[12h \\
making changes. \e[12l
</code>
</td>
<td>
Enable (`h`) or disable (`l`) Send-Receive Mode (SRM).
SRM is the opposite of Local Echo, meaning `\e[12h` disables and `\e[12l` enables Local Echo.
</td> </td>
</tr> </tr>
</tbody> </tbody>

@ -1,39 +0,0 @@
<div class="Box fold">
<h2>Remote GPIO Control</h2>
<div class="Row v">
<p>
ESPTerm provides a simple API to remotely control and read GPIO pins GPIO2, GPIO4, and GPIO5.
The main use of this API is to remotely reset a device that communicates with ESPTerm
through the UART.
</p>
<p>
GPIO2 is normally used for debug UART, so when used as GPIO, debug logging is disabled. You
can configure the pin functions in <a href="<?= url('cfg_system') ?>">System Settings</a>.
</p>
<p>
The GPIO control endpoint is `/api/v1/gpio`, with optional GET arguments:
</p>
<ul>
<li>`do2=<i>x</i>` - set GPIO2 level. <i>x</i> can be `0`, `1`, or `t` to toggle the pin.
<li>`do4=<i>x</i>` - set GPIO4 level
<li>`do5=<i>x</i>` - set GPIO5 level
<li>`pulse=<i>ms</i>` - the command starts a pulse. After the given amount of time
(milliseconds) has elapsed, the pins are set to the opposite levels than what was specified
(in the case of toggle, the original pin state)
</ul>
<p>
A quick example: <a href="/api/v1/gpio?do4=1&amp;pulse=500">`/api/v1/gpio?do4=1&amp;pulse=500`</a>
sends a 500ms long positive pulse on GPIO4.
</p>
<p>
The GPIO endpoint always returns a JSON object like this: `{"io2":0,"io4":1,"io5":0}`, showing
the current input levels. Input reading works always, regardless of the GPIO settings.
</p>
</div>
</div>

@ -16,10 +16,6 @@
can be selected in <a href="<?= url('cfg_term') ?>">Terminal Settings</a>. can be selected in <a href="<?= url('cfg_term') ?>">Terminal Settings</a>.
</p> </p>
<p>
Background image can be set using `\e]70;<i>url</i>\a` (see section System Functions).
</p>
<h3>Foreground colors</h3> <h3>Foreground colors</h3>
<div class="colorprev"> <div class="colorprev">

@ -44,7 +44,7 @@
</div> </div>
</div> </div>
<h1 id="screen-title">ESPTerm</h1> <h1 id="screen-title"><!-- JS, title --></h1>
<a href="#" id="term-fit-screen" class="mq-tablet-max"><i id="resize-button-icon" class="icn-resize-small"></i></a> <a href="#" id="term-fit-screen" class="mq-tablet-max"><i id="resize-button-icon" class="icn-resize-small"></i></a>
<div id="term-wrap"> <div id="term-wrap">
@ -59,7 +59,7 @@
<div class="screen-margin bottom"></div> <div class="screen-margin bottom"></div>
</div> </div>
<div id="action-buttons" class="hidden"><!-- JS, buttons --></div> <div id="action-buttons"><!-- JS, buttons --></div>
</div> </div>
<nav id="term-nav"> <nav id="term-nav">

@ -22,11 +22,6 @@ form { @include naked(); }
} }
} }
.Box.errmessage {
@extend .Box.message;
color: crimson;
}
.Row { .Row {
vertical-align: middle; vertical-align: middle;
margin: 12px auto; margin: 12px auto;
@ -115,14 +110,6 @@ form { @include naked(); }
border-top: 2px solid rgba(255, 255, 255, 0.1); border-top: 2px solid rgba(255, 255, 255, 0.1);
} }
.filewrap {
background: $c-form-field-bg;
padding: 6px 10px;
border-radius: 3px;
border: 1px solid #666666;
margin-right: .5em;
}
textarea { textarea {
display: inline-block; display: inline-block;
vertical-align: top; vertical-align: top;

@ -1,10 +1,10 @@
html { html {
font-family: $font-stack; font-family: $font-stack;
color: #D0D0D0; color: #D0D0D0;
background: #131315;
} }
html, body { html, body {
background: #131315;
@include naked(); @include naked();
width: 100%; width: 100%;
height: 100%; height: 100%;

@ -25,12 +25,6 @@ body.term {
cursor: default; cursor: default;
canvas {
background-repeat: no-repeat;
background-position: center center;
background-size: contain;
}
canvas.selectable { canvas.selectable {
cursor: text; cursor: text;
} }
@ -106,118 +100,18 @@ body.term {
.debug-canvas { .debug-canvas {
position: absolute; position: absolute;
top: 0; top: 6px;
left: 0; left: 6px;
pointer-events: none;
}
.debug-tooltip {
position: absolute;
top: 0;
left: 0;
pointer-events: none; pointer-events: none;
background: #fff;
color: #000;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
line-height: 1;
table {
tr {
.label {
font-weight: bold;
text-align: right;
opacity: 0.5;
}
.value {
text-align: left;
.attributes {
&:empty::before {
content: 'None'
}
span:not(:last-of-type)::after {
content: ', '
}
}
}
}
}
} }
.debug-toolbar { .debug-toolbar {
line-height: 1.2; line-height: 1.5;
text-align: left; text-align: left;
margin: 6px 12px 12px 12px;
padding: 6px; padding: 6px;
background: #fff; font-family: $screen-stack;
color: #000;
border-radius: 6px;
font-size: 12px; font-size: 12px;
white-space: normal; white-space: normal;
.toolbar-group {
display: inline-block;
vertical-align: top;
margin: 0 1em;
tr {
.name {
font-weight: bold;
text-align: right;
opacity: 0.5;
&.title, &.has-button {
opacity: 1;
}
button {
background: none;
font: inherit;
text-shadow: none;
box-shadow: none;
color: #2ea1f9;
font-weight: bold;
text-align: right;
padding: 0;
margin: 0;
}
}
}
}
.heartbeat {
float: right;
font-family: $font-stack;
color: crimson;
font-size: 120%;
padding-right: 5px;
&.beat {
animation-name: heartbeat-beat;
animation-duration: 3s;
animation-fill-mode: forwards;
@keyframes heartbeat-beat {
0% {
transform: scale(1);
animation-timing-function: ease-out;
}
5% {
transform: scale(1.2);
animation-timing-function: linear;
}
100% {
transform: scale(0);
opacity: 0;
}
}
}
}
} }
} }
@ -246,13 +140,7 @@ body.term {
color: red; color: red;
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
margin: 20px 15px; margin: 10px 5px 14px 5px;
}
#screen.failed {
canvas, .screen-margin {
display: none;
}
} }
#term-nav { #term-nav {

@ -10,7 +10,7 @@ let devtool = 'source-map'
if (process.env.ESP_PROD) { if (process.env.ESP_PROD) {
// ignore demo // ignore demo
plugins.push(new webpack.IgnorePlugin(/(term|\.)\/demo(?:\.js)?$/)) plugins.push(new webpack.IgnorePlugin(/\.\/demo(?:\.js)?$/))
// no source maps // no source maps
devtool = '' devtool = ''
@ -20,13 +20,6 @@ plugins.push(new webpack.optimize.UglifyJsPlugin({
sourceMap: devtool === 'source-map' sourceMap: devtool === 'source-map'
})) }))
// replace "locale-data" with path to locale data
let locale = process.env.ESP_LANG || 'en'
plugins.push(new webpack.NormalModuleReplacementPlugin(
/^locale-data$/,
path.resolve(`lang/${locale}.php`)
))
module.exports = { module.exports = {
entry: './js', entry: './js',
output: { output: {
@ -41,10 +34,6 @@ module.exports = {
path.resolve(__dirname, 'node_modules') path.resolve(__dirname, 'node_modules')
], ],
loader: 'babel-loader' loader: 'babel-loader'
},
{
test: /lang\/.+?\.php$/,
loader: './lang/_js-lang-loader.js'
} }
] ]
}, },

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