const EventEmitter = require('events') const CanvasRenderer = require('./screen_renderer') const DEFAULT_FONT = '"DejaVu Sans Mono", "Liberation Mono", "Inconsolata", "Menlo", monospace' /** * Manages terminal screen layout and sizing */ module.exports = class ScreenLayout extends EventEmitter { constructor () { super() this.canvas = document.createElement('canvas') this.renderer = new CanvasRenderer(this.canvas) this._window = { width: 0, height: 0, devicePixelRatio: 1, fontFamily: DEFAULT_FONT, fontSize: 20, padding: 6, gridScaleX: 1.0, gridScaleY: 1.2, fitIntoWidth: 0, fitIntoHeight: 0, debug: false } // scaling caused by fitIntoWidth/fitIntoHeight this._windowScale = 1 // actual padding, as it may be disabled by fullscreen mode etc. this._padding = 0 // properties of this.window that require updating size and redrawing this.windowState = { width: 0, height: 0, devicePixelRatio: 0, padding: 0, gridScaleX: 0, gridScaleY: 0, fontFamily: '', fontSize: 0, fitIntoWidth: 0, fitIntoHeight: 0 } this.charSize = { width: 0, height: 0 } const self = this // 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.scheduleSizeUpdate() self.renderer.scheduleDraw(`window:${key}=${value}`) self.emit(`update-window:${key}`, value) } return true } }) 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)) this.canvas.addEventListener('touchstart', e => this.emit('touchstart', e)) this.canvas.addEventListener('touchmove', e => this.emit('touchmove', e)) this.canvas.addEventListener('touchend', e => this.emit('touchend', e)) this.canvas.addEventListener('wheel', e => this.emit('wheel', e)) this.canvas.addEventListener('contextmenu', e => this.emit('contextmenu', e)) } /** * Schedule a size update in the next millisecond */ scheduleSizeUpdate () { clearTimeout(this._scheduledSizeUpdate) this._scheduledSizeUpdate = setTimeout(() => this.updateSize(), 1) } get backgroundImage () { return this.canvas.style.backgroundImage } set backgroundImage (value) { this.canvas.style.backgroundImage = value ? `url(${value})` : '' if (this.renderer.backgroundImage !== !!value) { this.renderer.backgroundImage = !!value this.renderer.resetDrawn() this.renderer.scheduleDraw('background-image') } } get selectable () { return this.canvas.classList.contains('selectable') } set selectable (selectable) { if (selectable) this.canvas.classList.add('selectable') else this.canvas.classList.remove('selectable') } /** * Returns a CSS font string with the current font settings and the * specified 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' let fontFamily = this.window.fontFamily || '' if (fontFamily.length > 0) fontFamily += ',' fontFamily += DEFAULT_FONT return `${fontStyle} normal ${fontWeight} ${this.window.fontSize}px ${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() x = x / this._windowScale - this._padding y = y / this._windowScale - this._padding x = Math.floor((x + (rounded ? cellSize.width / 2 : 0)) / cellSize.width) y = Math.floor(y / cellSize.height) x = Math.max(0, Math.min(this.window.width - 1, x)) y = Math.max(0, Math.min(this.window.height - 1, y)) return [x, y] } /** * 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 => this._padding + (withScale ? v * this._windowScale : v)) } /** * Update 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 */ updateCharSize () { this.charSize = { width: this.renderer.getCharWidthFor(this.getFont()), height: this.window.fontSize } return this.charSize } /** * 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 () { if (!this.charSize.height && this.window.fontSize) this.updateCharSize() return { width: Math.ceil(this.charSize.width * this.window.gridScaleX), height: Math.ceil(this.charSize.height * this.window.gridScaleY) } } /** * Updates the canvas size if it changed */ updateSize () { // see below (this is just updating it) this._window.devicePixelRatio = Math.ceil(this._windowScale * (window.devicePixelRatio || 1)) 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, padding } = this.window this.updateCharSize() const cellSize = this.getCellSize() // real height of the canvas element in pixels let realWidth = width * cellSize.width let realHeight = height * cellSize.height let originalWidth = realWidth if (fitIntoWidth && fitIntoHeight) { let terminalAspect = realWidth / realHeight let fitAspect = fitIntoWidth / fitIntoHeight if (terminalAspect < fitAspect) { // align heights realHeight = fitIntoHeight - 2 * padding realWidth = realHeight * terminalAspect } else { // align widths realWidth = fitIntoWidth - 2 * padding realHeight = realWidth / terminalAspect } } // store new window scale this._windowScale = realWidth / originalWidth realWidth += 2 * padding realHeight += 2 * padding // store padding this._padding = padding * (originalWidth / realWidth) // the DPR must be rounded to a very nice value to prevent gaps between cells let devicePixelRatio = this._window.devicePixelRatio = Math.ceil(this._windowScale * (window.devicePixelRatio || 1)) this.canvas.width = (width * cellSize.width + 2 * Math.round(this._padding)) * devicePixelRatio this.canvas.style.width = `${realWidth}px` this.canvas.height = (height * cellSize.height + 2 * Math.round(this._padding)) * devicePixelRatio this.canvas.style.height = `${realHeight}px` // the screen has been cleared (by changing canvas width) this.renderer.resetDrawn() this.renderer.render('update-size', this.serializeRenderData()) this.emit('size-update') } } serializeRenderData () { return { padding: Math.round(this._padding), devicePixelRatio: this.window.devicePixelRatio, charSize: this.charSize, cellSize: this.getCellSize(), fonts: [ this.getFont(), this.getFont({ weight: 'bold' }), this.getFont({ style: 'italic' }), this.getFont({ weight: 'bold', style: 'italic' }) ] } } render (reason, data) { this.window.width = data.width this.window.height = data.height Object.assign(data, this.serializeRenderData()) this.renderer.render(reason, data) } }