*since JS doesn't really have type checking the now split screen classes might throw errors and stuff later on, but right now everything seems to be working finebox-drawing
parent
df4c75b370
commit
9ccf9dd2cf
@ -1,5 +1,5 @@ |
||||
const EventEmitter = require('events') |
||||
const $ = require('./lib/chibi') |
||||
const $ = require('../lib/chibi') |
||||
let demo |
||||
try { demo = require('./demo') } catch (err) {} |
||||
|
@ -1,4 +1,4 @@ |
||||
const { mk } = require('./utils') |
||||
const { mk } = require('../utils') |
||||
|
||||
module.exports = function attachDebugScreen (screen) { |
||||
const debugCanvas = mk('canvas') |
@ -1,5 +1,5 @@ |
||||
const EventEmitter = require('events') |
||||
const { encode2B, encode3B, parse2B } = require('./utils') |
||||
const { encode2B, encode3B, parse2B } = require('../utils') |
||||
|
||||
class ANSIParser { |
||||
constructor (handler) { |
@ -1,9 +1,9 @@ |
||||
const { qs, mk } = require('./utils') |
||||
const Notify = require('./notif') |
||||
const TermScreen = require('./term_screen') |
||||
const TermConnection = require('./term_conn') |
||||
const TermInput = require('./term_input') |
||||
const TermUpload = require('./term_upload') |
||||
const { qs, mk } = require('../utils') |
||||
const Notify = require('../notif') |
||||
const TermScreen = require('./screen') |
||||
const TermConnection = require('./connection') |
||||
const TermInput = require('./input') |
||||
const TermUpload = require('./upload') |
||||
const initSoftKeyboard = require('./soft_keyboard') |
||||
const attachDebugScreen = require('./debug_screen') |
||||
|
@ -1,5 +1,5 @@ |
||||
const $ = require('./lib/chibi') |
||||
const { encode2B } = require('./utils') |
||||
const $ = require('../lib/chibi') |
||||
const { encode2B } = require('../utils') |
||||
|
||||
/** |
||||
* User input |
@ -0,0 +1,577 @@ |
||||
const EventEmitter = require('events') |
||||
const $ = require('../lib/chibi') |
||||
const { mk, qs } = require('../utils') |
||||
const notify = require('../notif') |
||||
const ScreenParser = require('./screen_parser') |
||||
const ScreenRenderer = require('./screen_renderer') |
||||
|
||||
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) |
||||
|
||||
// debug screen handle
|
||||
this._debug = null |
||||
|
||||
if ('AudioContext' in window || 'webkitAudioContext' in window) { |
||||
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)() |
||||
} else { |
||||
console.warn('No AudioContext!') |
||||
} |
||||
|
||||
// dummy. Handle for Input
|
||||
this.input = new Proxy({}, { |
||||
get () { |
||||
return () => console.warn('TermScreen#input not set!') |
||||
} |
||||
}) |
||||
|
||||
this.cursor = { |
||||
x: 0, |
||||
y: 0, |
||||
blinking: true, |
||||
visible: true, |
||||
hanging: false, |
||||
style: 'block' |
||||
} |
||||
|
||||
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, |
||||
fitIntoWidth: 0, |
||||
fitIntoHeight: 0, |
||||
debug: false, |
||||
graphics: 0, |
||||
statusScreen: null |
||||
} |
||||
|
||||
// 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.renderer.scheduleDraw(`window:${key}=${value}`) |
||||
self.emit(`update-window:${key}`, value) |
||||
return true |
||||
} |
||||
}) |
||||
|
||||
this.bracketedPaste = false |
||||
this.blinkingCellCount = 0 |
||||
|
||||
this.screen = [] |
||||
this.screenFG = [] |
||||
this.screenBG = [] |
||||
this.screenAttrs = [] |
||||
|
||||
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') |
||||
} |
||||
|
||||
let selectMove = (x, y) => { |
||||
if (!selecting) return |
||||
this.selection.end = this.screenToGrid(x, y, true) |
||||
this.renderer.scheduleDraw('select-move') |
||||
} |
||||
|
||||
let selectEnd = (x, y) => { |
||||
if (!selecting) return |
||||
selecting = false |
||||
this.selection.end = this.screenToGrid(x, y, true) |
||||
this.renderer.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.renderer.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) |
||||
}) |
||||
} |
||||
|
||||
/** |
||||
* Schedule a size update in the next millisecond |
||||
*/ |
||||
scheduleSizeUpdate () { |
||||
clearTimeout(this._scheduledSizeUpdate) |
||||
this._scheduledSizeUpdate = setTimeout(() => this.updateSize(), 1) |
||||
} |
||||
|
||||
/** |
||||
* 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() |
||||
|
||||
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 |
||||
* @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 => withScale ? v * this._windowScale : v) |
||||
} |
||||
|
||||
/** |
||||
* 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 |
||||
} |
||||
|
||||
// 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.renderer.resetDrawn() |
||||
|
||||
// draw immediately; the canvas shouldn't flash
|
||||
this.renderer.draw('update-size') |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 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) |
||||
} |
||||
|
||||
/** |
||||
* 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) |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 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() |
||||
|
||||
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
|
||||
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')
|
||||
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) |
||||
mainGain.gain.value *= 0.8 |
||||
surrGain.gain.value *= 0.8 |
||||
} |
||||
loop() |
||||
} |
||||
|
||||
load (...args) { |
||||
this.parser.load(...args) |
||||
} |
||||
} |
@ -0,0 +1,259 @@ |
||||
const $ = require('../lib/chibi') |
||||
const { qs, parse2B, parse3B } = require('../utils') |
||||
const { themes } = 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 |
||||
|
||||
module.exports = class ScreenParser { |
||||
constructor (screen) { |
||||
this.screen = screen |
||||
|
||||
// true if TermScreen#load was called at least once
|
||||
this.contentLoaded = false |
||||
} |
||||
/** |
||||
* 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.screen.window.height !== newHeight) || (this.screen.window.width !== newWidth) |
||||
this.screen.window.height = newHeight |
||||
this.screen.window.width = newWidth |
||||
i += 4 |
||||
|
||||
// cursor position
|
||||
let [cursorY, cursorX] = [parse2B(str, i), parse2B(str, i + 2)] |
||||
i += 4 |
||||
let cursorMoved = (cursorX !== this.screen.cursor.x || cursorY !== this.screen.cursor.y) |
||||
this.screen.cursor.x = cursorX |
||||
this.screen.cursor.y = cursorY |
||||
|
||||
if (cursorMoved) { |
||||
this.screen.renderer.resetCursorBlink() |
||||
this.screen.emit('cursor-moved') |
||||
} |
||||
|
||||
// attributes
|
||||
let attributes = parse3B(str, i) |
||||
i += 3 |
||||
|
||||
this.screen.cursor.visible = !!(attributes & 1) |
||||
this.screen.cursor.hanging = !!(attributes & (1 << 1)) |
||||
|
||||
this.screen.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.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 |
||||
} |
||||
|
||||
let showButtons = !!(attributes & (1 << 7)) |
||||
let showConfigLinks = !!(attributes & (1 << 8)) |
||||
|
||||
$('.x-term-conf-btn').toggleClass('hidden', !showConfigLinks) |
||||
$('#action-buttons').toggleClass('hidden', !showButtons) |
||||
|
||||
this.screen.bracketedPaste = !!(attributes & (1 << 13)) |
||||
|
||||
// content
|
||||
let fg = 7 |
||||
let bg = 0 |
||||
let attrs = 0 |
||||
let cell = 0 // cell index
|
||||
let lastChar = ' ' |
||||
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(' ') |
||||
} |
||||
|
||||
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.screen.screenAttrs[cell] & MASK_BLINK) !== (myAttrs & MASK_BLINK)) { |
||||
if (myAttrs & MASK_BLINK) this.screen.blinkingCellCount++ |
||||
else this.screen.blinkingCellCount-- |
||||
} |
||||
|
||||
this.screen.screen[cell] = lastChar |
||||
this.screen.screenFG[cell] = fg |
||||
this.screen.screenBG[cell] = bg |
||||
this.screen.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.screen.window.debug) console.log(`Blinky cells: ${this.screen.blinkingCellCount}`) |
||||
|
||||
this.screen.renderer.scheduleDraw('load', 16) |
||||
this.screen.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) : ' ' |
||||
button.style.opacity = label ? 1 : 0.2 |
||||
}) |
||||
} |
||||
|
||||
/** |
||||
* 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.screen.renderer.palette = themes[theme] |
||||
} |
||||
|
||||
switch (str[0]) { |
||||
case 'S': |
||||
this.loadContent(content) |
||||
break |
||||
|
||||
case 'T': |
||||
this.loadLabels(content) |
||||
break |
||||
|
||||
case 'B': |
||||
this.screen.beep() |
||||
break |
||||
|
||||
case 'G': |
||||
this.screen.showNotification(content) |
||||
break |
||||
|
||||
default: |
||||
console.warn(`Bad data message type; ignoring.\n${JSON.stringify(str)}`) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,673 @@ |
||||
const { themes, buildColorTable, SELECTION_FG, SELECTION_BG } = require('./themes') |
||||
|
||||
// Some non-bold Fraktur symbols are outside the contiguous block
|
||||
const frakturExceptions = { |
||||
'C': '\u212d', |
||||
'H': '\u210c', |
||||
'I': '\u2111', |
||||
'R': '\u211c', |
||||
'Z': '\u2128' |
||||
} |
||||
|
||||
module.exports = class ScreenRenderer { |
||||
constructor (screen) { |
||||
this.screen = screen |
||||
this.ctx = screen.ctx |
||||
|
||||
this._palette = null |
||||
|
||||
// 256color lookup table
|
||||
// should not be used to look up 0-15 (will return transparent)
|
||||
this.colorTable256 = buildColorTable() |
||||
|
||||
this.resetDrawn() |
||||
|
||||
this.blinkStyleOn = false |
||||
this.blinkInterval = null |
||||
this.cursorBlinkOn = false |
||||
this.cursorBlinkInterval = null |
||||
|
||||
// start blink timers
|
||||
this.resetBlink() |
||||
this.resetCursorBlink() |
||||
} |
||||
|
||||
resetDrawn () { |
||||
// 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, ''] |
||||
} |
||||
|
||||
/** |
||||
* 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') |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 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 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)' |
||||
} |
||||
|
||||
/** |
||||
* Resets the cursor blink to on and restarts the timer |
||||
*/ |
||||
resetCursorBlink () { |
||||
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') |
||||
}, 500) |
||||
} |
||||
|
||||
/** |
||||
* Resets the blink style to on and restarts the timer |
||||
*/ |
||||
resetBlink () { |
||||
this.blinkStyleOn = true |
||||
clearInterval(this.blinkInterval) |
||||
let intervals = 0 |
||||
this.blinkInterval = setInterval(() => { |
||||
if (this.screen.blinkingCellCount <= 0) return |
||||
|
||||
intervals++ |
||||
if (intervals >= 4 && this.blinkStyleOn) { |
||||
this.blinkStyleOn = false |
||||
intervals = 0 |
||||
this.scheduleDraw('blink-style') |
||||
} else if (intervals >= 1 && !this.blinkStyleOn) { |
||||
this.blinkStyleOn = true |
||||
intervals = 0 |
||||
this.scheduleDraw('blink-style') |
||||
} |
||||
}, 200) |
||||
} |
||||
|
||||
/** |
||||
* 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 |
||||
*/ |
||||
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 |
||||
*/ |
||||
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 = ScreenRenderer.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.screen.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.screen.window |
||||
|
||||
if (statusScreen) { |
||||
// draw status screen instead
|
||||
this.drawStatus(statusScreen) |
||||
this.startDrawLoop() |
||||
return |
||||
} else this.stopDrawLoop() |
||||
|
||||
const charSize = this.screen.getCharSize() |
||||
const { width: cellWidth, height: cellHeight } = this.screen.getCellSize() |
||||
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) |
||||
|
||||
ctx.font = this.screen.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.screen.cursor.hanging && |
||||
this.screen.cursor.x === x && |
||||
this.screen.cursor.y === y && |
||||
this.screen.cursor.visible && |
||||
this.cursorBlinkOn |
||||
|
||||
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] |
||||
let bg = this.screen.screenBG[cell] |
||||
let attrs = this.screen.screenAttrs[cell] |
||||
|
||||
if (attrs & (1 << 4) && !this.blinkStyleOn) { |
||||
// blinking is enabled and blink style is off
|
||||
// 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.screen.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.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.screen.window.graphics < 2 || isWideCell || isTextWide(this.screen.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.screen.window.graphics >= 1) { |
||||
let debug = this.screen.window.debug && this.screen._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.screen._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.screen._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)) { |
||||
this.drawBackground({ x, y, cellWidth, cellHeight, bg }) |
||||
|
||||
if (this.screen.window.debug && this.screen._debug) { |
||||
// set cell flags
|
||||
let flags = (+redrawMap.get(cell)) |
||||
flags |= (+updateMap.get(cell)) << 1 |
||||
flags |= (+isTextWide(text)) << 2 |
||||
this.screen._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.screen.getFont(modifiers) |
||||
|
||||
for (let data of fontGroups.get(font)) { |
||||
let [cell, x, y, text, fg, bg, attrs, isCursor, inSelection] = data |
||||
|
||||
if (redrawMap.get(cell)) { |
||||
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.screen.cursor.style] |
||||
|
||||
if (isCursor && !inSelection) { |
||||
ctx.save() |
||||
ctx.beginPath() |
||||
if (this.screen.cursor.style === 'block') { |
||||
// block
|
||||
ctx.rect(x * cellWidth, y * cellHeight, cellWidth, cellHeight) |
||||
} else if (this.screen.cursor.style === 'bar') { |
||||
// vertical bar
|
||||
let barWidth = 2 |
||||
ctx.rect(x * cellWidth, y * cellHeight, barWidth, cellHeight) |
||||
} else if (this.screen.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 |
||||
|
||||
this.drawBackground({ x, y, cellWidth, cellHeight, bg }) |
||||
this.drawCharacter({ |
||||
x, y, charSize, cellWidth, cellHeight, text, fg, attrs |
||||
}) |
||||
ctx.restore() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (this.screen.window.graphics >= 1) ctx.restore() |
||||
|
||||
if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawEnd() |
||||
|
||||
this.screen.emit('draw') |
||||
} |
||||
|
||||
drawStatus (statusScreen) { |
||||
const ctx = this.ctx |
||||
const { |
||||
fontFamily, |
||||
width, |
||||
height, |
||||
devicePixelRatio |
||||
} = this.screen.window |
||||
|
||||
// reset drawnScreen to force redraw when statusScreen is disabled
|
||||
this.drawnScreen = [] |
||||
|
||||
const cellSize = this.screen.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') |
||||
} |
||||
|
||||
/** |
||||
* Converts an alphabetic character to its fraktur variant. |
||||
* @param {string} character - the character |
||||
* @returns {string} the converted character |
||||
*/ |
||||
static alphaToFraktur (character) { |
||||
if (character >= 'a' && character <= 'z') { |
||||
character = String.fromCodePoint(0x1d51e - 0x61 + character.charCodeAt(0)) |
||||
} else if (character >= 'A' && character <= 'Z') { |
||||
character = frakturExceptions[character] || String.fromCodePoint(0x1d504 - 0x41 + character.charCodeAt(0)) |
||||
} |
||||
return character |
||||
} |
||||
} |
@ -1,4 +1,4 @@ |
||||
const { qs } = require('./utils') |
||||
const { qs } = require('../utils') |
||||
|
||||
module.exports = function (screen, input) { |
||||
const keyInput = qs('#softkb-input') |
@ -1,6 +1,6 @@ |
||||
const $ = require('./lib/chibi') |
||||
const { qs } = require('./utils') |
||||
const modal = require('./modal') |
||||
const $ = require('../lib/chibi') |
||||
const { qs } = require('../utils') |
||||
const modal = require('../modal') |
||||
|
||||
/** File upload utility */ |
||||
module.exports = function (conn, input, screen) { |
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue