Merge branch 'work'

pull/2/head
Ondřej Hruška 7 years ago
commit a0412c0e9e
Signed by: MightyPork
GPG Key ID: 2C5FD5035250423D
  1. 19
      _debug_replacements.php
  2. 4
      _pages.php
  3. 44
      js/appcommon.js
  4. 52
      js/term/buttons.js
  5. 22
      js/term/connection.js
  6. 539
      js/term/debug.js
  7. 381
      js/term/debug_screen.js
  8. 8
      js/term/demo.js
  9. 114
      js/term/index.js
  10. 1
      js/term/input.js
  11. 521
      js/term/screen.js
  12. 285
      js/term/screen_layout.js
  13. 308
      js/term/screen_parser.js
  14. 389
      js/term/screen_renderer.js
  15. 2
      js/term/soft_keyboard.js
  16. 36
      js/term/themes.js
  17. 7
      js/term_conf.js
  18. 4
      js/wifi.js
  19. 2
      lang/common.php
  20. 4
      lang/cs.php
  21. 2
      lang/de.php
  22. 9
      lang/en.php
  23. 272
      lang/hu.php
  24. 1
      pages/_head.php
  25. 23
      pages/cfg_system.php
  26. 43
      pages/cfg_term.php
  27. 102
      pages/help/cmd_screen.php
  28. 134
      pages/help/cmd_system.php
  29. 4
      pages/term.php
  30. 13
      sass/form/_form_layout.scss
  31. 108
      sass/pages/_term.scss
  32. 2
      webpack.config.js

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

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

@ -90,41 +90,65 @@ $.ready(function () {
e.preventDefault()
})
try {
do {
let msgAt, box
// populate the form errors box from GET arg ?err=...
// (a way to pass errors back from server via redirect)
let errAt = window.location.search.indexOf('err=')
if (errAt !== -1 && qs('.Box.errors')) {
let errs = decodeURIComponent(window.location.search.substr(errAt + 4)).split(',')
msgAt = window.location.search.indexOf('err=')
if (msgAt !== -1 && qs('.Box.errors')) {
let errs = decodeURIComponent(window.location.search.substr(msgAt + 4)).split(',')
let humanReadableErrors = []
errs.forEach(function (er) {
if (er.length === 0) return
let lbls = qsa('label[for="' + er + '"]')
if (lbls) {
if (lbls && lbls.length > 0) {
for (let i = 0; i < lbls.length; i++) {
let lbl = lbls[i]
lbl.classList.add('error')
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 msgAt = window.location.search.indexOf('msg=')
if (msgAt !== -1 && qs('.Box.message')) {
let msg = decodeURIComponent(window.location.search.substr(msgAt + 4))
let box = qs('.Box.message')
box.innerHTML = msg
let fademsgbox = function (box, time) {
box.classList.remove('hidden')
setTimeout(() => {
box.classList.add('hiding')
setTimeout(() => {
box.classList.add('hidden')
}, 1000)
}, 2000)
}, time)
}
msgAt = window.location.search.indexOf('errmsg=')
box = qs('.Box.errmessage')
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=')
box = qs('.Box.message')
if (msgAt !== -1 && box) {
let msg = decodeURIComponent(window.location.search.substr(msgAt + 4))
box.innerHTML = msg
fademsgbox(box, 2000)
break
}
} while (0)
} catch (e) {
console.error(e)
}
modal.init()

@ -1,11 +1,19 @@
const { getColor } = require('./themes')
const { qs } = require('../utils')
const { rgb2hsl, hex2rgb, rgb2hex, hsl2rgb } = require('../lib/color_utils')
module.exports = function initButtons (input) {
let container = qs('#action-buttons')
// current color palette
let palette = []
// button labels
let labels = []
// button colors
let colors = {}
// button elements
let buttons = []
@ -46,7 +54,7 @@ module.exports = function initButtons (input) {
pushButton()
}
} else if (buttons.length > labels.length) {
for (let i = labels.length; i <= buttons.length; i++) {
for (let i = buttons.length; i > labels.length; i--) {
popButton()
}
}
@ -54,11 +62,51 @@ module.exports = function initButtons (input) {
for (let i = 0; i < labels.length; i++) {
let label = labels[i].trim()
let button = buttons[i]
let color = colors[i]
button.textContent = label || '\u00a0' // label or nbsp
if (!label) 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 { update, labels }
return {
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,6 +5,7 @@ try { demo = require('./demo') } catch (err) {}
const RECONN_DELAY = 2000
const HEARTBEAT_TIME = 3000
const HTTPS = window.location.protocol.match(/s:/)
/** Handle connections */
module.exports = class TermConnection extends EventEmitter {
@ -19,9 +20,10 @@ module.exports = class TermConnection extends EventEmitter {
this.autoXoffTimeout = null
this.reconnTimeout = null
this.forceClosing = false
this.queue = []
try {
this.blobReader = new FileReader()
this.blobReader = new window.FileReader()
this.blobReader.onload = (evt) => {
this.onDecodedWSMessage(this.blobReader.result)
}
@ -82,7 +84,6 @@ module.exports = class TermConnection extends EventEmitter {
onDecodedWSMessage (str) {
switch (str.charAt(0)) {
case '.':
console.log(str)
// heartbeat, no-op message
break
@ -91,12 +92,14 @@ module.exports = class TermConnection extends EventEmitter {
this.xoff = true
this.autoXoffTimeout = setTimeout(() => {
this.xoff = false
this.flushQueue()
}, 250)
break
case '+':
// console.log('xon');
this.xoff = false
this.flushQueue()
clearTimeout(this.autoXoffTimeout)
break
@ -143,8 +146,8 @@ module.exports = class TermConnection extends EventEmitter {
return true // Simulate success
}
if (this.xoff) {
// TODO queue
console.log("Can't send, flood control.")
console.log("Can't send, flood control. Queueing")
this.queue.push(message)
return false
}
@ -160,6 +163,12 @@ module.exports = class TermConnection extends EventEmitter {
return true
}
flushQueue () {
console.log('Flushing input queue')
for (let message of this.queue) this.send(message)
this.queue = []
}
/** Safely close the socket */
closeSocket () {
if (this.ws) {
@ -184,7 +193,7 @@ module.exports = class TermConnection extends EventEmitter {
this.closeSocket()
this.ws = new window.WebSocket('ws://' + window._root + '/term/update.ws')
this.ws = new window.WebSocket(`${HTTPS ? 'wss' : 'ws'}://${window._root}/term/update.ws`)
this.ws.addEventListener('open', (...args) => this.onWSOpen(...args))
this.ws.addEventListener('close', (...args) => this.onWSClose(...args))
this.ws.addEventListener('message', (...args) => this.onWSMessage(...args))
@ -195,6 +204,7 @@ module.exports = class TermConnection extends EventEmitter {
}
heartbeat () {
this.emit('heartbeat')
clearTimeout(this.heartbeatTimeout)
this.heartbeatTimeout = setTimeout(() => this.onHeartbeatFail(), HEARTBEAT_TIME)
}
@ -202,7 +212,7 @@ module.exports = class TermConnection extends EventEmitter {
sendPing () {
console.log('> ping')
this.emit('ping')
$.get('http://' + window._root + '/api/v1/ping', (resp, status) => {
$.get(`${HTTPS ? 'https' : 'http'}://${window._root}/api/v1/ping`, (resp, status) => {
if (status === 200) {
clearInterval(this.pingInterval)
console.info('Server ready, opening socket…')

@ -0,0 +1,539 @@
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()
}
})
}

@ -1,381 +0,0 @@
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()
})
}

@ -276,10 +276,8 @@ class ScrollingTerminal {
data += encodeAsCodePoint(25)
data += encodeAsCodePoint(80)
data += encodeAsCodePoint(this.theme)
data += encodeAsCodePoint(this.defaultFG & 0xFFFF)
data += encodeAsCodePoint(this.defaultFG >> 16)
data += encodeAsCodePoint(this.defaultBG & 0xFFFF)
data += encodeAsCodePoint(this.defaultBG >> 16)
data += this.encodeColor(this.defaultFG)
data += this.encodeColor(this.defaultBG)
let attributes = +this.cursor.visible
attributes |= (3 << 5) * +this.trackMouse // track mouse controls both
attributes |= 3 << 7 // buttons/links always visible
@ -290,7 +288,7 @@ class ScrollingTerminal {
getButtons () {
let data = 'B'
data += encodeAsCodePoint(this.buttonLabels.length)
data += this.buttonLabels.map(x => x + '\x01').join('')
data += this.buttonLabels.map(x => `\x01${x}\x01`).join('')
return data
}
getTitle () {

@ -1,3 +1,4 @@
const $ = require('../lib/chibi')
const { qs, mk } = require('../utils')
const localize = require('../lang')
const Notify = require('../notif')
@ -6,7 +7,7 @@ const TermConnection = require('./connection')
const TermInput = require('./input')
const TermUpload = require('./upload')
const initSoftKeyboard = require('./soft_keyboard')
const attachDebugScreen = require('./debug_screen')
const attachDebugger = require('./debug')
const initButtons = require('./buttons')
/** Init the terminal sub-module - called from HTML */
@ -15,17 +16,79 @@ module.exports = function (opts) {
const conn = new TermConnection(screen)
const input = TermInput(conn, screen)
const termUpload = TermUpload(conn, input, screen)
screen.input = input
screen.conn = conn
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)
screen.on('button-labels', labels => {
// TODO: don't use pointers for this
buttons.labels.splice(0, buttons.labels.length, ...labels)
buttons.update()
screen.on('buttons-update', update => {
buttons.labels = update.labels
buttons.colors = update.colors
})
// 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 showSplash = (obj, delay = 250) => {
clearTimeout(showSplashTimeout)
@ -42,7 +105,7 @@ module.exports = function (opts) {
// console.log('*connect')
showSplash({ title: localize('term_conn.waiting_content'), loading: true })
})
conn.on('load', () => {
screen.on('load', () => {
// console.log('*load')
clearTimeout(showSplashTimeout)
if (screen.window.statusScreen) screen.window.statusScreen = null
@ -75,37 +138,39 @@ module.exports = function (opts) {
return false
}
qs('#screen').appendChild(screen.canvas)
qs('#screen').appendChild(screen.layout.canvas)
initSoftKeyboard(screen, input)
if (attachDebugScreen) attachDebugScreen(screen)
if (attachDebugger) attachDebugger(screen, conn)
// fullscreen mode
let fullscreenIcon = {} // dummy
let isFullscreen = false
let properFullscreen = false
let fitScreen = false
let screenPadding = screen.window.padding
let screenPadding = screen.layout.window.padding
let fitScreenIfNeeded = function fitScreenIfNeeded () {
if (isFullscreen) {
fullscreenIcon.className = 'icn-resize-small'
if (properFullscreen) {
screen.window.fitIntoWidth = window.screen.width
screen.window.fitIntoHeight = window.screen.height
screen.window.padding = 0
screen.layout.window.fitIntoWidth = window.screen.width
screen.layout.window.fitIntoHeight = window.screen.height
screen.layout.window.padding = 0
} else {
screen.window.fitIntoWidth = window.innerWidth
screen.layout.window.fitIntoWidth = window.innerWidth
if (qs('#term-nav').classList.contains('hidden')) {
screen.window.fitIntoHeight = window.innerHeight
screen.layout.window.fitIntoHeight = window.innerHeight
} else {
screen.window.fitIntoHeight = window.innerHeight - 24
screen.layout.window.fitIntoHeight = window.innerHeight - 24
}
screen.window.padding = 0
screen.layout.window.padding = 0
}
} else {
fullscreenIcon.className = 'icn-resize-full'
screen.window.fitIntoWidth = fitScreen ? window.innerWidth - 4 : 0
screen.window.fitIntoHeight = fitScreen ? window.innerHeight : 0
screen.window.padding = screenPadding
screen.layout.window.fitIntoWidth = fitScreen ? window.innerWidth - 4 : 0
screen.layout.window.fitIntoHeight = fitScreen ? window.innerHeight : 0
screen.layout.window.padding = screenPadding
}
}
fitScreenIfNeeded()
@ -159,11 +224,11 @@ module.exports = function (opts) {
isFullscreen = true
fitScreenIfNeeded()
screen.updateSize()
screen.layout.updateSize()
if (properFullscreen) {
if (screen.canvas.requestFullscreen) screen.canvas.requestFullscreen()
else screen.canvas.webkitRequestFullscreen()
if (screen.layout.canvas.requestFullscreen) screen.layout.canvas.requestFullscreen()
else screen.layout.canvas.webkitRequestFullscreen()
} else {
document.body.classList.add('pseudo-fullscreen')
}
@ -178,6 +243,7 @@ module.exports = function (opts) {
// for debugging
window.termScreen = screen
window.buttons = buttons
window.conn = conn
window.input = input
window.termUpl = termUpload

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

@ -1,19 +1,19 @@
const EventEmitter = require('events')
const $ = require('../lib/chibi')
const { mk, qs } = require('../utils')
const { mk } = require('../utils')
const notify = require('../notif')
const ScreenParser = require('./screen_parser')
const ScreenRenderer = require('./screen_renderer')
const ScreenLayout = require('./screen_layout')
const { ATTR_BLINK } = require('./screen_attr_bits')
/**
* A terminal screen.
*/
module.exports = class TermScreen extends EventEmitter {
constructor () {
super()
this.canvas = mk('canvas')
this.ctx = this.canvas.getContext('2d')
this.parser = new ScreenParser(this)
this.renderer = new ScreenRenderer(this)
this.parser = new ScreenParser()
this.layout = new ScreenLayout()
// debug screen handle
this._debug = null
@ -24,22 +24,29 @@ module.exports = class TermScreen extends EventEmitter {
console.warn('No AudioContext!')
}
// dummy. Handle for Input
this.input = new Proxy({}, {
get () {
return () => console.warn('TermScreen#input not set!')
this._window = {
width: 0,
height: 0,
// two bits. LSB: debug enabled by user, MSB: debug enabled by server
debug: 0,
statusScreen: null
}
})
// dummy. Handle for Conn
this.conn = new Proxy({}, {
get () {
return () => console.warn('TermScreen#conn not set!')
},
set (a, b) {
return () => console.warn('TermScreen#conn not set!')
// 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.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 = {
x: 0,
y: 0,
@ -49,69 +56,31 @@ module.exports = class TermScreen extends EventEmitter {
style: 'block'
}
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
}
const self = this
// current selection
this.selection = {
// when false, this will prevent selection in favor of mouse events,
// though alt can be held to override it
selectable: true,
selectable: null,
// selection start and end (x, y) tuples
start: [0, 0],
end: [0, 0]
end: [0, 0],
setSelectable (value) {
if (value !== this.selectable) {
this.selectable = self.layout.selectable = value
}
}
}
// mouse features
this.mouseMode = { clicks: false, movement: false }
// make writing to window update size and draw
const self = this
this.window = new Proxy(this._window, {
set (target, key, value, receiver) {
if (target[key] !== value) {
target[key] = value
self.scheduleSizeUpdate()
self.renderer.scheduleDraw(`window:${key}=${value}`)
self.emit(`update-window:${key}`, value)
}
return true
}
})
this.showLinks = false
this.showButtons = false
this.title = ''
this.bracketedPaste = false
this.blinkingCellCount = 0
@ -121,38 +90,46 @@ module.exports = class TermScreen extends EventEmitter {
this.screenFG = []
this.screenBG = []
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 selectStart = (x, y) => {
if (selecting) return
selecting = true
this.selection.start = this.selection.end = this.screenToGrid(x, y, true)
this.renderer.scheduleDraw('select-start')
this.selection.start = this.selection.end = this.layout.screenToGrid(x, y, true)
this.renderScreen('select-start')
}
let selectMove = (x, y) => {
if (!selecting) return
this.selection.end = this.screenToGrid(x, y, true)
this.renderer.scheduleDraw('select-move')
this.selection.end = this.layout.screenToGrid(x, y, true)
this.renderScreen('select-move')
}
let selectEnd = (x, y) => {
if (!selecting) return
selecting = false
this.selection.end = this.screenToGrid(x, y, true)
this.renderer.scheduleDraw('select-end')
this.selection.end = this.layout.screenToGrid(x, y, true)
this.renderScreen('select-end')
Object.assign(this.selection, this.getNormalizedSelection())
}
// bind event listeners
this.canvas.addEventListener('mousedown', e => {
this.layout.on('mousedown', e => {
this.emit('hide-touch-select-menu')
if ((this.selection.selectable || e.altKey) && e.button === 0) {
selectStart(e.offsetX, e.offsetY)
} else {
this.input.onMouseDown(...this.screenToGrid(e.offsetX, e.offsetY),
e.button + 1)
this.emit('mousedown', ...this.layout.screenToGrid(e.offsetX, e.offsetY), e.button + 1)
}
})
@ -172,17 +149,22 @@ module.exports = class TermScreen extends EventEmitter {
let touchDidMove = false
let getTouchPositionOffset = touch => {
let rect = this.canvas.getBoundingClientRect()
let rect = this.layout.canvas.getBoundingClientRect()
return [touch.clientX - rect.left, touch.clientY - rect.top]
}
this.canvas.addEventListener('touchstart', e => {
this.layout.on('touchstart', e => {
touchPosition = getTouchPositionOffset(e.touches[0])
touchDidMove = false
touchDownTime = Date.now()
}, { passive: true })
this.canvas.addEventListener('touchmove', e => {
if (this.mouseMode.clicks) {
this.emit('mousedown', ...this.layout.screenToGrid(...touchPosition), 1)
e.preventDefault()
}
})
this.layout.on('touchmove', e => {
touchPosition = getTouchPositionOffset(e.touches[0])
if (!selecting && touchDidMove === false) {
@ -192,12 +174,15 @@ module.exports = class TermScreen extends EventEmitter {
} else if (selecting) {
e.preventDefault()
selectMove(...touchPosition)
} else if (this.mouseMode.movement && !selecting) {
this.emit('mousemove', ...this.layout.screenToGrid(...touchPosition))
e.preventDefault()
}
touchDidMove = true
})
this.canvas.addEventListener('touchend', e => {
this.layout.on('touchend', e => {
if (e.touches[0]) {
touchPosition = getTouchPositionOffset(e.touches[0])
}
@ -207,19 +192,16 @@ module.exports = class TermScreen extends EventEmitter {
selectEnd(...touchPosition)
// selection ended; show touch select menu
let touchSelectMenu = qs('#touch-select-menu')
touchSelectMenu.classList.add('open')
let rect = touchSelectMenu.getBoundingClientRect()
// use middle position for x and one line above for y
let selectionPos = this.gridToScreen(
let selectionPos = this.layout.gridToScreen(
(this.selection.start[0] + this.selection.end[0]) / 2,
this.selection.start[1] - 1
)
selectionPos[0] -= rect.width / 2
selectionPos[1] -= rect.height / 2
touchSelectMenu.style.transform = `translate(${selectionPos[0]}px, ${
selectionPos[1]}px)`
this.emit('show-touch-select-menu', selectionPos[0], selectionPos[1])
} else if (this.mouseMode.clicks) {
this.emit('mouseup', ...this.layout.screenToGrid(...touchPosition), 1)
e.preventDefault()
}
if (!touchDidMove && !this.mouseMode.clicks) {
@ -227,7 +209,7 @@ module.exports = class TermScreen extends EventEmitter {
x: touchPosition[0],
y: touchPosition[1]
}))
}
} else if (!touchDidMove) this.resetSelection()
touchPosition = null
})
@ -236,49 +218,37 @@ module.exports = class TermScreen extends EventEmitter {
if (this.selection.start[0] !== this.selection.end[0] ||
this.selection.start[1] !== this.selection.end[1]) {
// selection is not empty
// reset selection
this.selection.start = this.selection.end = [0, 0]
qs('#touch-select-menu').classList.remove('open')
this.renderer.scheduleDraw('select-reset')
this.resetSelection()
} else {
e.preventDefault()
this.emit('open-soft-keyboard')
}
})
$.ready(() => {
let copyButton = qs('#touch-select-copy-btn')
if (copyButton) {
copyButton.addEventListener('click', () => {
this.copySelectionToClipboard()
})
}
})
this.canvas.addEventListener('mousemove', e => {
this.layout.on('mousemove', e => {
if (!selecting) {
this.input.onMouseMove(...this.screenToGrid(e.offsetX, e.offsetY))
this.emit('mousemove', ...this.layout.screenToGrid(e.offsetX, e.offsetY))
}
})
this.canvas.addEventListener('mouseup', e => {
this.layout.on('mouseup', e => {
if (!selecting) {
this.input.onMouseUp(...this.screenToGrid(e.offsetX, e.offsetY),
this.emit('mouseup', ...this.layout.screenToGrid(e.offsetX, e.offsetY),
e.button + 1)
}
})
let aggregateWheelDelta = 0
this.canvas.addEventListener('wheel', e => {
this.layout.on('wheel', e => {
if (this.mouseMode.clicks) {
if (Math.abs(e.wheelDeltaY) === 120) {
// mouse wheel scrolling
this.input.onMouseWheel(...this.screenToGrid(e.offsetX, e.offsetY), e.deltaY > 0 ? 1 : -1)
this.emit('mousewheel', ...this.layout.screenToGrid(e.offsetX, e.offsetY), e.deltaY > 0 ? 1 : -1)
} else {
// smooth scrolling
aggregateWheelDelta -= e.wheelDeltaY
if (Math.abs(aggregateWheelDelta) >= 40) {
this.input.onMouseWheel(...this.screenToGrid(e.offsetX, e.offsetY), aggregateWheelDelta > 0 ? 1 : -1)
this.emit('mousewheel', ...this.layout.screenToGrid(e.offsetX, e.offsetY), aggregateWheelDelta > 0 ? 1 : -1)
aggregateWheelDelta = 0
}
}
@ -288,7 +258,7 @@ module.exports = class TermScreen extends EventEmitter {
}
})
this.canvas.addEventListener('contextmenu', e => {
this.layout.on('contextmenu', e => {
if (this.mouseMode.clicks) {
// prevent mouse keys getting stuck
e.preventDefault()
@ -297,169 +267,48 @@ module.exports = class TermScreen extends EventEmitter {
})
}
/**
* 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')
}
}
/**
* Returns a CSS font string with this TermScreen's font settings and the
* font modifiers.
* @param {Object} modifiers
* @param {string} [modifiers.style] - the font style
* @param {string} [modifiers.weight] - the font weight
* @returns {string} a CSS font string
*/
getFont (modifiers = {}) {
let fontStyle = modifiers.style || 'normal'
let fontWeight = modifiers.weight || 'normal'
return `${fontStyle} normal ${fontWeight} ${this.window.fontSize}px ${this.window.fontFamily}`
}
/**
* Converts screen coordinates to grid coordinates.
* @param {number} x - x in pixels
* @param {number} y - y in pixels
* @param {boolean} rounded - whether to round the coord, used for select highlighting
* @returns {number[]} a tuple of (x, y) in cells
*/
screenToGrid (x, y, rounded = false) {
let cellSize = this.getCellSize()
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))
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()
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
}
}
/**
* The cell size, which is the character size multiplied by the grid scale.
* @returns {Object} the cell size with `width` and `height` in pixels
*/
getCellSize () {
let charSize = this.getCharSize()
return {
width: Math.ceil(charSize.width * this.window.gridScaleX),
height: Math.ceil(charSize.height * this.window.gridScaleY)
}
}
/**
* Updates the canvas size if it changed
*/
updateSize () {
// see below (this is just updating it)
this._window.devicePixelRatio = Math.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
}
resetScreen () {
const { width, height } = this.window
this.blinkingCellCount = 0
this.screen.screen = new Array(width * height).fill(' ')
this.screen.screenFG = new Array(width * height).fill(0)
this.screen.screenBG = new Array(width * height).fill(0)
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
this.layout.window.height = this.window.height
}
renderScreen (reason) {
let selection = []
for (let cell = 0; cell < this.screen.length; cell++) {
selection.push(this.isInSelection(cell % this.window.width, Math.floor(cell / this.window.width)))
}
this.layout.render(reason, {
width: this.window.width,
height: this.window.height,
screen: this.screen,
screenFG: this.screenFG,
screenBG: this.screenBG,
screenSelection: selection,
screenAttrs: this.screenAttrs,
screenLines: this.screenLines,
cursor: this.cursor,
statusScreen: this.window.statusScreen,
reverseVideo: this.reverseVideo,
hasBlinkingCells: !!this.blinkingCellCount
})
}
// 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')
}
resetSelection () {
this.selection.start = this.selection.end = [0, 0]
this.emit('hide-touch-select-menu')
this.renderScreen('select-reset')
}
/**
@ -618,6 +467,124 @@ module.exports = class TermScreen extends EventEmitter {
}
load (...args) {
this.parser.load(...args)
const updates = this.parser.parse(...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')
}
}

@ -0,0 +1,285 @@
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,6 +1,3 @@
const $ = require('../lib/chibi')
const { qs } = require('../utils')
const {
ATTR_FG,
ATTR_BG,
@ -21,21 +18,26 @@ const SEQ_SET_FG = 5
const SEQ_SET_BG = 6
const SEQ_SET_ATTR_0 = 7
// decode a number encoded as a unicode code point
function du (str) {
if (!str) return NaN
let num = str.codePointAt(0)
if (num > 0xDFFF) num -= 0x800
return num - 1
}
/* eslint-disable no-multi-spaces */
const TOPIC_SCREEN_OPTS = 'O'
const TOPIC_CONTENT = 'S'
const TOPIC_TITLE = 'T'
const TOPIC_BUTTONS = 'B'
const TOPIC_CURSOR = 'C'
const TOPIC_INTERNAL = 'D'
const TOPIC_BELL = '!'
const TOPIC_BACKDROP = 'W'
// mnemonic
const TOPIC_SCREEN_OPTS = 'O' // O-ptions
const TOPIC_STATIC_OPTS = 'P' // P-arams
const TOPIC_CONTENT = 'S' // S-creen
const TOPIC_TITLE = 'T' // T-itle
const TOPIC_BUTTONS = 'B' // B-uttons
const TOPIC_CURSOR = 'C' // C-ursor
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_DEBUGBAR = (1 << 1)
@ -53,37 +55,24 @@ const OPT_REVERSE_VIDEO = (1 << 14)
/* eslint-enable no-multi-spaces */
/**
* A parser for screen update messages
*/
module.exports = class ScreenParser {
constructor (screen) {
this.screen = screen
// true if TermScreen#load was called at least once
constructor () {
// true if full content was loaded
this.contentLoaded = false
}
/**
* Hide the warning message about failed data load
*/
hideLoadFailedMsg () {
if (!this.contentLoaded) {
let scr = qs('#screen')
let errmsg = qs('#load-failed')
if (scr) scr.classList.remove('failed')
if (errmsg) errmsg.parentNode.removeChild(errmsg)
this.contentLoaded = true
}
}
loadUpdate (str) {
parseUpdate (str) {
// console.log(`update ${str}`)
// current index
let ci = 0
let strArray = Array.from ? Array.from(str) : str.split('')
let text
let resized = false
const topics = du(strArray[ci++])
// this.screen.cursor.hanging = !!(attributes & (1 << 1))
let collectOneTerminatedString = () => {
// TODO optimize this
@ -99,35 +88,40 @@ module.exports = class ScreenParser {
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) {
const topic = strArray[ci++]
if (topic === TOPIC_SCREEN_OPTS) {
const newHeight = du(strArray[ci++])
const newWidth = du(strArray[ci++])
const height = du(strArray[ci++])
const width = du(strArray[ci++])
const theme = du(strArray[ci++])
const defFg = (du(strArray[ci++]) & 0xFFFF) | ((du(strArray[ci++]) & 0xFFFF) << 16)
const defBg = (du(strArray[ci++]) & 0xFFFF) | ((du(strArray[ci++]) & 0xFFFF) << 16)
const attributes = du(strArray[ci++])
// 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
const defFG = collectColor()
const defBG = collectColor()
// process attributes
this.screen.cursor.visible = !!(attributes & OPT_CURSOR_VISIBLE)
const attributes = du(strArray[ci++])
const cursorVisible = !!(attributes & OPT_CURSOR_VISIBLE)
this.screen.input.setAlts(
// HACK: input alts are formatted as arguments for Input#setAlts
const inputAlts = [
!!(attributes & OPT_CURSORS_ALT_MODE),
!!(attributes & OPT_NUMPAD_ALT_MODE),
!!(attributes & OPT_FN_ALT_MODE),
!!(attributes & OPT_CRLF_MODE)
)
]
const trackMouseClicks = !!(attributes & OPT_CLICK_TRACKING)
const trackMouseMovement = !!(attributes & OPT_MOVE_TRACKING)
@ -139,88 +133,100 @@ module.exports = class ScreenParser {
// if it's not zero, decrement such that the two most significant bits
// are the type and the least significant bit is the blink state
if (cursorShape > 0) cursorShape--
const cursorStyle = cursorShape >> 1
let cursorStyle = cursorShape >> 1
const cursorBlinking = !(cursorShape & 1)
if (cursorStyle === 0) this.screen.cursor.style = 'block'
else if (cursorStyle === 1) this.screen.cursor.style = 'line'
else if (cursorStyle === 2) this.screen.cursor.style = 'bar'
if (this.screen.cursor.blinking !== cursorBlinking) {
this.screen.cursor.blinking = cursorBlinking
this.screen.renderer.resetCursorBlink()
}
this.screen.input.setMouseMode(trackMouseClicks, trackMouseMovement)
this.screen.selection.selectable = !trackMouseClicks && !trackMouseMovement
$(this.screen.canvas).toggleClass('selectable', this.screen.selection.selectable)
this.screen.mouseMode = {
clicks: trackMouseClicks,
movement: trackMouseMovement
}
if (cursorStyle === 0) cursorStyle = 'block'
else if (cursorStyle === 1) cursorStyle = 'line'
else cursorStyle = 'bar'
const showButtons = !!(attributes & OPT_SHOW_BUTTONS)
const showConfigLinks = !!(attributes & OPT_SHOW_CONFIG_LINKS)
$('.x-term-conf-btn').toggleClass('hidden', !showConfigLinks)
$('#action-buttons').toggleClass('hidden', !showButtons)
this.screen.bracketedPaste = !!(attributes & OPT_BRACKETED_PASTE)
this.screen.reverseVideo = !!(attributes & OPT_REVERSE_VIDEO)
const debugbar = !!(attributes & OPT_DEBUGBAR)
// TODO do something with debugbar
const bracketedPaste = !!(attributes & OPT_BRACKETED_PASTE)
const reverseVideo = !!(attributes & OPT_REVERSE_VIDEO)
const debugEnabled = !!(attributes & OPT_DEBUGBAR)
updates.push({
topic: 'screen-opts',
width,
height,
theme,
defFG,
defBG,
cursorVisible,
cursorBlinking,
cursorStyle,
inputAlts,
trackMouseClicks,
trackMouseMovement,
showButtons,
showConfigLinks,
bracketedPaste,
reverseVideo,
debugEnabled
})
} else if (topic === TOPIC_CURSOR) {
// cursor position
const cursorY = du(strArray[ci++])
const cursorX = du(strArray[ci++])
const hanging = du(strArray[ci++])
const cursorMoved = (
hanging !== this.screen.cursor.hanging ||
cursorX !== this.screen.cursor.x ||
cursorY !== this.screen.cursor.y)
const y = du(strArray[ci++])
const x = du(strArray[ci++])
const hanging = !!du(strArray[ci++])
updates.push({
topic: 'cursor',
x,
y,
hanging
})
this.screen.cursor.x = cursorX
this.screen.cursor.y = cursorY
} else if (topic === TOPIC_STATIC_OPTS) {
const fontStack = collectOneTerminatedString()
const fontSize = du(strArray[ci++])
this.screen.cursor.hanging = !!hanging
updates.push({
topic: 'static-opts',
fontStack,
fontSize
})
if (cursorMoved) {
this.screen.renderer.resetCursorBlink()
this.screen.emit('cursor-moved')
} else if (topic === TOPIC_DOUBLE_LINES) {
let lines = []
const count = du(strArray[ci++])
for (let i = 0; i < count; i++) {
// format: INDEX<<3 | (dbl-h-bot : dbl-h-top : dbl-w)
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) {
text = collectOneTerminatedString()
qs('#screen-title').textContent = text
if (text.length === 0) text = 'Terminal'
qs('title').textContent = `${text} :: ESPTerm`
updates.push({ topic: 'title', title: collectOneTerminatedString() })
} else if (topic === TOPIC_BUTTONS) {
const count = du(strArray[ci++])
let labels = []
let colors = []
for (let j = 0; j < count; j++) {
text = collectOneTerminatedString()
labels.push(text)
colors.push(collectColor())
labels.push(collectOneTerminatedString())
}
this.screen.emit('button-labels', labels)
} else if (topic === TOPIC_BACKDROP) {
updates.push({
topic: 'buttons-update',
labels,
colors
})
text = collectOneTerminatedString()
this.screen.backgroundImage = text
} else if (topic === TOPIC_BACKDROP) {
updates.push({ topic: 'backdrop', image: collectOneTerminatedString() })
} else if (topic === TOPIC_BELL) {
this.screen.beep()
updates.push({ topic: 'bell' })
} else if (topic === TOPIC_INTERNAL) {
// debug info
const flags = du(strArray[ci++])
const cursorAttrs = du(strArray[ci++])
const regionStart = du(strArray[ci++])
@ -228,10 +234,15 @@ module.exports = class ScreenParser {
const charsetGx = du(strArray[ci++])
const charsetG0 = strArray[ci++]
const charsetG1 = strArray[ci++]
let cursorFg = collectColor()
let cursorBg = collectColor()
const freeHeap = du(strArray[ci++])
const clientCount = du(strArray[ci++])
this.screen.emit('internal', {
updates.push({
topic: 'internal',
flags,
cursorAttrs,
regionStart,
@ -239,21 +250,19 @@ module.exports = class ScreenParser {
charsetGx,
charsetG0,
charsetG1,
cursorFg,
cursorBg,
freeHeap,
clientCount
})
} else if (topic === TOPIC_CONTENT) {
// set screen content
const frameY = du(strArray[ci++])
const frameX = du(strArray[ci++])
const frameHeight = 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
let fg = 7
let bg = 0
@ -261,59 +270,39 @@ module.exports = class ScreenParser {
let cell = 0 // cell index
let lastChar = ' '
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_BLINK = ATTR_BLINK
const cells = []
let pushCell = () => {
// Remove blink attribute if it wouldn't have any effect
let myAttrs = attrs
let hasFG = attrs & ATTR_FG
let hasBG = attrs & ATTR_BG
let cellFG = fg
let cellBG = bg
let cellAttrs = attrs
// 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
if (!hasFG) cellFG = 0
if (!hasBG) cellBG = 0
if ((myAttrs & MASK_BLINK) !== 0 &&
((lastChar === ' ' && ((myAttrs & MASK_LINE_ATTR) === 0)) || // no line styles
// Remove blink attribute if it wouldn't have any effect
if ((cellAttrs & MASK_BLINK) &&
((lastChar === ' ' && ((cellAttrs & MASK_LINE_ATTR) === 0)) || // no line styles
(fg === bg && hasFG && hasBG) // invisible text
)
) {
myAttrs ^= MASK_BLINK
cellAttrs ^= 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
if ((myAttrs & ATTR_BOLD) && !(myAttrs & ATTR_FAINT) && hasFG && cellFG < 8) {
if ((cellAttrs & ATTR_BOLD) && !(cellAttrs & ATTR_FAINT) && hasFG && cellFG < 8) {
cellFG += 8
}
this.screen.screen[index] = lastChar
this.screen.screenFG[index] = cellFG
this.screen.screenBG[index] = cellBG
this.screen.screenAttrs[index] = myAttrs
cells.push([lastChar, cellFG, cellBG, cellAttrs])
}
while (ci < strArray.length && cell < frameLength) {
@ -377,38 +366,51 @@ module.exports = class ScreenParser {
}
}
if (this.screen.window.debug) console.log(`Blinky cells: ${this.screen.blinkingCellCount}`)
this.screen.renderer.scheduleDraw('load', 16)
this.screen.conn.emit('load')
updates.push({
topic: 'content',
frameX,
frameY,
frameWidth,
frameHeight,
cells
})
}
if ((topics & 0x3B) !== 0) this.hideLoadFailedMsg()
if (topics & 0x3B && !this.contentLoaded) {
updates.push({ topic: 'full-load-complete' })
this.contentLoaded = true
}
}
return updates
}
/**
* Loads a message from the server, and optionally a theme.
* @param {string} str - the message
* Parses a message from the server
* @param {string} message - the message
*/
load (str) {
const content = str.substr(1)
parse (message) {
const content = message.substr(1)
const updates = []
// This is a good place for debugging the message
// console.log(str)
// console.log(message)
switch (str[0]) {
switch (message[0]) {
case 'U':
this.loadUpdate(content)
updates.push(...this.parseUpdate(content))
break
case 'G':
this.screen.showNotification(content)
break
return [{
topic: 'notification',
content
}]
default:
console.warn(`Bad data message type; ignoring.\n${JSON.stringify(str)}`)
console.warn(`Bad data message type; ignoring.\n${JSON.stringify(message)}`)
}
return updates
}
}

@ -1,7 +1,7 @@
const EventEmitter = require('events')
const {
themes,
buildColorTable,
SELECTION_FG, SELECTION_BG
getColor
} = require('./themes')
const {
@ -27,18 +27,44 @@ const frakturExceptions = {
'Z': '\u2128'
}
module.exports = class ScreenRenderer {
constructor (screen) {
this.screen = screen
this.ctx = screen.ctx
/**
* A terminal screen renderer, using canvas 2D
*/
module.exports = class CanvasRenderer extends EventEmitter {
constructor (canvas) {
super()
this._palette = null // colors 0-15
this.defaultBgNum = 0
this.defaultFgNum = 7
this.canvas = canvas
this.ctx = this.canvas.getContext('2d')
// 256color lookup table
// should not be used to look up 0-15 (will return transparent)
this.colorTable256 = buildColorTable()
this._palette = null // colors 0-15
this.defaultBG = 0
this.defaultFG = 7
this.debug = false
this._debug = null
this.graphics = 0
this.statusFont = "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Roboto, Arial, sans-serif"
// screen data, considered immutable
this.width = 0
this.height = 0
this.padding = 0
this.charSize = { width: 0, height: 0 }
this.cellSize = { width: 0, height: 0 }
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()
@ -52,17 +78,27 @@ module.exports = class ScreenRenderer {
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 () {
// used to determine if a cell should be redrawn; storing the current state
// as it is on screen
if (this.screen.window && this.screen.window.debug) {
console.log('Resetting drawn screen')
}
if (this.debug) console.log('Resetting drawn screen')
this.drawnScreen = []
this.drawnScreenFG = []
this.drawnScreenBG = []
this.drawnScreenAttrs = []
this.drawnCursor = [-1, -1, '']
this.drawnScreenLines = []
this.drawnCursor = [-1, -1, '', false]
}
/**
@ -78,23 +114,29 @@ module.exports = class ScreenRenderer {
if (this._palette !== palette) {
this._palette = palette
this.resetDrawn()
this.emit('palette-update', palette)
this.scheduleDraw('palette')
}
}
getCharWidthFor (font) {
this.ctx.font = font
return Math.floor(this.ctx.measureText(' ').width)
}
loadTheme (i) {
if (i in themes) this.palette = themes[i]
}
setDefaultColors (fg, bg) {
if (fg !== this.defaultFgNum || bg !== this.defaultBgNum) {
if (fg !== this.defaultFG || bg !== this.defaultBG) {
this.resetDrawn()
this.defaultFgNum = fg
this.defaultBgNum = bg
this.defaultFG = fg
this.defaultBG = bg
this.scheduleDraw('default-colors')
// full bg with default color (goes behind the image)
this.screen.canvas.style.backgroundColor = this.getColor(bg)
this.canvas.style.backgroundColor = this.getColor(bg)
}
}
@ -118,27 +160,7 @@ module.exports = class ScreenRenderer {
* @returns {string} the CSS color
*/
getColor (i) {
// return palette color if it exists
if (i < 16 && i in this.palette) return this.palette[i]
// -1 for selection foreground, -2 for selection background
if (i === -1) return SELECTION_FG
if (i === -2) return SELECTION_BG
// 256 color
if (i > 15 && i < 256) return this.colorTable256[i]
// true color, encoded as (hex) + 256 (such that #000 == 256)
if (i > 255) {
i -= 256
let red = (i >> 16) & 0xFF
let green = (i >> 8) & 0xFF
let blue = i & 0xFF
return `rgb(${red}, ${green}, ${blue})`
}
// return error color
return (Date.now() / 1000) % 2 === 0 ? '#f0f' : '#0f0'
return getColor(i, this.palette)
}
/**
@ -148,10 +170,8 @@ module.exports = class ScreenRenderer {
this.cursorBlinkOn = true
clearInterval(this.cursorBlinkInterval)
this.cursorBlinkInterval = setInterval(() => {
this.cursorBlinkOn = this.screen.cursor.blinking
? !this.cursorBlinkOn
: true
if (this.screen.cursor.blinking) this.scheduleDraw('cursor-blink')
this.cursorBlinkOn = this.cursor.blinking ? !this.cursorBlinkOn : true
if (this.cursor.blinking) this.scheduleDraw('cursor-blink')
}, 500)
}
@ -163,7 +183,7 @@ module.exports = class ScreenRenderer {
clearInterval(this.blinkInterval)
let intervals = 0
this.blinkInterval = setInterval(() => {
if (this.screen.blinkingCellCount <= 0) return
if (this.blinkingCellCount <= 0) return
intervals++
if (intervals >= 4 && this.blinkStyleOn) {
@ -189,9 +209,11 @@ module.exports = class ScreenRenderer {
* @param {number} options.isDefaultBG - if true, will draw image background if available
*/
drawBackground ({ x, y, cellWidth, cellHeight, bg, isDefaultBG }) {
const ctx = this.ctx
const { width, height } = this.screen.window
const padding = Math.round(this.screen._padding)
const { ctx, width, height, padding } = this
// is a double-width/double-height line
if (this.screenLines[y] & 0b001) cellWidth *= 2
ctx.fillStyle = this.getColor(bg)
let screenX = x * cellWidth + padding
let screenY = y * cellHeight + padding
@ -243,15 +265,14 @@ module.exports = class ScreenRenderer {
drawCharacter ({ x, y, charSize, cellWidth, cellHeight, text, fg, attrs }) {
if (!text) return
const ctx = this.ctx
const padding = Math.round(this.screen._padding)
const { ctx, padding } = this
let underline = false
let strike = false
let overline = false
if (attrs & ATTR_FAINT) ctx.globalAlpha = 0.5
if (attrs & ATTR_UNDERLINE) underline = true
if (attrs & ATTR_FRAKTUR) text = ScreenRenderer.alphaToFraktur(text)
if (attrs & ATTR_FRAKTUR) text = CanvasRenderer.alphaToFraktur(text)
if (attrs & ATTR_STRIKE) strike = true
if (attrs & ATTR_OVERLINE) overline = true
@ -260,6 +281,39 @@ module.exports = class ScreenRenderer {
let screenX = x * cellWidth + padding
let screenY = y * cellHeight + padding
const dblWidth = this.screenLines[y] & 0b001
const dblHeightTop = this.screenLines[y] & 0b010
const dblHeightBot = this.screenLines[y] & 0b100
if (this.screenLines[y]) {
// is a double-width/double-height line
if (dblWidth) cellWidth *= 2
ctx.save()
ctx.translate(padding, screenY + 0.5 * cellHeight)
if (dblWidth) ctx.scale(2, 1)
if (dblHeightTop) {
// 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()
if (dblHeightTop) ctx.rect(screenX, screenY, cellWidth, cellHeight / 2)
else ctx.rect(screenX, screenY + cellHeight / 2, cellWidth, cellHeight / 2)
ctx.clip()
}
}
let codePoint = text.codePointAt(0)
if (codePoint >= 0x2580 && codePoint <= 0x259F) {
// block elements
@ -434,6 +488,8 @@ module.exports = class ScreenRenderer {
ctx.stroke()
}
if (this.screenLines[y]) ctx.restore()
ctx.globalAlpha = 1
}
@ -444,7 +500,7 @@ module.exports = class ScreenRenderer {
* @returns {number[]} an array of cell indices
*/
getAdjacentCells (cell, radius = 1) {
const { width, height } = this.screen.window
const { width, height } = this
const screenLength = width * height
let cells = []
@ -470,7 +526,7 @@ module.exports = class ScreenRenderer {
height,
devicePixelRatio,
statusScreen
} = this.screen.window
} = this
if (statusScreen) {
// draw status screen instead
@ -479,15 +535,15 @@ module.exports = class ScreenRenderer {
return
} else this.stopDrawLoop()
const charSize = this.screen.getCharSize()
const { width: cellWidth, height: cellHeight } = this.screen.getCellSize()
const charSize = this.charSize
const { width: cellWidth, height: cellHeight } = this.cellSize
const screenLength = width * height
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawStart(why)
if (this.debug && this._debug) this._debug.drawStart(why)
ctx.font = this.screen.getFont()
ctx.font = this.fonts[0]
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
@ -504,34 +560,33 @@ module.exports = class ScreenRenderer {
let x = cell % width
let y = Math.floor(cell / width)
let isCursor = this.cursorBlinkOn &&
this.screen.cursor.x === x &&
this.screen.cursor.y === y &&
this.screen.cursor.visible
this.cursor.x === x &&
this.cursor.y === y &&
this.cursor.visible
let wasCursor = x === this.drawnCursor[0] && y === this.drawnCursor[1]
let inSelection = this.screen.isInSelection(x, y)
let text = this.screen.screen[cell]
let fg = this.screen.screenFG[cell] | 0
let bg = this.screen.screenBG[cell] | 0
let attrs = this.screen.screenAttrs[cell] | 0
let text = this.screen[cell]
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
if (!(attrs & ATTR_FG)) fg = this.defaultFgNum
if (!(attrs & ATTR_FG)) fg = this.defaultFG
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 (this.screen.reverseVideo) [fg, bg] = [bg, fg] // swap - reversed all screen
if (this.reverseVideo) [fg, bg] = [bg, fg] // swap - reversed all screen
if (attrs & ATTR_BLINK && !this.blinkStyleOn) {
// blinking is enabled and blink style is off
// set text to nothing so drawCharacter doesn't draw anything
text = ''
// set text to nothing so drawCharacter only draws decoration
text = ' '
}
if (inSelection) {
@ -543,9 +598,11 @@ module.exports = class ScreenRenderer {
fg !== this.drawnScreenFG[cell] || // foreground updated, and this cell has text
bg !== this.drawnScreenBG[cell] || // background updated
attrs !== this.drawnScreenAttrs[cell] || // attributes updated
isCursor !== wasCursor || // cursor blink/position updated
(isCursor && this.screen.cursor.style !== this.drawnCursor[2]) || // cursor style updated
(isCursor && this.screen.cursor.hanging !== this.drawnCursor[3]) // cursor hanging updated
this.screenLines[y] !== this.drawnScreenLines[y] || // line updated
// TODO: fix artifacts or keep this hack:
isCursor || wasCursor || // cursor blink/position 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
if (!fontGroups.has(font)) fontGroups.set(font, [])
@ -554,18 +611,41 @@ module.exports = class ScreenRenderer {
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
const redrawMap = new Map()
const maskedCells = new Map()
let isTextWide = text =>
text !== ' ' && ctx.measureText(text).width >= (cellWidth + 0.05)
// decide for each cell if it should be redrawn
let updateRedrawMapAt = cell => {
for (let cell of updateMap.keys()) {
let shouldUpdate = updateMap.get(cell) || redrawMap.get(cell) || false
// TODO: fonts (necessary?)
let text = this.screen.screen[cell]
let text = this.screen[cell]
let isWideCell = isTextWide(text)
let checkRadius = isWideCell ? 2 : 1
@ -577,8 +657,16 @@ module.exports = class ScreenRenderer {
// update this cell if:
// - the adjacent cell updated (For now, this'll always be true because characters can be slightly larger than they say they are)
// - the adjacent cell updated and this cell or the adjacent cell is wide
if (updateMap.get(adjacentCell) && (this.screen.window.graphics < 2 || isWideCell || isTextWide(this.screen.screen[adjacentCell]))) {
// - this or the adjacent cell is not double-sized
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
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
}
}
@ -586,33 +674,81 @@ module.exports = class ScreenRenderer {
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)
}
for (let cell of updateMap.keys()) updateRedrawMapAt(cell)
// mask to masked regions only
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++) {
let regionStart = null
for (let x = 0; x < width; x++) {
let cell = y * width + x
let redrawing = redrawMap.get(cell)
if (redrawing && regionStart === null) regionStart = x
if (!redrawing && regionStart !== null) {
ctx.rect(padding + regionStart * cellWidth, padding + y * cellHeight, (x - regionStart) * cellWidth, cellHeight)
if (debug) this.screen._debug.clipRect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight)
let masked = maskedCells.get(cell)
if (masked && regionStart === null) regionStart = x
if (!masked && regionStart !== null) {
regions.push([regionStart, y, x, y + 1])
regionStart = null
}
}
if (regionStart !== null) {
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)
regions.push([regionStart, y, width, y + 1])
}
}
// 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()
}
@ -625,28 +761,30 @@ module.exports = class ScreenRenderer {
if (redrawMap.get(cell)) {
this.drawBackground({ x, y, cellWidth, cellHeight, bg, isDefaultBG })
if (this.screen.window.debug && this.screen._debug) {
if (this.debug) {
// set cell flags
let flags = (+redrawMap.get(cell))
flags |= (+updateMap.get(cell)) << 1
flags |= (+isTextWide(text)) << 2
this.screen._debug.setCell(cell, flags)
flags |= (+maskedCells.get(cell)) << 2
flags |= (+isTextWide(text)) << 3
flags |= (+debugFilledUpdates.includes(cell)) << 4
this._debug.setCell(cell, flags)
}
}
}
}
// reset drawn cursor
this.drawnCursor = [-1, -1, -1]
this.drawnCursor = [-1, -1, '', false]
// pass 2: characters
for (let font of fontGroups.keys()) {
// set font once because in Firefox, this is a really slow action for some
// reason
let modifiers = {}
if (font & ATTR_BOLD) modifiers.weight = 'bold'
if (font & ATTR_ITALIC) modifiers.style = 'italic'
ctx.font = this.screen.getFont(modifiers)
let fontIndex = 0
if (font & ATTR_BOLD) fontIndex |= 1
if (font & ATTR_ITALIC) fontIndex |= 2
ctx.font = this.fonts[fontIndex]
for (let data of fontGroups.get(font)) {
let { cell, x, y, text, fg, bg, attrs, isCursor, inSelection } = data
@ -661,7 +799,7 @@ module.exports = class ScreenRenderer {
this.drawnScreenBG[cell] = bg
this.drawnScreenAttrs[cell] = attrs
if (isCursor) this.drawnCursor = [x, y, this.screen.cursor.style, this.screen.cursor.hanging]
if (isCursor) this.drawnCursor = [x, y, this.cursor.style, this.cursor.hanging]
// draw cursor
if (isCursor && !inSelection) {
@ -670,25 +808,30 @@ module.exports = class ScreenRenderer {
let cursorX = x
let cursorY = y
let cursorWidth = cellWidth // JS doesn't allow same-name assignment
if (this.screen.cursor.hanging) {
if (this.cursor.hanging) {
// draw hanging cursor in the margin
cursorX += 1
}
let screenX = cursorX * cellWidth + this.screen._padding
let screenY = cursorY * cellHeight + this.screen._padding
if (this.screen.cursor.style === 'block') {
// double-width lines
if (this.screenLines[cursorY] & 0b001) cursorWidth *= 2
let screenX = cursorX * cursorWidth + this.padding
let screenY = cursorY * cellHeight + this.padding
if (this.cursor.style === 'block') {
// block
ctx.rect(screenX, screenY, cellWidth, cellHeight)
} else if (this.screen.cursor.style === 'bar') {
ctx.rect(screenX, screenY, cursorWidth, cellHeight)
} else if (this.cursor.style === 'bar') {
// vertical bar
let barWidth = 2
ctx.rect(screenX, screenY, barWidth, cellHeight)
} else if (this.screen.cursor.style === 'line') {
} else if (this.cursor.style === 'line') {
// underline
let lineHeight = 2
ctx.rect(screenX, screenY + charSize.height, cellWidth, lineHeight)
ctx.rect(screenX, screenY + charSize.height, cursorWidth, lineHeight)
}
ctx.clip()
@ -708,35 +851,29 @@ module.exports = class ScreenRenderer {
}
}
if (this.screen.window.graphics >= 1) ctx.restore()
if (this.graphics >= 1) ctx.restore()
if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawEnd()
if (this.debug && this._debug) this._debug.drawEnd()
this.screen.emit('draw', why)
this.emit('draw', why)
}
drawStatus (statusScreen) {
const ctx = this.ctx
const {
fontFamily,
width,
height,
devicePixelRatio
} = this.screen.window
const { ctx, width, height, devicePixelRatio } = this
// reset drawnScreen to force redraw when statusScreen is disabled
this.drawnScreen = []
const cellSize = this.screen.getCellSize()
const screenWidth = width * cellSize.width + 2 * this.screen._padding
const screenHeight = height * cellSize.height + 2 * this.screen._padding
const cellSize = this.cellSize
const screenWidth = width * cellSize.width + 2 * this.padding
const screenHeight = height * cellSize.height + 2 * this.padding
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
ctx.fillStyle = this.getColor(this.defaultBgNum)
ctx.fillStyle = this.getColor(this.defaultBG)
ctx.fillRect(0, 0, screenWidth, screenHeight)
ctx.font = `24px ${fontFamily}`
ctx.fillStyle = this.getColor(this.defaultFgNum)
ctx.font = `24px ${this.statusFont}`
ctx.fillStyle = this.getColor(this.defaultFG)
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(statusScreen.title || '', screenWidth / 2, screenHeight / 2 - 50)
@ -746,7 +883,7 @@ module.exports = class ScreenRenderer {
ctx.save()
ctx.translate(screenWidth / 2, screenHeight / 2 + 20)
ctx.strokeStyle = this.getColor(this.defaultFgNum)
ctx.strokeStyle = this.getColor(this.defaultFG)
ctx.lineWidth = 5
ctx.lineCap = 'round'

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

@ -73,7 +73,7 @@ exports.buildColorTable = function () {
if (colorTable256 !== null) return colorTable256
// 256color lookup table
// should not be used to look up 0-15 (will return transparent)
// should not be used to look up 0-15
colorTable256 = new Array(16).fill('#000000')
// fill color table
@ -113,13 +113,39 @@ 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) {
if (/^\d+$/.test(shade)) {
shade = +shade
if (shade < 16) shade = themes[themeN][shade]
else {
shade = exports.buildColorTable()[shade]
}
return exports.getColor(shade, themes[themeN])
}
return shade
}

@ -9,9 +9,7 @@ function selectedTheme () {
exports.init = function () {
$('#theme').on('change', showColor)
$('#default_fg').on('input', showColor)
$('#default_bg').on('input', showColor)
$('#default_fg,#default_bg').on('input', showColor)
let opts = {
padding: 10,
@ -27,6 +25,9 @@ exports.init = function () {
ColorTriangle.initInput(qs('#default_fg'), 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 () {
const bg = this.dataset.bg

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

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

@ -81,6 +81,10 @@ return [
'term.debugbar' => 'Rozšířené ladění',
'term.ascii_debug' => 'Ladění vstupních dat',
'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_steady' => 'Blok, stálý',

@ -79,7 +79,7 @@ return [
'term.colors_preview' => '',
'term.debugbar' => 'Debug-Leiste anzeigen',
'term.ascii_debug' => 'Kontrollcodes anzeigen',
'term.backdrop' => 'URL Hintergrundbild',
'term.backdrop' => 'Hintergrundbild-URL',
'cursor.block_blink' => 'Block, blinkend',
'cursor.block_steady' => 'Block, ruhig',

@ -80,6 +80,10 @@ return [
'term.debugbar' => 'Debug internal state',
'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_steady' => 'Block, steady',
@ -229,6 +233,11 @@ return [
'persist.restore_hard_explain' =>
'(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.title' => 'Serial Port Parameters',

@ -0,0 +1,272 @@
<?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és:',
'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',
'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.)',
// 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-bite',
'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',
// Generic button / dialog labels
'apply' => 'Alkalmaz',
'start' => 'Start',
'cancel' => 'Mégse',
'enabled' => 'Engedélyez',
'disabled' => 'Letilt',
'yes' => 'Igen',
'no' => 'Nem',
'confirm' => 'OK',
'copy' => 'Másolás',
'form_errors' => 'Validációs hiba:',
];

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

@ -27,6 +27,29 @@
</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 -->
<form class="Box str mobcol" action="<?= e(url('system_set')) ?>" method="GET" id="form-hw">
<h2 tabindex=0><?= tr('hwtuning.title') ?></h2>

@ -134,18 +134,18 @@
<div class="Row">
<div class="SubRow">
<label for="term_width"><?= tr('term.term_width') ?></label>
<input type="number" step=1 min=1 max=255 name="term_width" id="term_width" value="%term_width%" required>
<label for="width"><?= tr('term.term_width') ?></label>
<input type="number" step=1 min=1 max=255 name="width" id="width" value="%width%" required>
</div>
<div class="SubRow">
<label for="term_height"><?= tr('term.term_height') ?></label>
<input type="number" step=1 min=1 max=255 name="term_height" id="term_height" value="%term_height%" required>
<label for="height"><?= tr('term.term_height') ?></label>
<input type="number" step=1 min=1 max=255 name="height" id="height" value="%height%" required>
</div>
</div>
<div class="Row">
<label for="term_title"><?= tr('term.term_title') ?></label>
<input type="text" name="term_title" id="term_title" value="%h:term_title%" required>
<label for="title"><?= tr('term.term_title') ?></label>
<input type="text" name="title" id="title" value="%h:title%" required>
</div>
<div class="Row checkbox" >
@ -154,6 +154,11 @@
<input type="hidden" id="show_buttons" name="show_buttons" value="%show_buttons%">
</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">
<label><?= tr("term.buttons") ?></label>
<input class="tiny" type="text" name="btn1" id="btn1" value="%h:btn1%">
@ -172,6 +177,15 @@
<input class="tiny" type="text" name="bm5" id="bm5" value="%h:bm5%">
</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>
@ -203,8 +217,8 @@
</div>
<div class="Row">
<label for="uart_baud"><?= tr('uart.baud') ?><span class="mq-phone">&nbsp;(bps)</span></label>
<select name="uart_baud" id="uart_baud" class="short">
<label for="uart_baudrate"><?= tr('uart.baud') ?><span class="mq-phone">&nbsp;(bps)</span></label>
<select name="uart_baudrate" id="uart_baudrate" class="short">
<?php
foreach([
300, 600, 1200, 2400, 4800, 9600, 19200, 38400,
@ -257,6 +271,17 @@
<?= tr('term.explain_expert') ?>
</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">
<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>
@ -320,7 +345,7 @@
$.ready(function () {
$('#cursor_shape').val('%cursor_shape%');
$('#theme').val('%theme%');
$('#uart_baud').val('%uart_baud%');
$('#uart_baudrate').val('%uart_baudrate%');
$('#uart_parity').val('%uart_parity%');
$('#uart_stopbits').val('%uart_stopbits%');

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

@ -8,6 +8,8 @@
Those changes are not retained after restart.
</p>
<h3>Single-byte commands &amp; queries</h3>
<table class="ansiref w100">
<thead><tr><th>Code</th><th>Meaning</th></tr></thead>
<tbody>
@ -28,17 +30,6 @@
This message contains the curretn version, unique ID, and the IP address if in Client mode.
</td>
</tr>
<tr>
<td>`\ec`</td>
<td>
Clear screen, reset attributes and cursor. This command also restores the default
screen size, title, button labels and messages and the background URL.
</td>
</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>
<td>`\e[5n`</td>
<td>
@ -46,6 +37,14 @@
Can be used to check if the terminal has booted up and is ready to receive commands.
</td>
</tr>
</tbody>
</table>
<h3>Setting parameters</h3>
<table class="ansiref w100">
<thead><tr><th>Code</th><th>Meaning</th></tr></thead>
<tbody>
<tr>
<td>`\e[<i>n</i> q`</td>
<td>
@ -60,7 +59,7 @@
<td>Set screen title to _t_ (this is a standard OSC command)</td>
</tr>
<tr>
<td>`\e]70;<i>u</i>\a`</td>
<td>`\e]27;1;<i>u</i>\a`</td>
<td>
Set background image to URL _u_ (including protocol)
that can be resolved by the user's browser. The image will be scaled
@ -70,81 +69,65 @@
</td>
</tr>
<tr>
<td>`\e]27;2;<i>n</i>\a`</td>
<td>
<code>
\e]<i>8x</i>;<i>t</i>\a
</code>
Set number of visible buttons to _n_ (0-5). To hide/show the entire buttons bar,
use the dedicated hiding commands (see below)
</td>
</tr>
<tr>
<td><code>
\e]28;<i>x</i>;<i>t</i>\a
</code></td>
<td>
Set label for button 1-5 (code 81-85) to _t_ - e.g.`\e]81;Yes\a`
Set label for button _x_ (1-5) to _t_ - e.g.`\e]28;1;Yes\a`
sets the first button text to "Yes".
</td>
</tr>
<tr>
<td><code>
\e]29;<i>x</i>;<i>m</i>\a
</code></td>
<td>
<code>
\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`
Set message for button _x_ (1-5) to _m_ - e.g.`\e]29;3;+\a`
sets the 3rd button to send "+" when pressed. The message can be up to
10 bytes long.
</td>
</tr>
<tr>
<td><code>
\e]30;<i>x</i>;<i>c</i>\a
</code></td>
<td>
<code>
\e]9;<i>t</i>\a
</code>
</td>
<td>
Show a notification with text _t_. This will be either a desktop notification
or a pop-up balloon.
</td>
</tr>
<tr>
<td>
<code>
\e[?<i>n</i>s \\
\e[?<i>n</i>r
</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.
Set button _x_ (1-5) color to _c_ - e.g.`\e]30;2;#00FF00\a`
makes the 2nd button green. Supported are SGR colors 1-255
and TrueColor in the format `#RRGGBB`. Use 0 to
reset to the default color.
</td>
</tr>
<tr>
<td>
<code>
<td><code>
\e[?800h \\
\e[?800l
</code>
</td>
</code></td>
<td>
Show (`h`) or hide (`l`) the action buttons (the blue buttons under the screen).
</td>
</tr>
<tr>
<td>
<code>
<td><code>
\e[?801h \\
\e[?801l
</code>
</td>
</code></td>
<td>
Show (`h`) or hide (`l`) menu/help links under the screen.
</td>
</tr>
<tr>
<td>
<code>
<td><code>
\e[?2004h \\
\e[?2004l
</code>
</td>
</code></td>
<td>
Enable (`h`) or disable (`l`) Bracketed Paste mode.
This mode makes any text sent using the Upload Tool be preceded by `\e[200\~`
@ -153,28 +136,41 @@
</td>
</tr>
<tr>
<td><code>
\e[12h \\
\e[12l
</code></td>
<td>
<code>
\e[?1049h \\
\e[?1049l
</code>
</td>
<td>
Switch to (`h`) or from (`l`) an alternate screen.
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.
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>
</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>
<code>
\e[12h \\
\e[12l
</code>
Show a notification with text _t_. This will be either a desktop notification
or a pop-up balloon.
</td>
</tr>
<tr>
<td><code>
\e[?<i>n</i>s \\
\e[?<i>n</i>r
</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.
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>
</tr>
</tbody>

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

@ -22,6 +22,11 @@ form { @include naked(); }
}
}
.Box.errmessage {
@extend .Box.message;
color: crimson;
}
.Row {
vertical-align: middle;
margin: 12px auto;
@ -110,6 +115,14 @@ form { @include naked(); }
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 {
display: inline-block;
vertical-align: top;

@ -106,18 +106,118 @@ body.term {
.debug-canvas {
position: absolute;
top: 6px;
left: 6px;
top: 0;
left: 0;
pointer-events: none;
}
.debug-tooltip {
position: absolute;
top: 0;
left: 0;
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 {
line-height: 1.5;
line-height: 1.2;
text-align: left;
margin: 6px 12px 12px 12px;
padding: 6px;
font-family: $screen-stack;
background: #fff;
color: #000;
border-radius: 6px;
font-size: 12px;
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;
}
}
}
}
}
}

@ -10,7 +10,7 @@ let devtool = 'source-map'
if (process.env.ESP_PROD) {
// ignore demo
plugins.push(new webpack.IgnorePlugin(/\.\/demo(?:\.js)?$/))
plugins.push(new webpack.IgnorePlugin(/(term|\.)\/demo(?:\.js)?$/))
// no source maps
devtool = ''

Loading…
Cancel
Save