ESPTerm - ESP8266 terminal emulator. Branches: [master] patches, [work] next release
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-firmware/html_orig/jssrc/term_screen.js

444 lines
11 KiB

// Some non-bold Fraktur symbols are outside the contiguous block
const frakturExceptions = {
'C': '\u212d',
'H': '\u210c',
'I': '\u2111',
'R': '\u211c',
'Z': '\u2128'
}
// constants for decoding the update blob
const SEQ_SET_COLOR_ATTR = 1
const SEQ_REPEAT = 2
const SEQ_SET_COLOR = 3
const SEQ_SET_ATTR = 4
const themes = [
[
'#111213',
'#CC0000',
'#4E9A06',
'#C4A000',
'#3465A4',
'#75507B',
'#06989A',
'#D3D7CF',
'#555753',
'#EF2929',
'#8AE234',
'#FCE94F',
'#729FCF',
'#AD7FA8',
'#34E2E2',
'#EEEEEC'
]
]
class TermScreen {
constructor () {
this.canvas = document.createElement('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!')
}
this.cursor = {
x: 0,
y: 0,
fg: 7,
bg: 0,
attrs: 0,
blinkOn: false,
visible: true,
hanging: false,
blinkInterval: null
}
this._colors = themes[0]
this._window = {
width: 0,
height: 0,
devicePixelRatio: 1,
fontFamily: '"DejaVu Sans Mono", "Liberation Mono", "Inconsolata", ' +
'monospace',
fontSize: 20,
gridScaleX: 1.0,
gridScaleY: 1.2,
blinkStyleOn: true,
blinkInterval: null
}
this.windowState = {
width: 0,
height: 0,
devicePixelRatio: 0,
gridScaleX: 0,
gridScaleY: 0,
fontFamily: '',
fontSize: 0
}
const self = this
this.window = new Proxy(this._window, {
set (target, key, value, receiver) {
target[key] = value
self.updateSize()
self.scheduleDraw()
return true
}
})
this.screen = []
this.screenFG = []
this.screenBG = []
this.screenAttrs = []
this.resetBlink()
this.resetCursorBlink()
}
get colors () { return this._colors }
set colors (theme) {
this._colors = theme
this.scheduleDraw()
}
// schedule a draw in the next tick
scheduleDraw () {
clearTimeout(this._scheduledDraw)
this._scheduledDraw = setTimeout(() => this.draw(), 1)
}
getFont (modifiers = {}) {
let fontStyle = modifiers.style || 'normal'
let fontWeight = modifiers.weight || 'normal'
return `${fontStyle} normal ${fontWeight} ${
this.window.fontSize}px ${this.window.fontFamily}`
}
getCharSize () {
this.ctx.font = this.getFont()
return {
width: this.ctx.measureText(' ').width,
height: this.window.fontSize
}
}
updateSize () {
this._window.devicePixelRatio = window.devicePixelRatio || 1
let didChange = false
for (let key in this.windowState) {
if (this.windowState[key] !== this.window[key]) {
didChange = true
this.windowState[key] = this.window[key]
}
}
if (didChange) {
const {
width, height, devicePixelRatio, gridScaleX, gridScaleY, fontSize
} = this.window
const charSize = this.getCharSize()
this.canvas.width = width * devicePixelRatio * charSize.width * gridScaleX
this.canvas.style.width = `${width * charSize.width * gridScaleX}px`
this.canvas.height = height * devicePixelRatio * charSize.height *
gridScaleY
this.canvas.style.height = `${height * charSize.height * gridScaleY}px`
}
}
resetCursorBlink () {
clearInterval(this.cursor.blinkInterval)
this.cursor.blinkInterval = setInterval(() => {
this.cursor.blinkOn = !this.cursor.blinkOn
this.scheduleDraw()
}, 500)
}
resetBlink () {
clearInterval(this.window.blinkInterval)
let intervals = 0
this.window.blinkInterval = setInterval(() => {
intervals++
if (intervals >= 4 && this.window.blinkStyleOn) {
this.window.blinkStyleOn = false
intervals = 0
} else {
this.window.blinkStyleOn = true
intervals = 0
}
}, 200)
}
drawCell ({ x, y, charSize, cellWidth, cellHeight, text, fg, bg, attrs }) {
const ctx = this.ctx
ctx.fillStyle = this.colors[bg]
ctx.globalCompositeOperation = 'destination-over'
ctx.fillRect(x * cellWidth, y * cellHeight,
Math.ceil(cellWidth), Math.ceil(cellHeight))
ctx.globalCompositeOperation = 'source-over'
let fontModifiers = {}
let underline = false
let blink = false
let strike = false
if (attrs & 1) fontModifiers.weight = 'bold'
if (attrs & 1 << 1) ctx.globalAlpha = 0.5
if (attrs & 1 << 2) fontModifiers.style = 'italic'
if (attrs & 1 << 3) underline = true
if (attrs & 1 << 4) blink = true
if (attrs & 1 << 5) text = TermScreen.alphaToFraktur(text)
if (attrs & 1 << 6) strike = true
if (!blink || this.window.blinkStyleOn) {
ctx.font = this.getFont(fontModifiers)
ctx.fillStyle = this.colors[fg]
ctx.fillText(text, (x + 0.5) * cellWidth, (y + 0.5) * cellHeight)
if (underline || strike) {
let lineY = underline
? y * cellHeight * charSize.height
: (y + 0.5) * cellHeight
ctx.strokeStyle = this.colors[fg]
ctx.lineWidth = 1
ctx.moveTo(x * cellWidth, lineY)
ctx.lineTo((x + 1) * cellWidth, lineY)
ctx.stroke()
}
}
ctx.globalAlpha = 1
}
draw () {
const ctx = this.ctx
const {
width,
height,
devicePixelRatio,
fontFamily,
fontSize,
gridScaleX,
gridScaleY
} = this.window
const charSize = this.getCharSize()
const cellWidth = charSize.width * gridScaleX
const cellHeight = charSize.height * gridScaleY
const screenWidth = width * cellWidth
const screenHeight = height * cellHeight
const screenLength = width * height
ctx.setTransform(this.window.devicePixelRatio, 0, 0,
this.window.devicePixelRatio, 0, 0)
ctx.clearRect(0, 0, screenWidth, screenHeight)
ctx.font = this.getFont()
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
for (let cell = 0; cell < screenLength; cell++) {
let x = cell % width
let y = Math.floor(cell / width)
let isCursor = this.cursor.x === x && this.cursor.y === y
let text = this.screen[cell]
let fg = isCursor ? this.screenBG[cell] : this.screenFG[cell]
let bg = isCursor ? this.screenFG[cell] : this.screenBG[cell]
let attrs = this.screenAttrs[cell]
if (isCursor && fg === bg) bg = fg === 0 ? 7 : 0
this.drawCell({
x, y, charSize, cellWidth, cellHeight, text, fg, bg, attrs
})
}
}
loadContent (str) {
// current index
let i = 0
// window size
this.window.height = parse2B(str, i)
this.window.width = parse2B(str, i + 2)
this.updateSize()
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()
// attributes
let attributes = parse2B(str, i)
i += 2
this.cursor.visible = !!(attributes & 1)
this.cursor.hanging = !!(attributes & 1 << 0)
Input.setAlts(
!!(attributes & 1 << 2), // cursors alt
!!(attributes & 1 << 3), // numpad alt
!!(attributes & 1 << 4) // fn keys alt
)
let trackMouseClicks = !!(attributes & 1 << 5)
let trackMouseMovement = !!(attributes & 1 << 6)
Input.setMouseMode(trackMouseClicks, trackMouseMovement)
let showButtons = !!(attributes & 1 << 7)
let showConfigLinks = !!(attributes & 1 << 8)
$('.x-term-conf-btn').toggleClass('hidden', !showConfigLinks)
$('#action-buttons').toggleClass('hidden', !showButtons)
// content
let fg = 7
let bg = 0
let attrs = 0
let cell = 0 // cell index
let text = ' '
let screenLength = this.window.width * this.window.height
this.screen = new Array(screenLength).fill(' ')
this.screenFG = new Array(screenLength).fill(' ')
this.screenBG = new Array(screenLength).fill(' ')
this.screenAttrs = new Array(screenLength).fill(' ')
while (i < str.length && cell < screenLength) {
let character = str[i++]
let charCode = character.charCodeAt(0)
if (charCode === SEQ_SET_COLOR_ATTR) {
let data = parse3B(str, i)
i += 3
fg = data & 0xF
bg = data >> 4 & 0xF
attrs = data >> 8 & 0xFF
} else if (charCode == SEQ_SET_COLOR) {
let data = parse2B(str, i)
i += 2
fg = data & 0xF
bg = data >> 4 & 0xF
} else if (charCode === SEQ_SET_ATTR) {
let data = parse2B(str, i)
i += 2
attrs = data & 0xFF
} else if (charCode === SEQ_REPEAT) {
let count = parse2B(str, i)
i += 2
for (let j = 0; j < count; j++) {
this.screen[cell] = text
this.screenFG[cell] = fg
this.screenBG[cell] = bg
this.screenAttrs[cell] = attrs
if (++cell > screenLength) break
}
} else {
// unique cell character
this.screen[cell] = text = character
this.screenFG[cell] = fg
this.screenBG[cell] = bg
this.screenAttrs[cell] = attrs
cell++
}
}
this.scheduleDraw()
if (this.onload) this.onload()
}
/** Apply labels to buttons and screen title (leading T removed already) */
loadLabels (str) {
let pieces = str.split('\x01')
qs('h1').textContent = pieces[0]
$('#action-buttons button').forEach((button, i) => {
var label = pieces[i + 1].trim()
// if empty string, use the "dim" effect and put nbsp instead to
// stretch the button vertically
button.innerHTML = label ? e(label) : '&nbsp;';
button.style.opacity = label ? 1 : 0.2;
})
}
load (str) {
const content = str.substr(1)
switch (str[0]) {
case 'S':
this.loadContent(content)
break
case 'T':
this.loadLabels(content)
break
case 'B':
this.beep()
break
default:
console.warn(`Bad data message type; ignoring.\n${
JSON.stringify(content)}`)
}
}
beep () {
const audioCtx = this.audioCtx
if (!audioCtx) return
let osc, gain
// main beep
osc = audioCtx.createOscillator()
gain = audioCtx.createGain()
osc.connect(gain)
gain.connect(audioCtx.destination);
gain.gain.value = 0.5;
osc.frequency.value = 750;
osc.type = 'sine';
osc.start();
osc.stop(audioCtx.currentTime + 0.05);
// surrogate beep (making it sound like 'oops')
osc = audioCtx.createOscillator();
gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
gain.gain.value = 0.2;
osc.frequency.value = 400;
osc.type = 'sine';
osc.start(audioCtx.currentTime + 0.05);
osc.stop(audioCtx.currentTime + 0.08);
}
static alphaToFraktur (character) {
if ('a' <= character && character <= 'z') {
character = String.fromCodePoint(0x1d51e - 0x61 + character.charCodeAt(0))
} else if ('A' <= character && character <= 'Z') {
character = frakturExceptions[character] || String.fromCodePoint(
0x1d504 - 0x41 + character.charCodeAt(0))
}
return character
}
}
const Screen = new TermScreen()
let didAddScreen = false
Screen.onload = function () {
if (didAddScreen) return
didAddScreen = true
qs('#screen').appendChild(Screen.canvas)
}