parent
a460b5fe89
commit
3ef4f0712b
@ -0,0 +1,254 @@ |
||||
const EventEmitter = require('events') |
||||
const CanvasRenderer = require('./screen_renderer') |
||||
|
||||
/** |
||||
* 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.getContext('2d')) |
||||
|
||||
this._window = { |
||||
width: 0, |
||||
height: 0, |
||||
devicePixelRatio: 1, |
||||
fontFamily: '"DejaVu Sans Mono", "Liberation Mono", "Inconsolata", "Menlo", monospace', |
||||
fontSize: 20, |
||||
padding: 6, |
||||
gridScaleX: 1.0, |
||||
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 |
||||
} |
||||
|
||||
// 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, receiver) { |
||||
if (target[key] !== value) { |
||||
target[key] = value |
||||
self.scheduleSizeUpdate() |
||||
self.renderer.scheduleDraw(`window:${key}=${value}`) |
||||
self.emit(`update-window:${key}`, value) |
||||
} |
||||
return true |
||||
} |
||||
}) |
||||
|
||||
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' |
||||
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() |
||||
|
||||
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.ctx.font = this.getFont() |
||||
|
||||
this.charSize = { |
||||
width: Math.floor(this.ctx.measureText(' ').width), |
||||
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 () { |
||||
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 |
||||
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` |
||||
|
||||
this.updateCharSize() |
||||
|
||||
// the screen has been cleared (by changing canvas width)
|
||||
this.renderer.resetDrawn() |
||||
|
||||
// draw immediately; the canvas shouldn't flash
|
||||
this.renderer.draw('update-size') |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue