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) { + diff --git a/pages/cfg_system.php b/pages/cfg_system.php index edae7c7..e22408c 100644 --- a/pages/cfg_system.php +++ b/pages/cfg_system.php @@ -27,6 +27,29 @@ + +
+

+ +
+ +
+ +
+ + + +
+ +
+
+ +
+
+
+

diff --git a/pages/cfg_term.php b/pages/cfg_term.php index ad2bc8b..a26f29b 100644 --- a/pages/cfg_term.php +++ b/pages/cfg_term.php @@ -134,18 +134,18 @@
- - + +
- - + +
- - + +
@@ -154,6 +154,11 @@
+
+ + +
+
@@ -172,6 +177,15 @@
+
+ + + + + + +
+
@@ -203,8 +217,8 @@
- -
+
+ + +
+ +
+ + +  px +
+
@@ -320,7 +345,7 @@ $.ready(function () { $('#cursor_shape').val('%cursor_shape%'); $('#theme').val('%theme%'); - $('#uart_baud').val('%uart_baud%'); + $('#uart_baudrate').val('%uart_baudrate%'); $('#uart_parity').val('%uart_parity%'); $('#uart_stopbits').val('%uart_stopbits%'); diff --git a/pages/help/cmd_screen.php b/pages/help/cmd_screen.php index d19c0b3..dc1bc91 100644 --- a/pages/help/cmd_screen.php +++ b/pages/help/cmd_screen.php @@ -8,60 +8,124 @@ If an argument is left out, it's treated as 0 or 1, depending on what makes sense for the command.

+

Erasing & Inserting

+ + + + + + + + + + + Insert (`@`) or delete (`P`) _n_ characters. The rest of the line is pulled left or pushed right. + Characters going past the end of line are lost. + + + +
CodeMeaning
`\e[mJ` - `\e[mJ` + Clear part of screen. _m_: 0 - from cursor, 1 - to cursor, 2 - all
`\e[mK` - Clear part of screen. _m_: 0 - from cursor, 1 - to cursor, 2 - all + Erase part of line. _m_: 0 - from cursor, 1 - to cursor, 2 - all
`\e[nX` - `\e[mK` + Erase _n_ characters in line.
+ \e[nL \\ + \e[nM + - Erase part of line. _m_: 0 - from cursor, 1 - to cursor, 2 - all + Insert (`L`) or delete (`M`) _n_ lines. Following lines are pulled up or pushed down.
+ \e[n@ \\ + \e[nP + - `\e[nX`
+ +

Supersized lines

+ + + + + + + + Make the current line part of a double-width, double-height line. + Use `3` for the top, `4` for the bottom half. + + + + + + + +
CodeMeaning
`\e#1`, `\e#2` - Erase _n_ characters in line. + Make the current line part of a double-height line. + Use `1` for the top, `2` for the bottom half.
`\e#3`, `\e#4` - `\e[nb`
`\e#6` - Repeat last printed characters _n_ times (moving cursor and using the current style). + Make the current line double-width.
`\e#5` - - \e[nL \\ - \e[nM - + Reset the current line to normal size.
+ +

Other

+ + + + + + + + + + + + + + + + + + diff --git a/pages/help/cmd_system.php b/pages/help/cmd_system.php index 796082f..7633136 100644 --- a/pages/help/cmd_system.php +++ b/pages/help/cmd_system.php @@ -8,6 +8,8 @@ Those changes are not retained after restart.

+

Single-byte commands & queries

+
CodeMeaning
`\ec` - Insert (`L`) or delete (`M`) _n_ lines. Following lines are pulled up or pushed down. + Clear screen, reset attributes and cursor. This command also restores the default + screen size, title, button labels and messages and the background URL.
+ \e[?1049h \\ + \e[?1049l + - - \e[n@ \\ - \e[nP - + Switch to (`h`) or from (`l`) an alternate screen. + ESPTerm can't implement this fully, so the original screen content is not saved, + but it will remember the cursor, screen size, terminal title, button labels and messages.
`\e[8;r;ct`Set screen size to _r_ rows and _c_ columns (this is a command borrowed from Xterm)
- Insert (`@`) or delete (`P`) _n_ characters. The rest of the line is pulled left or pushed right. - Characters going past the end of line are lost. + `\e[nb` + Repeat last printed characters _n_ times (moving cursor and using the current style). +
`\e#8` + Reset all screen attributes to default and fill the screen with the letter "E". This was + historically used for aligning CRT displays, now can be useful e.g. for testing erasing commands.
@@ -28,17 +30,6 @@ This message contains the curretn version, unique ID, and the IP address if in Client mode. - - - - - - - - + +
CodeMeaning
`\ec` - Clear screen, reset attributes and cursor. This command also restores the default - screen size, title, button labels and messages and the background URL. -
`\e[8;r;ct`Set screen size to _r_ rows and _c_ columns (this is a command borrowed from Xterm)
`\e[5n` @@ -46,6 +37,14 @@ Can be used to check if the terminal has booted up and is ready to receive commands.
+ +

Setting parameters

+ + + + - + + + + + + - + - - - - - - + - + - + + - + +
CodeMeaning
`\e[n q` @@ -60,7 +59,7 @@ Set screen title to _t_ (this is a standard OSC command)
`\e]70;u\a``\e]27;1;u\a` Set background image to URL _u_ (including protocol) that can be resolved by the user's browser. The image will be scaled @@ -70,81 +69,65 @@
`\e]27;2;n\a` - - \e]8x;t\a - + Set number of visible buttons to _n_ (0-5). To hide/show the entire buttons bar, + use the dedicated hiding commands (see below)
+ \e]28;x;t\a + - Set label for button 1-5 (code 81-85) to _t_ - e.g.`\e]81;Yes\a` + Set label for button _x_ (1-5) to _t_ - e.g.`\e]28;1;Yes\a` sets the first button text to "Yes".
+ \e]29;x;m\a + - - \e]9x;m\a - - - Set message for button 1-5 (code 91-95) to _m_ - e.g.`\e]94;+\a` + Set message for button _x_ (1-5) to _m_ - e.g.`\e]29;3;+\a` sets the 3rd button to send "+" when pressed. The message can be up to 10 bytes long.
+ \e]30;x;c\a + - - \e]9;t\a - - - Show a notification with text _t_. This will be either a desktop notification - or a pop-up balloon. + Set button _x_ (1-5) color to _c_ - e.g.`\e]30;2;#00FF00\a` + makes the 2nd button green. Supported are SGR colors 1-255 + and TrueColor in the format `#RRGGBB`. Use 0 to + reset to the default color.
- - \e[?ns \\ - \e[?nr - - - Save (`s`) and restore (`r`) any option set using `CSI ? n h`. - This is used by some applications to back up the original state before - making changes. -
- - \e[?800h \\ - \e[?800l - - + \e[?800h \\ + \e[?800l + Show (`h`) or hide (`l`) the action buttons (the blue buttons under the screen).
- - \e[?801h \\ - \e[?801l - - + \e[?801h \\ + \e[?801l + Show (`h`) or hide (`l`) menu/help links under the screen.
- - \e[?2004h \\ - \e[?2004l - - + \e[?2004h \\ + \e[?2004l + Enable (`h`) or disable (`l`) Bracketed Paste mode. This mode makes any text sent using the Upload Tool be preceded by `\e[200\~` @@ -153,28 +136,41 @@
+ \e[12h \\ + \e[12l + - - \e[?1049h \\ - \e[?1049l - - - Switch to (`h`) or from (`l`) an alternate screen. - ESPTerm can't implement this fully, so the original screen content is not saved, - but it will remember the cursor, screen size, terminal title, button labels and messages. + Enable (`h`) or disable (`l`) Send-Receive Mode (SRM). + SRM is the opposite of Local Echo, meaning `\e[12h` disables and `\e[12l` enables Local Echo.
+ +

Other

+ + + + + + + + diff --git a/pages/term.php b/pages/term.php index 9a2170d..7f82909 100644 --- a/pages/term.php +++ b/pages/term.php @@ -44,7 +44,7 @@ -

+

ESPTerm

@@ -59,7 +59,7 @@
-
+
CodeMeaning
+ \e]9;t\a + - - \e[12h \\ - \e[12l - + Show a notification with text _t_. This will be either a desktop notification + or a pop-up balloon.
+ \e[?ns \\ + \e[?nr + - Enable (`h`) or disable (`l`) Send-Receive Mode (SRM). - SRM is the opposite of Local Echo, meaning `\e[12h` disables and `\e[12l` enables Local Echo. + Save (`s`) and restore (`r`) any option set using `CSI ? n h`. + This is used by some applications to back up the original state before + making changes.