diff --git a/js/term/index.js b/js/term/index.js index f241472..5f40d0e 100644 --- a/js/term/index.js +++ b/js/term/index.js @@ -127,7 +127,7 @@ module.exports = function (opts) { return false } - qs('#screen').appendChild(screen.canvas) + qs('#screen').appendChild(screen.layout.canvas) initSoftKeyboard(screen, input) if (attachDebugger) attachDebugger(screen, conn) diff --git a/js/term/screen.js b/js/term/screen.js index 8c0458c..ec7d1cf 100644 --- a/js/term/screen.js +++ b/js/term/screen.js @@ -24,6 +24,28 @@ module.exports = class TermScreen extends EventEmitter { console.warn('No AudioContext!') } + this._window = { + width: 0, + height: 0, + // two bits. LSB: debug enabled by user, MSB: debug enabled by server + debug: 0, + statusScreen: null + } + + // make writing to window update size and draw + this.window = new Proxy(this._window, { + set (target, key, value) { + if (target[key] !== value) { + target[key] = value + self.updateLayout() + self.emit(`update-window:${key}`, value) + } + return true + } + }) + + this.on('update-window:debug', debug => { this.layout.window.debug = !!debug }) + this.cursor = { x: 0, y: 0, @@ -69,20 +91,20 @@ module.exports = class TermScreen extends EventEmitter { let selectStart = (x, y) => { if (selecting) return selecting = true - this.selection.start = this.selection.end = this.screenToGrid(x, y, true) + this.selection.start = this.selection.end = this.layout.screenToGrid(x, y, true) this.layout.scheduleDraw('select-start') } let selectMove = (x, y) => { if (!selecting) return - this.selection.end = this.screenToGrid(x, y, true) + this.selection.end = this.layout.screenToGrid(x, y, true) this.layout.scheduleDraw('select-move') } let selectEnd = (x, y) => { if (!selecting) return selecting = false - this.selection.end = this.screenToGrid(x, y, true) + this.selection.end = this.layout.screenToGrid(x, y, true) this.layout.scheduleDraw('select-end') Object.assign(this.selection, this.getNormalizedSelection()) } @@ -94,7 +116,7 @@ module.exports = class TermScreen extends EventEmitter { if ((this.selection.selectable || e.altKey) && e.button === 0) { selectStart(e.offsetX, e.offsetY) } else { - this.emit('mousedown', ...this.screenToGrid(e.offsetX, e.offsetY), e.button + 1) + this.emit('mousedown', ...this.layout.screenToGrid(e.offsetX, e.offsetY), e.button + 1) } }) @@ -150,7 +172,7 @@ module.exports = class TermScreen extends EventEmitter { // selection ended; show touch select menu // use middle position for x and one line above for y - let selectionPos = this.gridToScreen( + let selectionPos = this.layout.gridToScreen( (this.selection.start[0] + this.selection.end[0]) / 2, this.selection.start[1] - 1 ) @@ -184,13 +206,13 @@ module.exports = class TermScreen extends EventEmitter { this.layout.on('mousemove', e => { if (!selecting) { - this.emit('mousemove', ...this.screenToGrid(e.offsetX, e.offsetY)) + this.emit('mousemove', ...this.layout.screenToGrid(e.offsetX, e.offsetY)) } }) this.layout.on('mouseup', e => { if (!selecting) { - this.emit('mouseup', ...this.screenToGrid(e.offsetX, e.offsetY), + this.emit('mouseup', ...this.layout.screenToGrid(e.offsetX, e.offsetY), e.button + 1) } }) @@ -200,12 +222,12 @@ module.exports = class TermScreen extends EventEmitter { if (this.mouseMode.clicks) { if (Math.abs(e.wheelDeltaY) === 120) { // mouse wheel scrolling - this.emit('mousewheel', ...this.screenToGrid(e.offsetX, e.offsetY), e.deltaY > 0 ? 1 : -1) + this.emit('mousewheel', ...this.layout.screenToGrid(e.offsetX, e.offsetY), e.deltaY > 0 ? 1 : -1) } else { // smooth scrolling aggregateWheelDelta -= e.wheelDeltaY if (Math.abs(aggregateWheelDelta) >= 40) { - this.emit('mousewheel', ...this.screenToGrid(e.offsetX, e.offsetY), aggregateWheelDelta > 0 ? 1 : -1) + this.emit('mousewheel', ...this.layout.screenToGrid(e.offsetX, e.offsetY), aggregateWheelDelta > 0 ? 1 : -1) aggregateWheelDelta = 0 } } @@ -233,6 +255,24 @@ module.exports = class TermScreen extends EventEmitter { this.screen.screenAttrs = new Array(width * height).fill(0) } + updateLayout () { + this.layout.window.width = this.window.width + this.layout.window.height = this.window.height + } + + renderScreen () { + this.layout.render({ + width: this.window.width, + height: this.window.height, + screen: this.screen, + screenFG: this.screenFG, + screenBG: this.screenBG, + screenAttrs: this.screenAttrs, + cursor: this.cursor, + statusScreen: this.window.statusScreen + }) + } + /** * Returns a normalized version of the current selection, such that `start` * is always before `end`. @@ -399,8 +439,8 @@ module.exports = class TermScreen extends EventEmitter { this.window.height = update.height this.resetScreen() } - this.renderer.loadTheme(update.theme) - this.renderer.setDefaultColors(update.defFG, update.defBG) + this.layout.renderer.loadTheme(update.theme) + this.layout.renderer.setDefaultColors(update.defFG, update.defBG) this.cursor.visible = update.cursorVisible this.emit('input-alts', ...update.inputAlts) this.mouseMode.clicks = update.trackMouseClicks @@ -409,7 +449,7 @@ module.exports = class TermScreen extends EventEmitter { this.selection.setSelectable(!update.trackMouseClicks && !update.trackMouseMovement) if (this.cursor.blinking !== update.cursorBlinking) { this.cursor.blinking = update.cursorBlinking - this.renderer.resetCursorBlink() + this.layout.renderer.resetCursorBlink() } this.cursor.style = update.cursorStyle this.bracketedPaste = update.bracketedPaste @@ -426,9 +466,9 @@ module.exports = class TermScreen extends EventEmitter { this.cursor.x = update.x this.cursor.y = update.y this.cursor.hanging = update.hanging - this.renderer.resetCursorBlink() + this.layout.renderer.resetCursorBlink() this.emit('cursor-moved') - this.renderer.scheduleDraw('cursor-moved') + this.layout.renderer.scheduleDraw('cursor-moved') } break @@ -479,7 +519,7 @@ module.exports = class TermScreen extends EventEmitter { if (this.window.debug) console.log(`Blinking cells: ${this.blinkingCellCount}`) - this.renderer.scheduleDraw('load', 16) + this.layout.renderer.scheduleDraw('load', 16) this.emit('load') break diff --git a/js/term/screen_layout.js b/js/term/screen_layout.js index e4c86e1..e237b59 100644 --- a/js/term/screen_layout.js +++ b/js/term/screen_layout.js @@ -9,8 +9,7 @@ module.exports = class ScreenLayout extends EventEmitter { super() this.canvas = document.createElement('canvas') - - this.renderer = new CanvasRenderer(this.canvas.getContext('2d')) + this.renderer = new CanvasRenderer(this.canvas) this._window = { width: 0, @@ -23,10 +22,7 @@ module.exports = class ScreenLayout extends EventEmitter { gridScaleY: 1.2, fitIntoWidth: 0, fitIntoHeight: 0, - // two bits. LSB: debug enabled by user, MSB: debug enabled by server - debug: 0, - graphics: 0, - statusScreen: null + graphics: 0 } // scaling caused by fitIntoWidth/fitIntoHeight @@ -46,7 +42,8 @@ module.exports = class ScreenLayout extends EventEmitter { fontFamily: '', fontSize: 0, fitIntoWidth: 0, - fitIntoHeight: 0 + fitIntoHeight: 0, + debug: false } this.charSize = { width: 0, height: 0 } @@ -55,7 +52,7 @@ module.exports = class ScreenLayout extends EventEmitter { // make writing to window update size and draw this.window = new Proxy(this._window, { - set (target, key, value, receiver) { + set (target, key, value) { if (target[key] !== value) { target[key] = value self.scheduleSizeUpdate() @@ -66,6 +63,8 @@ module.exports = class ScreenLayout extends EventEmitter { } }) + this.on('update-window:debug', debug => { this.renderer.debug = debug }) + this.canvas.addEventListener('mousedown', e => this.emit('mousedown', e)) this.canvas.addEventListener('mousemove', e => this.emit('mousemove', e)) this.canvas.addEventListener('mouseup', e => this.emit('mouseup', e)) @@ -159,10 +158,8 @@ module.exports = class ScreenLayout extends EventEmitter { * @returns {Object} the character size with `width` and `height` in pixels */ updateCharSize () { - this.ctx.font = this.getFont() - this.charSize = { - width: Math.floor(this.ctx.measureText(' ').width), + width: this.renderer.getCharWidthFor(this.getFont()), height: this.window.fontSize } @@ -251,4 +248,8 @@ module.exports = class ScreenLayout extends EventEmitter { this.renderer.draw('update-size') } } + + render (...args) { + this.renderer.render(...args) + } } diff --git a/js/term/screen_renderer.js b/js/term/screen_renderer.js index 6893a66..bc79f93 100644 --- a/js/term/screen_renderer.js +++ b/js/term/screen_renderer.js @@ -1,3 +1,4 @@ +const EventEmitter = require('events') const { themes, buildColorTable, @@ -30,9 +31,12 @@ const frakturExceptions = { /** * A terminal screen renderer, using canvas 2D */ -module.exports = class CanvasRenderer { - constructor (context) { - this.ctx = context +module.exports = class CanvasRenderer extends EventEmitter { + constructor (canvas) { + super() + + this.canvas = canvas + this.ctx = this.canvas.getContext('2d') this._palette = null // colors 0-15 this.defaultBgNum = 0 @@ -42,6 +46,21 @@ module.exports = class CanvasRenderer { // should not be used to look up 0-15 (will return transparent) this.colorTable256 = buildColorTable() + this.debug = false + + // screen data, considered immutable + this.width = 0 + this.height = 0 + this.padding = 0 + this.charSize = { width: 0, height: 0 } + this.cellSize = { width: 0, height: 0 } + this.fonts = ['', '', '', ''] // normal, bold, italic, bold-italic + this.screen = [] + this.screenFG = [] + this.screenBG = [] + this.screenAttrs = [] + this.cursor = {} + this.resetDrawn() this.blinkStyleOn = false @@ -57,9 +76,8 @@ module.exports = class CanvasRenderer { resetDrawn () { // used to determine if a cell should be redrawn; storing the current state // as it is on screen - if (this.screen.window && this.screen.window.debug) { - console.log('Resetting drawn screen') - } + if (this.debug) console.log('Resetting drawn screen') + this.drawnScreen = [] this.drawnScreenFG = [] this.drawnScreenBG = [] @@ -84,6 +102,11 @@ module.exports = class CanvasRenderer { } } + getCharWidthFor (font) { + this.ctx.font = font + return Math.floor(this.ctx.measureText(' ').width) + } + loadTheme (i) { if (i in themes) this.palette = themes[i] } @@ -96,7 +119,7 @@ module.exports = class CanvasRenderer { this.scheduleDraw('default-colors') // full bg with default color (goes behind the image) - this.screen.canvas.style.backgroundColor = this.getColor(bg) + this.canvas.style.backgroundColor = this.getColor(bg) } } @@ -130,10 +153,8 @@ module.exports = class CanvasRenderer { this.cursorBlinkOn = true clearInterval(this.cursorBlinkInterval) this.cursorBlinkInterval = setInterval(() => { - this.cursorBlinkOn = this.screen.cursor.blinking - ? !this.cursorBlinkOn - : true - if (this.screen.cursor.blinking) this.scheduleDraw('cursor-blink') + this.cursorBlinkOn = this.cursor.blinking ? !this.cursorBlinkOn : true + if (this.cursor.blinking) this.scheduleDraw('cursor-blink') }, 500) } @@ -145,7 +166,7 @@ module.exports = class CanvasRenderer { clearInterval(this.blinkInterval) let intervals = 0 this.blinkInterval = setInterval(() => { - if (this.screen.blinkingCellCount <= 0) return + if (this.blinkingCellCount <= 0) return intervals++ if (intervals >= 4 && this.blinkStyleOn) { @@ -171,9 +192,8 @@ module.exports = class CanvasRenderer { * @param {number} options.isDefaultBG - if true, will draw image background if available */ drawBackground ({ x, y, cellWidth, cellHeight, bg, isDefaultBG }) { - const ctx = this.ctx - const { width, height } = this.screen.window - const padding = Math.round(this.screen._padding) + const { ctx, width, height, padding } = this + ctx.fillStyle = this.getColor(bg) let screenX = x * cellWidth + padding let screenY = y * cellHeight + padding @@ -225,8 +245,7 @@ module.exports = class CanvasRenderer { drawCharacter ({ x, y, charSize, cellWidth, cellHeight, text, fg, attrs }) { if (!text) return - const ctx = this.ctx - const padding = Math.round(this.screen._padding) + const { ctx, padding } = this let underline = false let strike = false @@ -426,7 +445,7 @@ module.exports = class CanvasRenderer { * @returns {number[]} an array of cell indices */ getAdjacentCells (cell, radius = 1) { - const { width, height } = this.screen.window + const { width, height } = this const screenLength = width * height let cells = [] @@ -452,7 +471,7 @@ module.exports = class CanvasRenderer { height, devicePixelRatio, statusScreen - } = this.screen.window + } = this if (statusScreen) { // draw status screen instead @@ -461,15 +480,15 @@ module.exports = class CanvasRenderer { return } else this.stopDrawLoop() - const charSize = this.screen.getCharSize() - const { width: cellWidth, height: cellHeight } = this.screen.getCellSize() + const charSize = this.charSize + const { width: cellWidth, height: cellHeight } = this.cellSize const screenLength = width * height ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0) - if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawStart(why) + // if (this.debug) this.screen._debug.drawStart(why) - ctx.font = this.screen.getFont() + ctx.font = this.fonts[0] ctx.textAlign = 'center' ctx.textBaseline = 'middle' @@ -486,18 +505,18 @@ module.exports = class CanvasRenderer { let x = cell % width let y = Math.floor(cell / width) let isCursor = this.cursorBlinkOn && - this.screen.cursor.x === x && - this.screen.cursor.y === y && - this.screen.cursor.visible + this.cursor.x === x && + this.cursor.y === y && + this.cursor.visible let wasCursor = x === this.drawnCursor[0] && y === this.drawnCursor[1] let inSelection = this.screen.isInSelection(x, y) - let text = this.screen.screen[cell] - let fg = this.screen.screenFG[cell] | 0 - let bg = this.screen.screenBG[cell] | 0 - let attrs = this.screen.screenAttrs[cell] | 0 + let text = this.screen[cell] + let fg = this.screenFG[cell] | 0 + let bg = this.screenBG[cell] | 0 + let attrs = this.screenAttrs[cell] | 0 let isDefaultBG = false @@ -526,8 +545,8 @@ module.exports = class CanvasRenderer { bg !== this.drawnScreenBG[cell] || // background updated attrs !== this.drawnScreenAttrs[cell] || // attributes updated isCursor !== wasCursor || // cursor blink/position updated - (isCursor && this.screen.cursor.style !== this.drawnCursor[2]) || // cursor style updated - (isCursor && this.screen.cursor.hanging !== this.drawnCursor[3]) // cursor hanging updated + (isCursor && this.cursor.style !== this.drawnCursor[2]) || // cursor style updated + (isCursor && this.cursor.hanging !== this.drawnCursor[3]) // cursor hanging updated let font = attrs & FONT_MASK if (!fontGroups.has(font)) fontGroups.set(font, []) @@ -547,7 +566,7 @@ module.exports = class CanvasRenderer { let shouldUpdate = updateMap.get(cell) || redrawMap.get(cell) || false // TODO: fonts (necessary?) - let text = this.screen.screen[cell] + let text = this.screen[cell] let isWideCell = isTextWide(text) let checkRadius = isWideCell ? 2 : 1 @@ -559,7 +578,7 @@ module.exports = class CanvasRenderer { // 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]))) { + if (updateMap.get(adjacentCell) && (this.graphics < 2 || isWideCell || isTextWide(this.screen[adjacentCell]))) { adjacentDidUpdate = true break } @@ -574,9 +593,10 @@ module.exports = class CanvasRenderer { 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 - let padding = Math.round(this.screen._padding) + if (this.graphics >= 1) { + // TODO: include padding in border cells + const padding = this.padding + ctx.save() ctx.beginPath() for (let y = 0; y < height; y++) { @@ -587,13 +607,13 @@ module.exports = class CanvasRenderer { if (redrawing && regionStart === null) regionStart = x if (!redrawing && regionStart !== null) { ctx.rect(padding + regionStart * cellWidth, padding + y * cellHeight, (x - regionStart) * cellWidth, cellHeight) - if (debug) this.screen._debug.clipRect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight) + // if (this.debug) this.screen._debug.clipRect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight) regionStart = null } } if (regionStart !== null) { ctx.rect(padding + regionStart * cellWidth, padding + y * cellHeight, (width - regionStart) * cellWidth, cellHeight) - if (debug) this.screen._debug.clipRect(regionStart * cellWidth, y * cellHeight, (width - regionStart) * cellWidth, cellHeight) + // if (this.debug) this.screen._debug.clipRect(regionStart * cellWidth, y * cellHeight, (width - regionStart) * cellWidth, cellHeight) } } ctx.clip() @@ -607,12 +627,12 @@ module.exports = class CanvasRenderer { if (redrawMap.get(cell)) { this.drawBackground({ x, y, cellWidth, cellHeight, bg, isDefaultBG }) - if (this.screen.window.debug && this.screen._debug) { + if (this.debug) { // set cell flags let flags = (+redrawMap.get(cell)) flags |= (+updateMap.get(cell)) << 1 flags |= (+isTextWide(text)) << 2 - this.screen._debug.setCell(cell, flags) + // this.screen._debug.setCell(cell, flags) } } } @@ -625,10 +645,10 @@ module.exports = class CanvasRenderer { for (let font of fontGroups.keys()) { // set font once because in Firefox, this is a really slow action for some // reason - let modifiers = {} - if (font & ATTR_BOLD) modifiers.weight = 'bold' - if (font & ATTR_ITALIC) modifiers.style = 'italic' - ctx.font = this.screen.getFont(modifiers) + let fontIndex = 0 + if (font & ATTR_BOLD) fontIndex |= 1 + if (font & ATTR_ITALIC) fontIndex |= 2 + ctx.font = this.fonts[fontIndex] for (let data of fontGroups.get(font)) { let { cell, x, y, text, fg, bg, attrs, isCursor, inSelection } = data @@ -643,7 +663,7 @@ module.exports = class CanvasRenderer { this.drawnScreenBG[cell] = bg this.drawnScreenAttrs[cell] = attrs - if (isCursor) this.drawnCursor = [x, y, this.screen.cursor.style, this.screen.cursor.hanging] + if (isCursor) this.drawnCursor = [x, y, this.cursor.style, this.cursor.hanging] // draw cursor if (isCursor && !inSelection) { @@ -653,21 +673,21 @@ module.exports = class CanvasRenderer { let cursorX = x let cursorY = y - if (this.screen.cursor.hanging) { + if (this.cursor.hanging) { // draw hanging cursor in the margin cursorX += 1 } - let screenX = cursorX * cellWidth + this.screen._padding - let screenY = cursorY * cellHeight + this.screen._padding - if (this.screen.cursor.style === 'block') { + let screenX = cursorX * cellWidth + this.padding + let screenY = cursorY * cellHeight + this.padding + if (this.cursor.style === 'block') { // block ctx.rect(screenX, screenY, cellWidth, cellHeight) - } else if (this.screen.cursor.style === 'bar') { + } else if (this.cursor.style === 'bar') { // vertical bar let barWidth = 2 ctx.rect(screenX, screenY, barWidth, cellHeight) - } else if (this.screen.cursor.style === 'line') { + } else if (this.cursor.style === 'line') { // underline let lineHeight = 2 ctx.rect(screenX, screenY + charSize.height, cellWidth, lineHeight) @@ -690,34 +710,28 @@ module.exports = class CanvasRenderer { } } - if (this.screen.window.graphics >= 1) ctx.restore() + if (this.graphics >= 1) ctx.restore() - if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawEnd() + // if (this.debug) this.screen._debug.drawEnd() - this.screen.emit('draw', why) + this.emit('draw', why) } drawStatus (statusScreen) { - const ctx = this.ctx - const { - fontFamily, - width, - height, - devicePixelRatio - } = this.screen.window + const { ctx, width, height, devicePixelRatio } = this // reset drawnScreen to force redraw when statusScreen is disabled this.drawnScreen = [] - const cellSize = this.screen.getCellSize() - const screenWidth = width * cellSize.width + 2 * this.screen._padding - const screenHeight = height * cellSize.height + 2 * this.screen._padding + const cellSize = this.cellSize + const screenWidth = width * cellSize.width + 2 * this.padding + const screenHeight = height * cellSize.height + 2 * this.padding ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0) ctx.fillStyle = this.getColor(this.defaultBgNum) ctx.fillRect(0, 0, screenWidth, screenHeight) - ctx.font = `24px ${fontFamily}` + ctx.font = `24px ${this.statusFont}` ctx.fillStyle = this.getColor(this.defaultFgNum) ctx.textAlign = 'center' ctx.textBaseline = 'middle'