Rewrite term_screen with canvas

http-comm
cpsdqs 7 years ago committed by Ondřej Hruška
parent b8ed46cbea
commit dd08290587
  1. 693
      html_orig/jssrc/term_screen.js

@ -1,346 +1,417 @@
var Screen = (function () { // Some non-bold Fraktur symbols are outside the contiguous block
var W = 0, H = 0; // dimensions const frakturExceptions = {
var inited = false;
var cursor = {
a: false, // active (blink state)
x: 0, // 0-based coordinates
y: 0,
fg: 7, // colors 0-15
bg: 0,
attrs: 0,
suppress: false, // do not turn on in blink interval (for safe moving)
forceOn: false, // force on unless hanging: used to keep cursor visible during move
hidden: false, // do not show (DEC opt)
hanging: false, // cursor at column "W+1" - not visible
};
var screen = [];
var blinkIval;
var cursorFlashStartIval;
// Some non-bold Fraktur symbols are outside the contiguous block
var frakturExceptions = {
'C': '\u212d', 'C': '\u212d',
'H': '\u210c', 'H': '\u210c',
'I': '\u2111', 'I': '\u2111',
'R': '\u211c', 'R': '\u211c',
'Z': '\u2128', 'Z': '\u2128'
}; }
// for BEL // constants for decoding the update blob
var audioCtx = null; const SEQ_SET_COLOR_ATTR = 1
try { const SEQ_REPEAT = 2
audioCtx = new (window.AudioContext || window.audioContext || window.webkitAudioContext)(); const SEQ_SET_COLOR = 3
} catch (er) { const SEQ_SET_ATTR = 4
console.error("No AudioContext!", er);
} const themes = [
[
/** Get cell under cursor */ '#111213',
function _curCell() { '#CC0000',
return screen[cursor.y*W + cursor.x]; '#4E9A06',
} '#C4A000',
'#3465A4',
/** Safely move cursor */ '#75507B',
function cursorSet(y, x) { '#06989A',
// Hide and prevent from showing up during the move '#D3D7CF',
cursor.suppress = true; '#555753',
_draw(_curCell(), false); '#EF2929',
cursor.x = x; '#8AE234',
cursor.y = y; '#FCE94F',
// Show again '#729FCF',
cursor.suppress = false; '#AD7FA8',
_draw(_curCell()); '#34E2E2',
} '#EEEEEC'
]
function alpha2fraktur(t) { ]
// perform substitution
if (t >= 'a' && t <= 'z') { class TermScreen {
t = String.fromCodePoint(0x1d51e - 97 + t.charCodeAt(0)); constructor () {
} this.canvas = document.createElement('canvas')
else if (t >= 'A' && t <= 'Z') { this.ctx = this.canvas.getContext('2d')
// this set is incomplete, some exceptions are needed
if (frakturExceptions.hasOwnProperty(t)) { if ('AudioContext' in window || 'webkitAudioContext' in window) {
t = frakturExceptions[t]; this.audioCtx = new (window.AudioContext || window.webkitAudioContext)()
} else { } else {
t = String.fromCodePoint(0x1d504 - 65 + t.charCodeAt(0)); console.warn('No AudioContext!')
}
}
return t;
} }
/** Update cell on display. inv = invert (for cursor) */ this.cursor = {
function _draw(cell, inv) { x: 0,
if (!cell) return; y: 0,
if (typeof inv == 'undefined') { fg: 7,
inv = cursor.a && cursor.x == cell.x && cursor.y == cell.y; 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
} }
})
var fg, bg, cn, t; this.screen = []
this.screenFG = []
fg = inv ? cell.bg : cell.fg; this.screenBG = []
bg = inv ? cell.fg : cell.bg; this.screenAttrs = []
t = cell.t;
if (!t.length) t = ' ';
cn = 'fg' + fg + ' bg' + bg; this.resetBlink()
if (cell.attrs & (1<<0)) cn += ' bold'; this.resetCursorBlink()
if (cell.attrs & (1<<1)) cn += ' faint';
if (cell.attrs & (1<<2)) cn += ' italic';
if (cell.attrs & (1<<3)) cn += ' under';
if (cell.attrs & (1<<4)) cn += ' blink';
if (cell.attrs & (1<<5)) {
cn += ' fraktur';
t = alpha2fraktur(t);
} }
if (cell.attrs & (1<<6)) cn += ' strike';
cell.slot.textContent = t; get colors () { return this._colors }
cell.elem.className = cn; set colors (theme) {
this._colors = theme
this.scheduleDraw()
} }
/** Show entire screen */ // schedule a draw in the next tick
function _drawAll() { scheduleDraw () {
for (var i = W*H-1; i>=0; i--) { clearTimeout(this._scheduledDraw)
_draw(screen[i]); this._scheduledDraw = setTimeout(() => this.draw(), 1)
} }
}
function _rebuild(rows, cols) {
W = cols;
H = rows;
/* Build screen & show */ getFont (modifiers = {}) {
var cOuter, cInner, cell, screenDiv = qs('#screen'); let fontStyle = modifiers.style || 'normal'
let fontWeight = modifiers.weight || 'normal'
return `${fontStyle} normal ${fontWeight} ${
this.window.fontSize}px ${this.window.fontFamily}`
}
// Empty the screen node getCharSize () {
while (screenDiv.firstChild) screenDiv.removeChild(screenDiv.firstChild); this.ctx.font = this.getFont()
screen = []; return {
width: this.ctx.measureText(' ').width,
height: this.window.fontSize
}
}
for(var i = 0; i < W*H; i++) { updateSize () {
cOuter = mk('span'); this._window.devicePixelRatio = window.devicePixelRatio || 1
cInner = mk('span');
/* Mouse tracking */ let didChange = false
(function() { for (let key in this.windowState) {
var x = i % W; if (this.windowState[key] !== this.window[key]) {
var y = Math.floor(i / W); didChange = true
cOuter.addEventListener('mouseenter', function (evt) { this.windowState[key] = this.window[key]
Input.onMouseMove(x, y);
});
cOuter.addEventListener('mousedown', function (evt) {
Input.onMouseDown(x, y, evt.button+1);
});
cOuter.addEventListener('mouseup', function (evt) {
Input.onMouseUp(x, y, evt.button+1);
});
cOuter.addEventListener('contextmenu', function (evt) {
if (Input.mouseTracksClicks()) {
evt.preventDefault();
} }
});
cOuter.addEventListener('mousewheel', function (evt) {
Input.onMouseWheel(x, y, evt.deltaY>0?1:-1);
return false;
});
})();
/* End of line */
if ((i > 0) && (i % W == 0)) {
screenDiv.appendChild(mk('br'));
} }
/* The cell */
cOuter.appendChild(cInner);
screenDiv.appendChild(cOuter);
cell = { if (didChange) {
t: ' ', const {
fg: 7, width, height, devicePixelRatio, gridScaleX, gridScaleY, fontSize
bg: 0, // the colors will be replaced immediately as we receive data (user won't see this) } = this.window
attrs: 0, const charSize = this.getCharSize()
elem: cOuter,
slot: cInner, this.canvas.width = width * devicePixelRatio * charSize.width * gridScaleX
x: i % W, this.canvas.style.width = `${width * charSize.width * gridScaleX}px`
y: Math.floor(i / W), this.canvas.height = height * devicePixelRatio * charSize.height *
}; gridScaleY
screen.push(cell); this.canvas.style.height = `${height * charSize.height * gridScaleY}px`
_draw(cell);
} }
} }
/** Init the terminal */ resetCursorBlink () {
function _init() { clearInterval(this.cursor.blinkInterval)
/* Cursor blinking */ this.cursor.blinkInterval = setInterval(() => {
clearInterval(blinkIval); this.cursor.blinkOn = !this.cursor.blinkOn
blinkIval = setInterval(function () { this.scheduleDraw()
cursor.a = !cursor.a; }, 500)
if (cursor.hidden || cursor.hanging) {
cursor.a = false;
} }
if (!cursor.suppress) { resetBlink () {
_draw(_curCell(), cursor.forceOn || cursor.a); 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
})
} }
}, 500);
/* blink attribute animation */
setInterval(function () {
$('#screen').removeClass('blink-hide');
setTimeout(function () {
$('#screen').addClass('blink-hide');
}, 800); // 200 ms ON
}, 1000);
inited = true;
} }
// constants for decoding the update blob loadContent (str) {
var SEQ_SET_COLOR_ATTR = 1; // current index
var SEQ_REPEAT = 2; let i = 0
var SEQ_SET_COLOR = 3;
var SEQ_SET_ATTR = 4;
/** Parse received screen update object (leading S removed already) */ // window size
function _load_content(str) { this.window.height = parse2B(str, i)
var i = 0, ci = 0, j, jc, num, num2, t = ' ', fg, bg, attrs, cell; this.window.width = parse2B(str, i + 2)
this.updateSize()
i += 4
if (!inited) _init(); // 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
var cursorMoved; if (cursorMoved) this.resetCursorBlink()
// Set size
num = parse2B(str, i); i += 2; // height
num2 = parse2B(str, i); i += 2; // width
if (num != H || num2 != W) {
_rebuild(num, num2);
}
// console.log("Size ",num, num2);
// Cursor position // attributes
num = parse2B(str, i); i += 2; // row let attributes = parse2B(str, i)
num2 = parse2B(str, i); i += 2; // col i += 2
cursorMoved = (cursor.x != num2 || cursor.y != num);
cursorSet(num, num2);
// console.log("Cursor at ",num, num2);
// Attributes this.cursor.visible = !!(attributes & 1)
num = parse2B(str, i); i += 2; // fg bg attribs this.cursor.hanging = !!(attributes & 1 << 0)
cursor.hidden = !(num & (1<<0)); // DEC opt "visible"
cursor.hanging = !!(num & (1<<1));
// console.log("Attributes word ",num.toString(16)+'h');
Input.setAlts( Input.setAlts(
!!(num & (1<<2)), // cursors alt !!(attributes & 1 << 2), // cursors alt
!!(num & (1<<3)), // numpad alt !!(attributes & 1 << 3), // numpad alt
!!(num & (1<<4)) // fn keys alt !!(attributes & 1 << 4) // fn keys alt
); )
var mt_click = !!(num & (1<<5)); let trackMouseClicks = !!(attributes & 1 << 5)
var mt_move = !!(num & (1<<6)); let trackMouseMovement = !!(attributes & 1 << 6)
Input.setMouseMode(
mt_click, Input.setMouseMode(trackMouseClicks, trackMouseMovement)
mt_move
); let showButtons = !!(attributes & 1 << 7)
$('#screen').toggleClass('noselect', mt_move); let showConfigLinks = !!(attributes & 1 << 8)
var show_buttons = !!(num & (1<<7)); $('.x-term-conf-btn').toggleClass('hidden', !showConfigLinks)
var show_config_links = !!(num & (1<<8)); $('#action-buttons').toggleClass('hidden', !showButtons)
$('.x-term-conf-btn').toggleClass('hidden', !show_config_links);
$('#action-buttons').toggleClass('hidden', !show_buttons); // content
let fg = 7
fg = 7; let bg = 0
bg = 0; let attrs = 0
attrs = 0; let cell = 0 // cell index
let text = ' '
// Here come the content let screenLength = this.window.width * this.window.height
while(i < str.length && ci<W*H) {
this.screen = new Array(screenLength).fill(' ')
j = str[i++]; this.screenFG = new Array(screenLength).fill(' ')
jc = j.charCodeAt(0); this.screenBG = new Array(screenLength).fill(' ')
if (jc == SEQ_SET_COLOR_ATTR) { this.screenAttrs = new Array(screenLength).fill(' ')
num = parse3B(str, i); i += 3;
fg = num & 0x0F; while (i < str.length && cell < screenLength) {
bg = (num & 0xF0) >> 4; let character = str[i++]
attrs = (num & 0xFF00)>>8; let charCode = character.charCodeAt(0)
}
else if (jc == SEQ_SET_COLOR) { if (charCode === SEQ_SET_COLOR_ATTR) {
num = parse2B(str, i); i += 2; let data = parse3B(str, i)
fg = num & 0x0F; i += 3
bg = (num & 0xF0) >> 4; fg = data & 0xF
} bg = data >> 4 & 0xF
else if (jc == SEQ_SET_ATTR) { attrs = data >> 8 & 0xFF
num = parse2B(str, i); i += 2; } else if (charCode == SEQ_SET_COLOR) {
attrs = num & 0xFF; let data = parse2B(str, i)
} i += 2
else if (jc == SEQ_REPEAT) { fg = data & 0xF
num = parse2B(str, i); i += 2; bg = data >> 4 & 0xF
// console.log("Repeat x ",num); } else if (charCode === SEQ_SET_ATTR) {
for (; num>0 && ci<W*H; num--) { let data = parse2B(str, i)
cell = screen[ci++]; i += 2
cell.fg = fg; attrs = data & 0xFF
cell.bg = bg; } else if (charCode === SEQ_REPEAT) {
cell.t = t; let count = parse2B(str, i)
cell.attrs = attrs; i += 2
} for (let j = 0; j < count; j++) {
} this.screen[cell] = text
else { this.screenFG[cell] = fg
cell = screen[ci++]; this.screenBG[cell] = bg
// Unique cell character this.screenAttrs[cell] = attrs
t = cell.t = j;
cell.fg = fg; if (++cell > screenLength) break
cell.bg = bg; }
cell.attrs = attrs; } else {
// console.log("Symbol ", j); // unique cell character
} this.screen[cell] = text = character
} this.screenFG[cell] = fg
this.screenBG[cell] = bg
_drawAll(); this.screenAttrs[cell] = attrs
cell++
// if (!cursor.hidden || cursor.hanging || !cursor.suppress) {
// // hide cursor asap
// _draw(_curCell(), false);
// }
if (cursorMoved) {
cursor.forceOn = true;
cursorFlashStartIval = setTimeout(function() {
cursor.forceOn = false;
}, 1200);
_draw(_curCell(), true);
} }
} }
this.scheduleDraw()
if (this.onload) this.onload()
}
/** Apply labels to buttons and screen title (leading T removed already) */ /** Apply labels to buttons and screen title (leading T removed already) */
function _load_labels(str) { loadLabels (str) {
var pieces = str.split('\x01'); let pieces = str.split('\x01')
qs('h1').textContent = pieces[0]; qs('h1').textContent = pieces[0]
$('#action-buttons button').forEach(function(x, i) { $('#action-buttons button').forEach((button, i) => {
var s = pieces[i+1].trim(); var label = pieces[i + 1].trim()
// if empty string, use the "dim" effect and put nbsp instead to stretch the btn vertically // if empty string, use the "dim" effect and put nbsp instead to
x.innerHTML = s.length > 0 ? e(s) : "&nbsp;"; // stretch the button vertically
x.style.opacity = s.length > 0 ? 1 : 0.2; button.innerHTML = label ? e(label) : '&nbsp;';
button.style.opacity = label ? 1 : 0.2;
}) })
} }
/** Audible beep for ASCII 7 */ load (str) {
function _beep() { const content = str.substr(1)
var osc, gain;
if (!audioCtx) return;
// Main beep switch (str[0]) {
osc = audioCtx.createOscillator(); case 'S':
gain = audioCtx.createGain(); this.loadContent(content)
osc.connect(gain); 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.connect(audioCtx.destination);
gain.gain.value = 0.5; gain.gain.value = 0.5;
osc.frequency.value = 750; osc.frequency.value = 750;
osc.type = 'sine'; osc.type = 'sine';
osc.start(); osc.start();
osc.stop(audioCtx.currentTime+0.05); osc.stop(audioCtx.currentTime + 0.05);
// Surrogate beep (making it sound like 'oops') // surrogate beep (making it sound like 'oops')
osc = audioCtx.createOscillator(); osc = audioCtx.createOscillator();
gain = audioCtx.createGain(); gain = audioCtx.createGain();
osc.connect(gain); osc.connect(gain);
@ -348,31 +419,25 @@ var Screen = (function () {
gain.gain.value = 0.2; gain.gain.value = 0.2;
osc.frequency.value = 400; osc.frequency.value = 400;
osc.type = 'sine'; osc.type = 'sine';
osc.start(audioCtx.currentTime+0.05); osc.start(audioCtx.currentTime + 0.05);
osc.stop(audioCtx.currentTime+0.08); osc.stop(audioCtx.currentTime + 0.08);
} }
/** Load screen content from a binary sequence (new) */ static alphaToFraktur (character) {
function load(str) { if ('a' <= character && character <= 'z') {
//console.log(JSON.stringify(str)); character = String.fromCodePoint(0x1d51e - 0x61 + character.charCodeAt(0))
var content = str.substr(1); } else if ('A' <= character && character <= 'Z') {
switch(str.charAt(0)) { character = frakturExceptions[character] || String.fromCodePoint(
case 'S': 0x1d504 - 0x41 + character.charCodeAt(0))
_load_content(content);
break;
case 'T':
_load_labels(content);
break;
case 'B':
_beep();
break;
default:
console.warn("Bad data message type, ignoring.");
console.log(str);
} }
return character
} }
}
return { const Screen = new TermScreen()
load: load, // full load (string) let didAddScreen = false
}; Screen.onload = function () {
})(); if (didAddScreen) return
didAddScreen = true
qs('#screen').appendChild(Screen.canvas)
}

Loading…
Cancel
Save