ESPTerm web interface submodule, separated to make testing and development easier
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
espterm-front-end/js/term_screen.js

1481 lines
46 KiB

const EventEmitter = require('events')
const $ = require('./lib/chibi')
const { mk, qs, parse2B, parse3B } = require('./utils')
const notify = require('./notif')
const { themes, buildColorTable } = require('./themes')
// constants for decoding the update blob
const SEQ_REPEAT = 2
const SEQ_SET_COLORS = 3
const SEQ_SET_ATTRS = 4
const SEQ_SET_FG = 5
const SEQ_SET_BG = 6
const SELECTION_BG = '#b2d7fe'
const SELECTION_FG = '#333'
module.exports = class TermScreen extends EventEmitter {
constructor () {
super()
// Some non-bold Fraktur symbols are outside the contiguous block
this.frakturExceptions = {
'C': '\u212d',
'H': '\u210c',
'I': '\u2111',
'R': '\u211c',
'Z': '\u2128'
}
// 256color lookup table
// should not be used to look up 0-15 (will return transparent)
this.colorTable256 = buildColorTable()
this._debug = null
this.contentLoaded = false
this.canvas = mk('canvas')
this.ctx = this.canvas.getContext('2d')
if ('AudioContext' in window || 'webkitAudioContext' in window) {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)()
} else {
console.warn('No AudioContext!')
}
// dummy
this.input = new Proxy({}, {
get () {
return () => console.warn('TermScreen#input not set!')
}
})
this.cursor = {
x: 0,
y: 0,
blinkOn: false,
blinking: true,
visible: true,
hanging: false,
style: 'block',
blinkInterval: null
}
this._palette = null
this._window = {
width: 0,
height: 0,
devicePixelRatio: 1,
fontFamily: '"DejaVu Sans Mono", "Liberation Mono", "Inconsolata", "Menlo", monospace',
fontSize: 20,
gridScaleX: 1.0,
gridScaleY: 1.2,
blinkStyleOn: true,
blinkInterval: null,
fitIntoWidth: 0,
fitIntoHeight: 0,
debug: false,
graphics: 0,
statusScreen: null
}
7 years ago
// scaling caused by fitIntoWidth/fitIntoHeight
this._windowScale = 1
// properties of this.window that require updating size and redrawing
this.windowState = {
width: 0,
height: 0,
devicePixelRatio: 0,
gridScaleX: 0,
gridScaleY: 0,
fontFamily: '',
fontSize: 0,
fitIntoWidth: 0,
fitIntoHeight: 0
}
// current selection
this.selection = {
// when false, this will prevent selection in favor of mouse events,
// though alt can be held to override it
selectable: true,
// selection start and end (x, y) tuples
start: [0, 0],
end: [0, 0]
}
// mouse features
this.mouseMode = { clicks: false, movement: false }
// make writing to window update size and draw
const self = this
this.window = new Proxy(this._window, {
set (target, key, value, receiver) {
target[key] = value
self.scheduleSizeUpdate()
self.scheduleDraw(`window:${key}=${value}`)
return true
}
})
this.bracketedPaste = false
this.blinkingCellCount = 0
this.screen = []
this.screenFG = []
this.screenBG = []
this.screenAttrs = []
// used to determine if a cell should be redrawn; storing the current state
// as it is on screen
this.drawnScreen = []
this.drawnScreenFG = []
this.drawnScreenBG = []
this.drawnScreenAttrs = []
this.drawnCursor = [-1, -1, '']
// start blink timers
this.resetBlink()
this.resetCursorBlink()
let selecting = false
let selectStart = (x, y) => {
if (selecting) return
selecting = true
this.selection.start = this.selection.end = this.screenToGrid(x, y, true)
this.scheduleDraw('select-start')
}
let selectMove = (x, y) => {
if (!selecting) return
this.selection.end = this.screenToGrid(x, y, true)
this.scheduleDraw('select-move')
}
let selectEnd = (x, y) => {
if (!selecting) return
selecting = false
this.selection.end = this.screenToGrid(x, y, true)
this.scheduleDraw('select-end')
Object.assign(this.selection, this.getNormalizedSelection())
}
// bind event listeners
this.canvas.addEventListener('mousedown', e => {
if ((this.selection.selectable || e.altKey) && e.button === 0) {
selectStart(e.offsetX, e.offsetY)
} else {
this.input.onMouseDown(...this.screenToGrid(e.offsetX, e.offsetY),
e.button + 1)
}
})
window.addEventListener('mousemove', e => {
selectMove(e.offsetX, e.offsetY)
})
window.addEventListener('mouseup', e => {
selectEnd(e.offsetX, e.offsetY)
})
// touch event listeners
let touchPosition = null
let touchDownTime = 0
let touchSelectMinTime = 500
let touchDidMove = false
let getTouchPositionOffset = touch => {
let rect = this.canvas.getBoundingClientRect()
return [touch.clientX - rect.left, touch.clientY - rect.top]
}
this.canvas.addEventListener('touchstart', e => {
touchPosition = getTouchPositionOffset(e.touches[0])
touchDidMove = false
touchDownTime = Date.now()
})
this.canvas.addEventListener('touchmove', e => {
touchPosition = getTouchPositionOffset(e.touches[0])
if (!selecting && touchDidMove === false) {
if (touchDownTime < Date.now() - touchSelectMinTime) {
selectStart(...touchPosition)
}
} else if (selecting) {
e.preventDefault()
selectMove(...touchPosition)
}
touchDidMove = true
})
this.canvas.addEventListener('touchend', e => {
if (e.touches[0]) {
touchPosition = getTouchPositionOffset(e.touches[0])
}
if (selecting) {
e.preventDefault()
selectEnd(...touchPosition)
// selection ended; show touch select menu
let touchSelectMenu = qs('#touch-select-menu')
touchSelectMenu.classList.add('open')
let rect = touchSelectMenu.getBoundingClientRect()
// use middle position for x and one line above for y
let selectionPos = this.gridToScreen(
(this.selection.start[0] + this.selection.end[0]) / 2,
this.selection.start[1] - 1
)
selectionPos[0] -= rect.width / 2
selectionPos[1] -= rect.height / 2
touchSelectMenu.style.transform = `translate(${selectionPos[0]}px, ${
selectionPos[1]}px)`
}
if (!touchDidMove) {
this.emit('tap', Object.assign(e, {
x: touchPosition[0],
y: touchPosition[1]
}))
}
touchPosition = null
})
this.on('tap', e => {
if (this.selection.start[0] !== this.selection.end[0] ||
this.selection.start[1] !== this.selection.end[1]) {
// selection is not empty
// reset selection
this.selection.start = this.selection.end = [0, 0]
qs('#touch-select-menu').classList.remove('open')
this.scheduleDraw('select-reset')
} else {
e.preventDefault()
this.emit('open-soft-keyboard')
}
})
$.ready(() => {
let copyButton = qs('#touch-select-copy-btn')
if (copyButton) {
copyButton.addEventListener('click', () => {
this.copySelectionToClipboard()
})
}
})
this.canvas.addEventListener('mousemove', e => {
if (!selecting) {
this.input.onMouseMove(...this.screenToGrid(e.offsetX, e.offsetY))
}
})
this.canvas.addEventListener('mouseup', e => {
if (!selecting) {
this.input.onMouseUp(...this.screenToGrid(e.offsetX, e.offsetY),
e.button + 1)
}
})
this.canvas.addEventListener('wheel', e => {
if (this.mouseMode.clicks) {
this.input.onMouseWheel(...this.screenToGrid(e.offsetX, e.offsetY),
e.deltaY > 0 ? 1 : -1)
// prevent page scrolling
e.preventDefault()
}
})
this.canvas.addEventListener('contextmenu', e => {
if (this.mouseMode.clicks) {
// prevent mouse keys getting stuck
e.preventDefault()
}
selectEnd(e.offsetX, e.offsetY)
})
}
/**
* The color palette. Should define 16 colors in an array.
* @type {number[]}
*/
get palette () {
return this._palette || themes[0]
}
/** @type {number[]} */
set palette (palette) {
if (this._palette !== palette) {
this._palette = palette
this.scheduleDraw('palette')
}
}
/**
* Returns the specified color. If `i` is in the palette, it will return the
* palette color. If `i` is between 16 and 255, it will return the 256color
* value. If `i` is larger than 255, it will return an RGB color value. If `i`
* is -1 (foreground) or -2 (background), it will return the selection colors.
* @param {number} i - the color
* @returns {string} the CSS color
*/
getColor (i) {
// return palette color if it exists
if (this.palette[i]) return this.palette[i]
// -1 for selection foreground, -2 for selection background
if (i === -1) return SELECTION_FG
if (i === -2) return SELECTION_BG
// 256 color
if (i > 15 && i < 256) return 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})`
}
// default to transparent
return 'rgba(0, 0, 0, 0)'
}
/**
* Schedule a size update in the next millisecond
*/
scheduleSizeUpdate () {
clearTimeout(this._scheduledSizeUpdate)
this._scheduledSizeUpdate = setTimeout(() => this.updateSize(), 1)
}
/**
* Schedule a draw in the next millisecond
* @param {string} why - the reason why the draw occured (for debugging)
* @param {number} [aggregateTime] - time to wait for more scheduleDraw calls
* to occur. 1 ms by default.
*/
scheduleDraw (why, aggregateTime = 1) {
clearTimeout(this._scheduledDraw)
this._scheduledDraw = setTimeout(() => this.draw(why), aggregateTime)
}
/**
* Returns a CSS font string with this TermScreen's font settings and the
* font modifiers.
* @param {Object} modifiers
* @param {string} [modifiers.style] - the font style
* @param {string} [modifiers.weight] - the font weight
* @returns {string} a CSS font string
*/
getFont (modifiers = {}) {
let fontStyle = modifiers.style || 'normal'
let fontWeight = modifiers.weight || 'normal'
return `${fontStyle} normal ${fontWeight} ${this.window.fontSize}px ${this.window.fontFamily}`
}
/**
* The character size, used for calculating the cell size. The space character
* is used for measuring.
* @returns {Object} the character size with `width` and `height` in pixels
*/
getCharSize () {
this.ctx.font = this.getFont()
return {
width: Math.floor(this.ctx.measureText(' ').width),
height: this.window.fontSize
}
}
/**
* The cell size, which is the character size multiplied by the grid scale.
* @returns {Object} the cell size with `width` and `height` in pixels
*/
getCellSize () {
let charSize = this.getCharSize()
return {
width: Math.ceil(charSize.width * this.window.gridScaleX),
height: Math.ceil(charSize.height * this.window.gridScaleY)
}
}
/**
* Updates the canvas size if it changed
*/
updateSize () {
// see below (this is just updating it)
this._window.devicePixelRatio = Math.round(this._windowScale * (window.devicePixelRatio || 1) * 2) / 2
let didChange = false
for (let key in this.windowState) {
if (this.windowState.hasOwnProperty(key) && this.windowState[key] !== this.window[key]) {
didChange = true
this.windowState[key] = this.window[key]
}
}
if (didChange) {
const {
width,
height,
fitIntoWidth,
fitIntoHeight
} = this.window
const cellSize = this.getCellSize()
// real height of the canvas element in pixels
let realWidth = width * cellSize.width
let realHeight = height * cellSize.height
if (fitIntoWidth && fitIntoHeight) {
let terminalAspect = realWidth / realHeight
let fitAspect = fitIntoWidth / fitIntoHeight
if (terminalAspect < fitAspect) {
// align heights
realHeight = fitIntoHeight
realWidth = realHeight * terminalAspect
} else {
// align widths
realWidth = fitIntoWidth
realHeight = realWidth / terminalAspect
}
} else if (fitIntoWidth) {
realHeight = fitIntoWidth / (realWidth / realHeight)
realWidth = fitIntoWidth
} else if (fitIntoHeight) {
realWidth = fitIntoHeight * (realWidth / realHeight)
realHeight = fitIntoHeight
}
7 years ago
// store new window scale
this._windowScale = realWidth / (width * cellSize.width)
// the DPR must be rounded to a very nice value to prevent gaps between cells
let devicePixelRatio = this._window.devicePixelRatio = Math.round(this._windowScale * (window.devicePixelRatio || 1) * 2) / 2
this.canvas.width = width * devicePixelRatio * cellSize.width
this.canvas.style.width = `${realWidth}px`
this.canvas.height = height * devicePixelRatio * cellSize.height
this.canvas.style.height = `${realHeight}px`
// the screen has been cleared (by changing canvas width)
this.drawnScreen = []
this.drawnScreenFG = []
this.drawnScreenBG = []
this.drawnScreenAttrs = []
// draw immediately; the canvas shouldn't flash
this.draw('update-size')
}
}
/**
* Resets the cursor blink to on and restarts the timer
*/
resetCursorBlink () {
this.cursor.blinkOn = true
clearInterval(this.cursor.blinkInterval)
this.cursor.blinkInterval = setInterval(() => {
this.cursor.blinkOn = this.cursor.blinking
? !this.cursor.blinkOn
: true
if (this.cursor.blinking) this.scheduleDraw('cursor-blink')
}, 500)
}
/**
* Resets the blink style to on and restarts the timer
*/
resetBlink () {
this.window.blinkStyleOn = true
clearInterval(this.window.blinkInterval)
let intervals = 0
this.window.blinkInterval = setInterval(() => {
if (this.blinkingCellCount <= 0) return
intervals++
if (intervals >= 4 && this.window.blinkStyleOn) {
this.window.blinkStyleOn = false
intervals = 0
} else if (intervals >= 1 && !this.window.blinkStyleOn) {
this.window.blinkStyleOn = true
intervals = 0
}
}, 200)
}
/**
* Returns a normalized version of the current selection, such that `start`
* is always before `end`.
* @returns {Object} the normalized selection, with `start` and `end`
*/
getNormalizedSelection () {
let { start, end } = this.selection
// if the start line is after the end line, or if they're both on the same
// line but the start column comes after the end column, swap
if (start[1] > end[1] || (start[1] === end[1] && start[0] > end[0])) {
[start, end] = [end, start]
}
return { start, end }
}
/**
* Returns whether or not a given cell is in the current selection.
* @param {number} col - the column (x)
* @param {number} line - the line (y)
* @returns {boolean}
*/
isInSelection (col, line) {
let { start, end } = this.getNormalizedSelection()
let colAfterStart = start[0] <= col
let colBeforeEnd = col < end[0]
let onStartLine = line === start[1]
let onEndLine = line === end[1]
if (onStartLine && onEndLine) return colAfterStart && colBeforeEnd
else if (onStartLine) return colAfterStart
else if (onEndLine) return colBeforeEnd
else return start[1] < line && line < end[1]
}
/**
* Sweeps for selected cells and joins them in a multiline string.
* @returns {string} the selection
*/
getSelectedText () {
const screenLength = this.window.width * this.window.height
let lines = []
let previousLineIndex = -1
for (let cell = 0; cell < screenLength; cell++) {
let x = cell % this.window.width
let y = Math.floor(cell / this.window.width)
if (this.isInSelection(x, y)) {
if (previousLineIndex !== y) {
previousLineIndex = y
lines.push('')
}
lines[lines.length - 1] += this.screen[cell]
}
}
return lines.join('\n')
}
/**
* Copies the selection to clipboard and creates a notification balloon.
*/
copySelectionToClipboard () {
let selectedText = this.getSelectedText()
// don't copy anything if nothing is selected
if (!selectedText) return
let textarea = mk('textarea')
document.body.appendChild(textarea)
textarea.value = selectedText
textarea.select()
if (document.execCommand('copy')) {
notify.show('Copied to clipboard')
} else {
notify.show('Failed to copy')
}
document.body.removeChild(textarea)
}
/**
* Converts screen coordinates to grid coordinates.
* @param {number} x - x in pixels
* @param {number} y - y in pixels
* @param {boolean} rounded - whether to round the coord, used for select highlighting
* @returns {number[]} a tuple of (x, y) in cells
*/
screenToGrid (x, y, rounded = false) {
let cellSize = this.getCellSize()
return [
Math.floor((x + (rounded ? cellSize.width / 2 : 0)) / cellSize.width),
Math.floor(y / cellSize.height)
]
}
/**
* Converts grid coordinates to screen coordinates.
* @param {number} x - x in cells
* @param {number} y - y in cells
7 years ago
* @param {boolean} [withScale] - when true, will apply window scale
* @returns {number[]} a tuple of (x, y) in pixels
*/
7 years ago
gridToScreen (x, y, withScale = false) {
let cellSize = this.getCellSize()
7 years ago
return [x * cellSize.width, y * cellSize.height].map(v => withScale ? v * this._windowScale : v)
}
/**
* Draws a cell's background with the given parameters.
* @param {Object} options
* @param {number} options.x - x in cells
* @param {number} options.y - y in cells
* @param {number} options.cellWidth - cell width in pixels
* @param {number} options.cellHeight - cell height in pixels
* @param {number} options.bg - the background color
*/
7 years ago
drawBackground ({ x, y, cellWidth, cellHeight, bg }) {
const ctx = this.ctx
ctx.fillStyle = this.getColor(bg)
ctx.clearRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight)
ctx.fillRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight)
}
/**
* Draws a cell's character with the given parameters. Won't do anything if
* text is an empty string.
* @param {Object} options
* @param {number} options.x - x in cells
* @param {number} options.y - y in cells
* @param {Object} options.charSize - the character size, an object with
* `width` and `height` in pixels
* @param {number} options.cellWidth - cell width in pixels
* @param {number} options.cellHeight - cell height in pixels
* @param {string} options.text - the cell content
* @param {number} options.fg - the foreground color
* @param {number} options.attrs - the cell's attributes
*/
7 years ago
drawCharacter ({ x, y, charSize, cellWidth, cellHeight, text, fg, attrs }) {
if (!text) return
const ctx = this.ctx
let underline = false
let strike = false
let overline = false
if (attrs & (1 << 1)) ctx.globalAlpha = 0.5
if (attrs & (1 << 3)) underline = true
if (attrs & (1 << 5)) text = this.alphaToFraktur(text)
if (attrs & (1 << 6)) strike = true
if (attrs & (1 << 7)) overline = true
ctx.fillStyle = this.getColor(fg)
let codePoint = text.codePointAt(0)
if (codePoint >= 0x2580 && codePoint <= 0x259F) {
// block elements
ctx.beginPath()
const left = x * cellWidth
const top = y * cellHeight
const cw = cellWidth
const ch = cellHeight
const c2w = cellWidth / 2
const c2h = cellHeight / 2
// http://www.fileformat.info/info/unicode/block/block_elements/utf8test.htm
// 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B 0x0C 0x0D 0x0E 0x0F
// 0x2580 ▀ ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ ▉ ▊ ▋ ▌ ▍ ▎ ▏
// 0x2590 ▐ ░ ▒ ▓ ▔ ▕ ▖ ▗ ▘ ▙ ▚ ▛ ▜ ▝ ▞ ▟
if (codePoint === 0x2580) {
// upper half block >▀<
ctx.rect(left, top, cw, c2h)
} else if (codePoint <= 0x2588) {
// lower n eighth block (increasing) >▁< to >█<
let offset = (1 - (codePoint - 0x2580) / 8) * ch
ctx.rect(left, top + offset, cw, ch - offset)
} else if (codePoint <= 0x258F) {
// left n eighth block (decreasing) >▉< to >▏<
let offset = (codePoint - 0x2588) / 8 * cw
ctx.rect(left, top, cw - offset, ch)
} else if (codePoint === 0x2590) {
// right half block >▐<
ctx.rect(left + c2w, top, c2w, ch)
} else if (codePoint <= 0x2593) {
// shading >░< >▒< >▓<
// dot spacing by dividing cell size by a constant. This could be
// reworked to always return a whole number, but that would require
// prime factorization, and doing that without a loop would let you
// take over the world, which is not within the scope of this project.
let dotSpacingX, dotSpacingY, dotSize
if (codePoint === 0x2591) {
dotSpacingX = cw / 4
dotSpacingY = ch / 10
dotSize = 1
} else if (codePoint === 0x2592) {
dotSpacingX = cw / 6
dotSpacingY = cw / 10
dotSize = 1
} else if (codePoint === 0x2593) {
dotSpacingX = cw / 4
dotSpacingY = cw / 7
dotSize = 2
}
let alignRight = false
for (let dy = 0; dy < ch; dy += dotSpacingY) {
for (let dx = 0; dx < cw; dx += dotSpacingX) {
// prevent overflow
let dotSizeY = Math.min(dotSize, ch - dy)
ctx.rect(x * cw + (alignRight ? cw - dx - dotSize : dx), y * ch + dy, dotSize, dotSizeY)
}
alignRight = !alignRight
}
} else if (codePoint === 0x2594) {
// upper one eighth block >▔<
ctx.rect(x * cw, y * ch, cw, ch / 8)
} else if (codePoint === 0x2595) {
// right one eighth block >▕<
ctx.rect((x + 7 / 8) * cw, y * ch, cw / 8, ch)
} else if (codePoint === 0x2596) {
// left bottom quadrant >▖<
ctx.rect(left, top + c2h, c2w, c2h)
} else if (codePoint === 0x2597) {
// right bottom quadrant >▗<
ctx.rect(left + c2w, top + c2h, c2w, c2h)
} else if (codePoint === 0x2598) {
// left top quadrant >▘<
ctx.rect(left, top, c2w, c2h)
} else if (codePoint === 0x2599) {
// left chair >▙<
ctx.rect(left, top, c2w, ch)
ctx.rect(left + c2w, top + c2h, c2w, c2h)
} else if (codePoint === 0x259A) {
// quadrants lt rb >▚<
ctx.rect(left, top, c2w, c2h)
ctx.rect(left + c2w, top + c2h, c2w, c2h)
} else if (codePoint === 0x259B) {
// left chair upside down >▛<
ctx.rect(left, top, c2w, ch)
ctx.rect(left + c2w, top, c2w, c2h)
} else if (codePoint === 0x259C) {
// right chair upside down >▜<
ctx.rect(left, top, cw, c2h)
ctx.rect(left + c2w, top + c2h, c2w, c2h)
} else if (codePoint === 0x259D) {
// right top quadrant >▝<
ctx.rect(left + c2w, top, c2w, c2h)
} else if (codePoint === 0x259E) {
// quadrants lb rt >▞<
ctx.rect(left, top + c2h, c2w, c2h)
ctx.rect(left + c2w, top, c2w, c2h)
} else if (codePoint === 0x259F) {
// right chair upside down >▟<
ctx.rect(left, top + c2h, c2w, c2h)
ctx.rect(left + c2w, top, c2w, ch)
}
ctx.fill()
} else {
// Draw other characters using the text renderer
ctx.fillText(text, (x + 0.5) * cellWidth, (y + 0.5) * cellHeight)
}
// -- line drawing - a reference for a possible future rect/line implementation ---
// http://www.fileformat.info/info/unicode/block/box_drawing/utf8test.htm
// 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B 0x0C 0x0D 0x0E 0x0F
// 0x2500 ─ ━ │ ┃ ┄ ┅ ┆ ┇ ┈ ┉ ┊ ┋ ┌ ┍ ┎ ┏
// 0x2510 ┐ ┑ ┒ ┓ └ ┕ ┖ ┗ ┘ ┙ ┚ ┛ ├ ┝ ┞ ┟
// 0x2520 ┠ ┡ ┢ ┣ ┤ ┥ ┦ ┧ ┨ ┩ ┪ ┫ ┬ ┭ ┮ ┯
// 0x2530 ┰ ┱ ┲ ┳ ┴ ┵ ┶ ┷ ┸ ┹ ┺ ┻ ┼ ┽ ┾ ┿
// 0x2540 ╀ ╁ ╂ ╃ ╄ ╅ ╆ ╇ ╈ ╉ ╊ ╋ ╌ ╍ ╎ ╏
// 0x2550 ═ ║ ╒ ╓ ╔ ╕ ╖ ╗ ╘ ╙ ╚ ╛ ╜ ╝ ╞ ╟
// 0x2560 ╠ ╡ ╢ ╣ ╤ ╥ ╦ ╧ ╨ ╩ ╪ ╫ ╬ ╭ ╮ ╯
// 0x2570 ╰ ╱ ╲ ╳ ╴ ╵ ╶ ╷ ╸ ╹ ╺ ╻ ╼ ╽ ╾ ╿
if (underline || strike || overline) {
ctx.strokeStyle = this.getColor(fg)
ctx.lineWidth = 1
ctx.lineCap = 'round'
ctx.beginPath()
if (underline) {
let lineY = Math.round(y * cellHeight + charSize.height) + 0.5
ctx.moveTo(x * cellWidth, lineY)
ctx.lineTo((x + 1) * cellWidth, lineY)
}
if (strike) {
let lineY = Math.round((y + 0.5) * cellHeight) + 0.5
ctx.moveTo(x * cellWidth, lineY)
ctx.lineTo((x + 1) * cellWidth, lineY)
}
if (overline) {
let lineY = Math.round(y * cellHeight) + 0.5
ctx.moveTo(x * cellWidth, lineY)
ctx.lineTo((x + 1) * cellWidth, lineY)
}
ctx.stroke()
}
ctx.globalAlpha = 1
}
/**
* Returns all adjacent cell indices given a radius.
* @param {number} cell - the center cell index
* @param {number} [radius] - the radius. 1 by default
* @returns {number[]} an array of cell indices
*/
getAdjacentCells (cell, radius = 1) {
const { width, height } = this.window
const screenLength = width * height
let cells = []
for (let x = -radius; x <= radius; x++) {
for (let y = -radius; y <= radius; y++) {
if (x === 0 && y === 0) continue
cells.push(cell + x + y * width)
}
}
return cells.filter(cell => cell >= 0 && cell < screenLength)
}
/**
* Updates the screen.
* @param {string} why - the draw reason (for debugging)
*/
draw (why) {
const ctx = this.ctx
const {
width,
height,
devicePixelRatio,
statusScreen
} = this.window
if (statusScreen) {
// draw status screen instead
this.drawStatus(statusScreen)
this.startDrawLoop()
return
} else this.stopDrawLoop()
const charSize = this.getCharSize()
const { width: cellWidth, height: cellHeight } = this.getCellSize()
const screenLength = width * height
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
if (this.window.debug && this._debug) this._debug.drawStart(why)
ctx.font = this.getFont()
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// bits in the attr value that affect the font
const FONT_MASK = 0b101
// Map of (attrs & FONT_MASK) -> Array of cell indices
let fontGroups = new Map()
// Map of (cell index) -> boolean, whether or not a cell has updated
let updateMap = new Map()
for (let cell = 0; cell < screenLength; cell++) {
let x = cell % width
let y = Math.floor(cell / width)
let isCursor = !this.cursor.hanging &&
this.cursor.x === x &&
this.cursor.y === y &&
this.cursor.blinkOn &&
this.cursor.visible
let wasCursor = x === this.drawnCursor[0] && y === this.drawnCursor[1]
let inSelection = this.isInSelection(x, y)
let text = this.screen[cell]
let fg = this.screenFG[cell]
let bg = this.screenBG[cell]
let attrs = this.screenAttrs[cell]
if (attrs & (1 << 4) && !this.window.blinkStyleOn) {
// blinking is enabled and blink style is off
7 years ago
// set text to nothing so drawCharacter doesn't draw anything
text = ''
}
if (inSelection) {
fg = -1
bg = -2
}
let didUpdate = text !== this.drawnScreen[cell] || // text updated
fg !== this.drawnScreenFG[cell] || // foreground updated, and this cell has text
bg !== this.drawnScreenBG[cell] || // background updated
attrs !== this.drawnScreenAttrs[cell] || // attributes updated
isCursor !== wasCursor || // cursor blink/position updated
(isCursor && this.cursor.style !== this.drawnCursor[2]) // cursor style updated
let font = attrs & FONT_MASK
if (!fontGroups.has(font)) fontGroups.set(font, [])
fontGroups.get(font).push([cell, x, y, text, fg, bg, attrs, isCursor, inSelection])
updateMap.set(cell, didUpdate)
}
// Map of (cell index) -> boolean, whether or not a cell should be redrawn
const redrawMap = new Map()
let isTextWide = text =>
text !== ' ' && ctx.measureText(text).width >= (cellWidth + 0.05)
// decide for each cell if it should be redrawn
let updateRedrawMapAt = cell => {
let shouldUpdate = updateMap.get(cell) || redrawMap.get(cell) || false
// TODO: fonts (necessary?)
let text = this.screen[cell]
let isWideCell = isTextWide(text)
let checkRadius = isWideCell ? 2 : 1
if (!shouldUpdate) {
// check adjacent cells
let adjacentDidUpdate = false
for (let adjacentCell of this.getAdjacentCells(cell, checkRadius)) {
// update this cell if:
// - the adjacent cell updated (For now, this'll always be true because characters can be slightly larger than they say they are)
// - the adjacent cell updated and this cell or the adjacent cell is wide
if (updateMap.get(adjacentCell) && (this.window.graphics < 2 || isWideCell || isTextWide(this.screen[adjacentCell]))) {
adjacentDidUpdate = true
break
}
}
if (adjacentDidUpdate) shouldUpdate = true
}
redrawMap.set(cell, shouldUpdate)
}
for (let cell of updateMap.keys()) updateRedrawMapAt(cell)
// mask to redrawing regions only
if (this.window.graphics >= 1) {
let debug = this.window.debug && this._debug
ctx.save()
ctx.beginPath()
for (let y = 0; y < height; y++) {
let regionStart = null
for (let x = 0; x < width; x++) {
let cell = y * width + x
let redrawing = redrawMap.get(cell)
if (redrawing && regionStart === null) regionStart = x
if (!redrawing && regionStart !== null) {
ctx.rect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight)
if (debug) this._debug.clipRect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight)
regionStart = null
}
}
if (regionStart !== null) {
ctx.rect(regionStart * cellWidth, y * cellHeight, (width - regionStart) * cellWidth, cellHeight)
if (debug) this._debug.clipRect(regionStart * cellWidth, y * cellHeight, (width - regionStart) * cellWidth, cellHeight)
}
}
ctx.clip()
}
// pass 1: backgrounds
for (let font of fontGroups.keys()) {
for (let data of fontGroups.get(font)) {
let [cell, x, y, text, , bg] = data
if (redrawMap.get(cell)) {
7 years ago
this.drawBackground({ x, y, cellWidth, cellHeight, bg })
if (this.window.debug && this._debug) {
// set cell flags
let flags = (+redrawMap.get(cell))
flags |= (+updateMap.get(cell)) << 1
flags |= (+isTextWide(text)) << 2
this._debug.setCell(cell, flags)
}
}
}
}
// reset drawn cursor
this.drawnCursor = [-1, -1, -1]
// pass 2: characters
for (let font of fontGroups.keys()) {
// set font once because in Firefox, this is a really slow action for some
// reason
let modifiers = {}
if (font & 1) modifiers.weight = 'bold'
if (font & 1 << 2) modifiers.style = 'italic'
ctx.font = this.getFont(modifiers)
for (let data of fontGroups.get(font)) {
let [cell, x, y, text, fg, bg, attrs, isCursor, inSelection] = data
if (redrawMap.get(cell)) {
7 years ago
this.drawCharacter({
x, y, charSize, cellWidth, cellHeight, text, fg, attrs
})
this.drawnScreen[cell] = text
this.drawnScreenFG[cell] = fg
this.drawnScreenBG[cell] = bg
this.drawnScreenAttrs[cell] = attrs
if (isCursor) this.drawnCursor = [x, y, this.cursor.style]
if (isCursor && !inSelection) {
ctx.save()
ctx.beginPath()
if (this.cursor.style === 'block') {
// block
ctx.rect(x * cellWidth, y * cellHeight, cellWidth, cellHeight)
} else if (this.cursor.style === 'bar') {
// vertical bar
let barWidth = 2
ctx.rect(x * cellWidth, y * cellHeight, barWidth, cellHeight)
} else if (this.cursor.style === 'line') {
// underline
let lineHeight = 2
ctx.rect(x * cellWidth, y * cellHeight + charSize.height, cellWidth, lineHeight)
}
ctx.clip()
// swap foreground/background
;[fg, bg] = [bg, fg]
// HACK: ensure cursor is visible
if (fg === bg) bg = fg === 0 ? 7 : 0
7 years ago
this.drawBackground({ x, y, cellWidth, cellHeight, bg })
this.drawCharacter({
x, y, charSize, cellWidth, cellHeight, text, fg, attrs
})
ctx.restore()
}
}
}
}
if (this.window.graphics >= 1) ctx.restore()
if (this.window.debug && this._debug) this._debug.drawEnd()
}
drawStatus (statusScreen) {
const ctx = this.ctx
const {
fontFamily,
width,
height,
devicePixelRatio
} = this.window
// reset drawnScreen to force redraw when statusScreen is disabled
this.drawnScreen = []
const cellSize = this.getCellSize()
const screenWidth = width * cellSize.width
const screenHeight = height * cellSize.height
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
ctx.clearRect(0, 0, screenWidth, screenHeight)
ctx.font = `24px ${fontFamily}`
ctx.fillStyle = '#fff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(statusScreen.title || '', screenWidth / 2, screenHeight / 2 - 50)
if (statusScreen.loading) {
// show loading spinner
ctx.save()
ctx.translate(screenWidth / 2, screenHeight / 2 + 20)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 5
ctx.lineCap = 'round'
let t = Date.now() / 1000
for (let i = 0; i < 12; i++) {
ctx.rotate(Math.PI / 6)
let offset = ((t * 12) - i) % 12
ctx.globalAlpha = Math.max(0.2, 1 - offset / 3)
ctx.beginPath()
ctx.moveTo(0, 15)
ctx.lineTo(0, 30)
ctx.stroke()
}
ctx.restore()
}
}
startDrawLoop () {
if (this._drawTimerThread) return
let threadID = Math.random().toString(36)
this._drawTimerThread = threadID
this.drawTimerLoop(threadID)
}
stopDrawLoop () {
this._drawTimerThread = null
}
drawTimerLoop (threadID) {
if (!threadID || threadID !== this._drawTimerThread) return
window.requestAnimationFrame(() => this.drawTimerLoop(threadID))
this.draw('draw-loop')
}
/**
* Parses the content of an `S` message and schedules a draw
* @param {string} str - the message content
*/
loadContent (str) {
// current index
let i = 0
// Uncomment to capture screen content for the demo page
// console.log(JSON.stringify(`S${str}`))
if (!this.contentLoaded) {
let errmsg = qs('#load-failed')
if (errmsg) errmsg.parentNode.removeChild(errmsg)
this.contentLoaded = true
}
// window size
const newHeight = parse2B(str, i)
const newWidth = parse2B(str, i + 2)
const resized = (this.window.height !== newHeight) || (this.window.width !== newWidth)
this.window.height = newHeight
this.window.width = newWidth
i += 4
// cursor position
let [cursorY, cursorX] = [parse2B(str, i), parse2B(str, i + 2)]
i += 4
let cursorMoved = (cursorX !== this.cursor.x || cursorY !== this.cursor.y)
this.cursor.x = cursorX
this.cursor.y = cursorY
if (cursorMoved) {
this.resetCursorBlink()
this.emit('cursor-moved')
}
// attributes
let attributes = parse3B(str, i)
i += 3
this.cursor.visible = !!(attributes & 1)
this.cursor.hanging = !!(attributes & (1 << 1))
this.input.setAlts(
!!(attributes & (1 << 2)), // cursors alt
!!(attributes & (1 << 3)), // numpad alt
!!(attributes & (1 << 4)), // fn keys alt
!!(attributes & (1 << 12)) // crlf mode
)
let trackMouseClicks = !!(attributes & (1 << 5))
let trackMouseMovement = !!(attributes & (1 << 6))
// 0 - Block blink 2 - Block steady (1 is unused)
// 3 - Underline blink 4 - Underline steady
// 5 - I-bar blink 6 - I-bar steady
let cursorShape = (attributes >> 9) & 0x07
// if it's not zero, decrement such that the two most significant bits
// are the type and the least significant bit is the blink state
if (cursorShape > 0) cursorShape--
let cursorStyle = cursorShape >> 1
let cursorBlinking = !(cursorShape & 1)
if (cursorStyle === 0) this.cursor.style = 'block'
else if (cursorStyle === 1) this.cursor.style = 'line'
else if (cursorStyle === 2) this.cursor.style = 'bar'
if (this.cursor.blinking !== cursorBlinking) {
this.cursor.blinking = cursorBlinking
this.resetCursorBlink()
}
this.input.setMouseMode(trackMouseClicks, trackMouseMovement)
this.selection.selectable = !trackMouseClicks && !trackMouseMovement
$(this.canvas).toggleClass('selectable', this.selection.selectable)
this.mouseMode = {
clicks: trackMouseClicks,
movement: trackMouseMovement
}
let showButtons = !!(attributes & (1 << 7))
let showConfigLinks = !!(attributes & (1 << 8))
$('.x-term-conf-btn').toggleClass('hidden', !showConfigLinks)
$('#action-buttons').toggleClass('hidden', !showButtons)
this.bracketedPaste = !!(attributes & (1 << 13))
// content
let fg = 7
let bg = 0
let attrs = 0
let cell = 0 // cell index
let lastChar = ' '
let screenLength = this.window.width * this.window.height
if (resized) {
this.updateSize()
this.blinkingCellCount = 0
this.screen = new Array(screenLength).fill(' ')
this.screenFG = new Array(screenLength).fill(' ')
this.screenBG = new Array(screenLength).fill(' ')
this.screenAttrs = new Array(screenLength).fill(' ')
}
let strArray = Array.from ? Array.from(str) : str.split('')
const MASK_LINE_ATTR = 0xC8
const MASK_BLINK = 1 << 4
let setCellContent = () => {
// Remove blink attribute if it wouldn't have any effect
let myAttrs = attrs
if ((myAttrs & MASK_BLINK) !== 0 &&
((lastChar === ' ' && ((myAttrs & MASK_LINE_ATTR) === 0)) || // no line styles
fg === bg // invisible text
)
) {
myAttrs ^= MASK_BLINK
}
// update blinking cells counter if blink state changed
if ((this.screenAttrs[cell] & MASK_BLINK) !== (myAttrs & MASK_BLINK)) {
if (myAttrs & MASK_BLINK) this.blinkingCellCount++
else this.blinkingCellCount--
}
this.screen[cell] = lastChar
this.screenFG[cell] = fg
this.screenBG[cell] = bg
this.screenAttrs[cell] = myAttrs
}
while (i < strArray.length && cell < screenLength) {
let character = strArray[i++]
let charCode = character.codePointAt(0)
let data
switch (charCode) {
case SEQ_REPEAT:
let count = parse2B(strArray[i] + strArray[i + 1])
i += 2
for (let j = 0; j < count; j++) {
setCellContent(cell)
if (++cell > screenLength) break
}
break
case SEQ_SET_COLORS:
data = parse3B(strArray[i] + strArray[i + 1] + strArray[i + 2])
i += 3
fg = data & 0xFF
bg = (data >> 8) & 0xFF
break
case SEQ_SET_ATTRS:
data = parse2B(strArray[i] + strArray[i + 1])
i += 2
attrs = data & 0xFF
break
case SEQ_SET_FG:
data = parse2B(strArray[i] + strArray[i + 1])
i += 2
fg = data & 0xFF
break
case SEQ_SET_BG:
data = parse2B(strArray[i] + strArray[i + 1])
i += 2
bg = data & 0xFF
break
default:
if (charCode < 32) character = '\ufffd'
lastChar = character
setCellContent(cell)
cell++
}
}
if (this.window.debug) console.log(`Blinky cells = ${this.blinkingCellCount}`)
this.scheduleDraw('load', 16)
this.emit('load')
}
/**
* Parses the content of a `T` message and updates the screen title and button
* labels.
* @param {string} str - the message content
*/
loadLabels (str) {
let pieces = str.split('\x01')
let screenTitle = pieces[0]
qs('#screen-title').textContent = screenTitle
if (screenTitle.length === 0) screenTitle = 'Terminal'
qs('title').textContent = `${screenTitle} :: ESPTerm`
$('#action-buttons button').forEach((button, i) => {
let label = pieces[i + 1].trim()
// if empty string, use the "dim" effect and put nbsp instead to
// stretch the button vertically
button.innerHTML = label ? $.htmlEscape(label) : '&nbsp;'
button.style.opacity = label ? 1 : 0.2
})
}
/**
* Shows an actual notification (if possible) or a notification balloon.
* @param {string} text - the notification content
*/
showNotification (text) {
console.info(`Notification: ${text}`)
if (window.Notification && window.Notification.permission === 'granted') {
let notification = new window.Notification('ESPTerm', {
body: text
})
notification.addEventListener('click', () => window.focus())
} else {
if (window.Notification && window.Notification.permission !== 'denied') {
window.Notification.requestPermission()
} else {
// Fallback using the built-in notification balloon
notify.show(text)
}
}
}
/**
* Loads a message from the server, and optionally a theme.
* @param {string} str - the message
* @param {number} [theme] - the new theme index
*/
load (str, theme = -1) {
const content = str.substr(1)
if (theme >= 0 && theme < themes.length) {
this.palette = themes[theme]
}
switch (str[0]) {
case 'S':
this.loadContent(content)
break
case 'T':
this.loadLabels(content)
break
case 'B':
this.beep()
break
case 'G':
this.showNotification(content)
break
default:
console.warn(`Bad data message type; ignoring.\n${JSON.stringify(str)}`)
}
}
/**
* Creates a beep sound.
*/
beep () {
const audioCtx = this.audioCtx
if (!audioCtx) return
// prevent screeching
if (this._lastBeep && this._lastBeep > Date.now() - 50) return
this._lastBeep = Date.now()
7 years ago
if (!this._convolver) {
this._convolver = audioCtx.createConvolver()
let impulseLength = audioCtx.sampleRate * 0.8
let impulse = audioCtx.createBuffer(2, impulseLength, audioCtx.sampleRate)
for (let i = 0; i < impulseLength; i++) {
impulse.getChannelData(0)[i] = (1 - i / impulseLength) ** (7 + Math.random())
impulse.getChannelData(1)[i] = (1 - i / impulseLength) ** (7 + Math.random())
}
this._convolver.buffer = impulse
this._convolver.connect(audioCtx.destination)
}
// main beep
7 years ago
const mainOsc = audioCtx.createOscillator()
const mainGain = audioCtx.createGain()
mainOsc.connect(mainGain)
mainGain.gain.value = 6
mainOsc.frequency.value = 750
mainOsc.type = 'sine'
// surrogate beep (making it sound like 'oops')
7 years ago
const surrOsc = audioCtx.createOscillator()
const surrGain = audioCtx.createGain()
surrOsc.connect(surrGain)
surrGain.gain.value = 4
surrOsc.frequency.value = 400
surrOsc.type = 'sine'
mainGain.connect(this._convolver)
surrGain.connect(this._convolver)
let startTime = audioCtx.currentTime
mainOsc.start()
mainOsc.stop(startTime + 0.5)
surrOsc.start(startTime + 0.05)
surrOsc.stop(startTime + 0.8)
let loop = function () {
if (audioCtx.currentTime < startTime + 0.8) window.requestAnimationFrame(loop)
7 years ago
mainGain.gain.value *= 0.8
surrGain.gain.value *= 0.8
}
loop()
}
/**
* Converts an alphabetic character to its fraktur variant.
* @param {string} character - the character
* @returns {string} the converted character
*/
alphaToFraktur (character) {
if (character >= 'a' && character <= 'z') {
character = String.fromCodePoint(0x1d51e - 0x61 + character.charCodeAt(0))
} else if (character >= 'A' && character <= 'Z') {
character = this.frakturExceptions[character] || String.fromCodePoint(
0x1d504 - 0x41 + character.charCodeAt(0))
}
return character
}
}