diff --git a/_debug_replacements.php b/_debug_replacements.php
index 3e99899..e393c6d 100644
--- a/_debug_replacements.php
+++ b/_debug_replacements.php
@@ -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,
diff --git a/_pages.php b/_pages.php
index b45d23f..1d3d040 100644
--- a/_pages.php
+++ b/_pages.php
@@ -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', '', '/', '');
diff --git a/js/appcommon.js b/js/appcommon.js
index 7b99806..906e5a2 100644
--- a/js/appcommon.js
+++ b/js/appcommon.js
@@ -90,41 +90,65 @@ $.ready(function () {
e.preventDefault()
})
- // 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(',')
- let humanReadableErrors = []
- errs.forEach(function (er) {
- let lbls = qsa('label[for="' + er + '"]')
- if (lbls) {
- 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 {
- humanReadableErrors.push(er)
+ try {
+ do {
+ let msgAt, box
+ // populate the form errors box from GET arg ?err=...
+ // (a way to pass errors back from server via redirect)
+ 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 && 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
}
- })
- qs('.Box.errors .list').innerHTML = humanReadableErrors.join(', ')
- qs('.Box.errors').classList.remove('hidden')
- }
+ let fademsgbox = function (box, time) {
+ box.classList.remove('hidden')
+ setTimeout(() => {
+ box.classList.add('hiding')
+ setTimeout(() => {
+ box.classList.add('hidden')
+ }, 1000)
+ }, time)
+ }
- 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
- box.classList.remove('hidden')
- setTimeout(() => {
- box.classList.add('hiding')
- setTimeout(() => {
- box.classList.add('hidden')
- }, 1000)
- }, 2000)
+ 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()
diff --git a/js/term/buttons.js b/js/term/buttons.js
index 32813dd..dac4a63 100644
--- a/js/term/buttons.js
+++ b/js/term/buttons.js
@@ -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()
+ }
+ }
}
diff --git a/js/term/connection.js b/js/term/connection.js
index d53966c..733d177 100644
--- a/js/term/connection.js
+++ b/js/term/connection.js
@@ -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…')
diff --git a/js/term/debug.js b/js/term/debug.js
new file mode 100644
index 0000000..a2ed786
--- /dev/null
+++ b/js/term/debug.js
@@ -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()
+ }
+ })
+}
diff --git a/js/term/debug_screen.js b/js/term/debug_screen.js
deleted file mode 100644
index 680690c..0000000
--- a/js/term/debug_screen.js
+++ /dev/null
@@ -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()
- })
-}
diff --git a/js/term/demo.js b/js/term/demo.js
index 7135e83..fd13a74 100644
--- a/js/term/demo.js
+++ b/js/term/demo.js
@@ -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 () {
diff --git a/js/term/index.js b/js/term/index.js
index a382d1a..76f723a 100644
--- a/js/term/index.js
+++ b/js/term/index.js
@@ -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
diff --git a/js/term/input.js b/js/term/input.js
index 1f319c7..bb0d0d1 100644
--- a/js/term/input.js
+++ b/js/term/input.js
@@ -1,4 +1,3 @@
-const $ = require('../lib/chibi')
const { encode2B } = require('../utils')
/**
diff --git a/js/term/screen.js b/js/term/screen.js
index 2fb13eb..ee3e448 100644
--- a/js/term/screen.js
+++ b/js/term/screen.js
@@ -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!')
- }
- })
- // 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!')
+ this._window = {
+ width: 0,
+ height: 0,
+ // two bits. LSB: debug enabled by user, MSB: debug enabled by server
+ debug: 0,
+ statusScreen: null
+ }
+
+ // 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]
+ 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)
}
- /**
- * 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))
+ updateLayout () {
+ this.layout.window.width = this.window.width
+ this.layout.window.height = this.window.height
}
- /**
- * 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()
+ renderScreen (reason) {
+ let selection = []
- return {
- width: Math.floor(this.ctx.measureText(' ').width),
- height: this.window.fontSize
+ for (let cell = 0; cell < this.screen.length; cell++) {
+ selection.push(this.isInSelection(cell % this.window.width, Math.floor(cell / this.window.width)))
}
- }
-
- /**
- * 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)
- }
+ 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
+ })
}
- /**
- * 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
- }
- }
-
- // 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')
}
}
diff --git a/js/term/screen_layout.js b/js/term/screen_layout.js
new file mode 100644
index 0000000..e78a1f6
--- /dev/null
+++ b/js/term/screen_layout.js
@@ -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)
+ }
+}
diff --git a/js/term/screen_parser.js b/js/term/screen_parser.js
index 93f4b09..9773dbc 100644
--- a/js/term/screen_parser.js
+++ b/js/term/screen_parser.js
@@ -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++])
- this.screen.input.setAlts(
+ const cursorVisible = !!(attributes & OPT_CURSOR_VISIBLE)
+
+ // 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
}
}
diff --git a/js/term/screen_renderer.js b/js/term/screen_renderer.js
index 0a174bd..a5482a5 100644
--- a/js/term/screen_renderer.js
+++ b/js/term/screen_renderer.js
@@ -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
-
- this._palette = null // colors 0-15
- this.defaultBgNum = 0
- this.defaultFgNum = 7
-
- // 256color lookup table
- // should not be used to look up 0-15 (will return transparent)
- this.colorTable256 = buildColorTable()
+/**
+ * A terminal screen renderer, using canvas 2D
+ */
+module.exports = class CanvasRenderer extends EventEmitter {
+ constructor (canvas) {
+ super()
+
+ this.canvas = canvas
+ this.ctx = this.canvas.getContext('2d')
+
+ 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'
diff --git a/js/term/soft_keyboard.js b/js/term/soft_keyboard.js
index 9d89960..f5d78a9 100644
--- a/js/term/soft_keyboard.js
+++ b/js/term/soft_keyboard.js
@@ -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)`
}
diff --git a/js/term/themes.js b/js/term/themes.js
index 8fb47ac..6828beb 100644
--- a/js/term/themes.js
+++ b/js/term/themes.js
@@ -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
}
diff --git a/js/term_conf.js b/js/term_conf.js
index 87d9d70..af011ab 100644
--- a/js/term_conf.js
+++ b/js/term_conf.js
@@ -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
diff --git a/js/wifi.js b/js/wifi.js
index 16d5e06..656ca6b 100644
--- a/js/wifi.js
+++ b/js/wifi.js
@@ -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)
}
}
diff --git a/lang/common.php b/lang/common.php
index f2d6c75..59380bd 100644
--- a/lang/common.php
+++ b/lang/common.php
@@ -16,4 +16,6 @@ return [
'menu.restore_hard' => '',
'menu.reset_screen' => '',
'menu.index' => '',
+ 'menu.ini_export' => '',
+ 'menu.ini_import' => '',
];
diff --git a/lang/cs.php b/lang/cs.php
index fb10e7e..ae146bb 100644
--- a/lang/cs.php
+++ b/lang/cs.php
@@ -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ý',
diff --git a/lang/de.php b/lang/de.php
index 7f7a422..4a44aa3 100644
--- a/lang/de.php
+++ b/lang/de.php
@@ -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',
diff --git a/lang/en.php b/lang/en.php
index c200871..b23b26f 100644
--- a/lang/en.php
+++ b/lang/en.php
@@ -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',
diff --git a/lang/hu.php b/lang/hu.php
new file mode 100644
index 0000000..eac77a1
--- /dev/null
+++ b/lang/hu.php
@@ -0,0 +1,272 @@
+ '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 (\ec
).
+ 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 (SRM)',
+ 'term.crlf_mode' => 'Enter = CR+LF (LNM)',
+ 'term.want_all_fn' => 'F5, F11, F12 elfogása',
+ 'term.button_msgs' => 'Gomb kódok
(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.
+ 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.
+ 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 MHz-ről 160 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:',
+];
diff --git a/pages/_head.php b/pages/_head.php
index 8ca2eb1..42c7892 100644
--- a/pages/_head.php
+++ b/pages/_head.php
@@ -33,5 +33,6 @@ if (strpos($_GET['BODYCLASS'], 'cfg') !== false) {