├── .npmignore ├── .gitignore ├── index.js ├── elements.js ├── .babelrc ├── sources ├── elements │ ├── index.js │ ├── Text.js │ └── Input.js └── core │ ├── constants.js │ ├── index.js │ ├── boxes │ ├── TerminalBox.js │ ├── ScrollBox.js │ ├── WorldBox.js │ ├── ContentBox.js │ ├── ClipBox.js │ ├── Box.js │ └── ElementBox.js │ ├── borders.js │ ├── Point.js │ ├── Event.js │ ├── utilities │ └── KeySequence.js │ ├── Rect.js │ ├── TermString.js │ ├── Block.js │ ├── Screen.js │ └── Element.js ├── tests ├── extra │ ├── draws.js │ ├── streams.js │ └── trees.js ├── Screen.js └── ElementBox.js ├── package.json ├── examples └── bouncing-ball.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /build 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./build/core'); 2 | -------------------------------------------------------------------------------- /elements.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./build/elements'); 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "es2015", "es2016", "es2017" ] 3 | } -------------------------------------------------------------------------------- /sources/elements/index.js: -------------------------------------------------------------------------------- 1 | export { Input } from './Input'; 2 | export { Text } from './Text'; 3 | -------------------------------------------------------------------------------- /tests/extra/draws.js: -------------------------------------------------------------------------------- 1 | export function getDirtyRects(screen, actionCallback) { 2 | 3 | screen._redraw(); 4 | actionCallback(); 5 | 6 | let dirtyRects = screen._pending.slice(); 7 | screen._redraw(); 8 | 9 | return dirtyRects; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /sources/core/constants.js: -------------------------------------------------------------------------------- 1 | export var numberRegex = new RegExp( '([0-9]+\\.[0-9]*|[0-9]*\\.[0-9]+|[0-9]+)', '' ); 2 | export var numberRegexF = new RegExp( '^' + numberRegex.source + '$', '' ); 3 | 4 | export var percentageRegex = new RegExp( numberRegex.source + '%', '' ); 5 | export var percentageRegexF = new RegExp( '^' + percentageRegex.source + '$', '' ); 6 | -------------------------------------------------------------------------------- /sources/core/index.js: -------------------------------------------------------------------------------- 1 | export { Block } from './Block'; 2 | export { Element } from './Element'; 3 | export { Event } from './Event'; 4 | export { Point } from './Point'; 5 | export { Rect } from './Rect'; 6 | export { Screen } from './Screen'; 7 | export { TermString } from './TermString'; 8 | 9 | export * from './borders'; 10 | export * from './constants'; 11 | -------------------------------------------------------------------------------- /tests/extra/streams.js: -------------------------------------------------------------------------------- 1 | import Stream from 'stream'; 2 | 3 | export function createDummyInputStream() { 4 | 5 | let stream = new Stream(); 6 | stream.setRawMode = () => {}; 7 | 8 | return stream; 9 | 10 | } 11 | 12 | export function createDummyOutputStream() { 13 | 14 | let stream = new Stream(); 15 | 16 | stream.write = () => {}; 17 | stream.columns = 100; 18 | stream.rows = 100; 19 | 20 | return stream; 21 | 22 | } 23 | -------------------------------------------------------------------------------- /sources/core/boxes/TerminalBox.js: -------------------------------------------------------------------------------- 1 | import { Box } from '../boxes/Box'; 2 | 3 | export class TerminalBox extends Box { 4 | 5 | constructor( ... args ) { 6 | 7 | super( ... args ); 8 | 9 | this._rect.left = this._rect.right = 0; 10 | this._rect.top = this._rect.bottom = 0; 11 | 12 | } 13 | 14 | refreshSize( axis ) { 15 | 16 | this._rect.width = this._element._out.columns; 17 | this._rect.height = this._element._out.rows; 18 | 19 | } 20 | 21 | refreshPosition( axis ) { 22 | 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /sources/core/borders.js: -------------------------------------------------------------------------------- 1 | import extend from 'extend'; 2 | 3 | export let borders = { 4 | 5 | simple: style => extend( true, { 6 | 7 | topLeft : '┌', 8 | topRight : '┐', 9 | bottomLeft : '└', 10 | bottomRight : '┘', 11 | vertical : '│', 12 | horizontal : '─' 13 | 14 | }, style ), 15 | 16 | strong: style => extend( true, { 17 | 18 | topLeft : '╔', 19 | topRight : '╗', 20 | bottomLeft : '╚', 21 | bottomRight : '╝', 22 | vertical : '║', 23 | horizontal : '═' 24 | 25 | }, style ) 26 | 27 | }; 28 | -------------------------------------------------------------------------------- /sources/core/Point.js: -------------------------------------------------------------------------------- 1 | export class Point { 2 | 3 | static fromJS({ x, y } = {}) { 4 | 5 | let point = new this(); 6 | 7 | if (!isNil(x)) 8 | point.x = x; 9 | 10 | if (!isNil(y)) 11 | point.y = y; 12 | 13 | return point; 14 | 15 | } 16 | 17 | constructor(other) { 18 | 19 | if (other instanceof Point) { 20 | 21 | this.copySelf(other); 22 | 23 | } else { 24 | 25 | this.x = this.y = 0; 26 | 27 | } 28 | 29 | } 30 | 31 | copySelf(other) { 32 | 33 | this.x = other.x; 34 | this.y = other.y; 35 | 36 | return this; 37 | 38 | } 39 | 40 | toString() { 41 | 42 | return ``; 43 | 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /sources/core/boxes/ScrollBox.js: -------------------------------------------------------------------------------- 1 | import { Box } from '../boxes/Box'; 2 | 3 | export class ScrollBox extends Box { 4 | 5 | refreshSize( axis ) { 6 | 7 | var contextBoxRect = this._context[ axis.get ]( ); 8 | 9 | this._rect[ axis.size ] = contextBoxRect[ axis.size ]; 10 | 11 | } 12 | 13 | refreshPosition( axis ) { 14 | 15 | var scroll = this._element.activeStyle.flags.staticPositioning || 16 | this._element.activeStyle.position === 'absolute' ? 17 | this._element.parentNode[ axis.scrollPosition ] : 0; 18 | 19 | var contextBoxRect = this._context[ axis.get ]( ); 20 | 21 | this._rect[ axis.a ] = contextBoxRect[ axis.a ] - scroll; 22 | this._rect[ axis.b ] = contextBoxRect[ axis.b ] + scroll; 23 | 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /sources/core/boxes/WorldBox.js: -------------------------------------------------------------------------------- 1 | import { Box } from '../boxes/Box'; 2 | 3 | export class WorldBox extends Box { 4 | 5 | refreshSize( axis ) { 6 | 7 | this._rect[ axis.size ] = this._context[ axis.getSize ]( ); 8 | 9 | } 10 | 11 | refreshPosition( axis ) { 12 | 13 | var contextBoxRect = this._context[ axis.get ]( ); 14 | 15 | this._rect[ axis.a ] = contextBoxRect[ axis.a ]; 16 | this._rect[ axis.b ] = contextBoxRect[ axis.b ]; 17 | 18 | if ( this._element.parentNode ) { 19 | 20 | var parentWorldBoxRect = this._element.parentNode.worldContentBox[ axis.get ]( ); 21 | 22 | this._rect[ axis.a ] += parentWorldBoxRect[ axis.a ]; 23 | this._rect[ axis.b ] += parentWorldBoxRect[ axis.b ]; 24 | 25 | } 26 | 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /sources/core/boxes/ContentBox.js: -------------------------------------------------------------------------------- 1 | import { Box } from '../boxes/Box'; 2 | 3 | export class ContentBox extends Box { 4 | 5 | refreshSize(axis) { 6 | 7 | this._rect[axis.size] = this._context[axis.getSize](); 8 | 9 | if (this._element.activeStyle.border) { 10 | 11 | this._rect[axis.size] -= 2; 12 | 13 | if (this._rect[axis.size] < 0) { 14 | this._rect[axis.size] = 0; 15 | } 16 | 17 | } 18 | 19 | } 20 | 21 | refreshPosition(axis) { 22 | 23 | let contextBoxRect = this._context[axis.get](); 24 | 25 | this._rect[axis.a] = contextBoxRect[axis.a]; 26 | this._rect[axis.b] = contextBoxRect[axis.b]; 27 | 28 | if (this._element.activeStyle.border) { 29 | this._rect[axis.a] += 1; 30 | this._rect[axis.b] += 1; 31 | } 32 | 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ohui", 3 | "version": "0.0.7", 4 | "license": "MIT", 5 | "scripts": { 6 | "demo": "babel-node -- examples/bouncing-ball", 7 | "test": "mocha --compilers js:babel-register -r core-js tests/*.js", 8 | "build": "babel -d build/ sources/", 9 | "prepublish": "npm run build" 10 | }, 11 | "dependencies": { 12 | "@manaflair/term-strings": "^0.0.1", 13 | "extend": "^1.3.0", 14 | "keypress": "^0.2.1", 15 | "lodash": "^4.16.2", 16 | "regexp-quote": "^0.0.0" 17 | }, 18 | "devDependencies": { 19 | "babel-cli": "^6.16.0", 20 | "babel-core": "^6.16.0", 21 | "babel-preset-es2015": "^6.16.0", 22 | "babel-preset-es2016": "^6.16.0", 23 | "babel-preset-es2017": "^6.16.0", 24 | "babel-register": "^6.16.3", 25 | "chai": "^3.5.0", 26 | "core-js": "^2.4.1", 27 | "mocha": "^3.1.0", 28 | "xterm": "^1.1.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/bouncing-ball.js: -------------------------------------------------------------------------------- 1 | import { Screen, Block, borders } from '../sources/core'; 2 | import { Text } from '../sources/elements'; 3 | 4 | let screen = new Screen(); 5 | 6 | let main = new Block({ position: `absolute`, left: 0, top: 0, right: 0, bottom: 0, border: borders.strong() }); 7 | screen.appendChild(main); 8 | 9 | let ball = new Block({ position: `absolute`, left: 0, top: 0, width: 6, height: 4, border: borders.simple(), backgroundCharacter: `#` }); 10 | main.appendChild(ball); 11 | 12 | (function run(dx, dy) { 13 | 14 | let left = ball.activeStyle.left + dx; 15 | let top = ball.activeStyle.top + dy; 16 | 17 | ball.setStyleProperties({ left, top }); 18 | 19 | let elementRect = ball.elementBox.get(); 20 | 21 | if (elementRect.left <= 0) 22 | dx = +1; 23 | else if (elementRect.right <= 0) 24 | dx = -1; 25 | 26 | if (elementRect.top <= 0) 27 | dy = +1; 28 | else if (elementRect.bottom <= 0) 29 | dy = -1; 30 | 31 | setTimeout(() => run(dx, dy), 1000 / 60); 32 | 33 | }(1, 1)) 34 | -------------------------------------------------------------------------------- /sources/core/Event.js: -------------------------------------------------------------------------------- 1 | import { isFunction } from 'lodash'; 2 | 3 | export class Event { 4 | 5 | constructor(name, properties = {}) { 6 | 7 | this.name = name; 8 | this.cancelable = true; 9 | 10 | for (let propertyName of Object.keys(properties)) 11 | this[propertyName] = properties[propertyName]; 12 | 13 | let defaultAction = null; 14 | let isDefaultPrevented = false; 15 | let isDefaultCancelable = this.cancelable; 16 | 17 | this.isDefaultPrevented = () => { 18 | 19 | return isDefaultPrevented; 20 | 21 | }; 22 | 23 | this.preventDefault = () => { 24 | 25 | if (!isDefaultCancelable) 26 | return; 27 | 28 | isDefaultPrevented = true; 29 | 30 | }; 31 | 32 | this.setDefault = action => { 33 | 34 | if (!isFunction(action)) 35 | throw new Error(`Invalid default`); 36 | 37 | defaultAction = action; 38 | 39 | }; 40 | 41 | this.resolveDefault = () => { 42 | 43 | if (isDefaultPrevented || !defaultAction) 44 | return; 45 | 46 | defaultAction(this); 47 | 48 | }; 49 | 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /tests/extra/trees.js: -------------------------------------------------------------------------------- 1 | import { isArray, isFunction, isNull, isPlainObject } from 'lodash'; 2 | 3 | import { Block, Screen } from '../../sources/core'; 4 | 5 | import { createDummyInputStream, createDummyOutputStream } from './streams'; 6 | 7 | export function dummyScreen() { 8 | 9 | return new Screen({ 10 | 11 | stdin: createDummyInputStream(), 12 | stdout: createDummyOutputStream(), 13 | 14 | resetOnExit: false 15 | 16 | }); 17 | 18 | } 19 | 20 | export function createTree(children, { map = {}, parent = null } = {}) { 21 | 22 | for (let [ name, ... args ] of children) { 23 | 24 | let constructor = Block; 25 | let style = {}; 26 | let children = []; 27 | 28 | for (let arg of args) { 29 | 30 | if (isFunction(arg)) { 31 | constructor = arg; 32 | } else if (isPlainObject(arg)) { 33 | style = arg; 34 | } else if (isArray(arg)) { 35 | children = arg; 36 | } 37 | 38 | } 39 | 40 | let node = map[name] = new constructor(style); 41 | node.name = name; 42 | 43 | if (!isNull(parent)) 44 | parent.appendChild(node); 45 | 46 | createTree(children, { map, parent: node }); 47 | 48 | } 49 | 50 | return map; 51 | 52 | } 53 | -------------------------------------------------------------------------------- /tests/Screen.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { Rect, ansiColors } from '../sources/core'; 4 | 5 | import { getDirtyRects } from './extra/draws'; 6 | import { createTree, dummyScreen } from './extra/trees'; 7 | 8 | describe(`Screen`, () => { 9 | 10 | it(`should only redraw the part of the screen that have changed (changing the background color)`, () => { 11 | 12 | var tree = createTree([ 13 | [ `screen`, dummyScreen, [ 14 | [ `main`, { position: `absolute`, left: 10, top: 20, width: 30, height: 40 } ] 15 | ] ] 16 | ]); 17 | 18 | var dirtyRects = getDirtyRects(tree.screen, () => { 19 | tree.main.setStyleProperty(`color.bg`, ansiColors.RED); 20 | }); 21 | 22 | expect(dirtyRects).to.deep.equal([ Rect.fromJS({ left: 10, top: 20, right: 60, bottom: 40, width: 30, height: 40 }) ]); 23 | 24 | }); 25 | 26 | it(`should only redraw the part of the screen that have changed (changing the position)`, () => { 27 | 28 | var tree = createTree([ 29 | [ `screen`, dummyScreen, [ 30 | [ `main`, { position: `absolute`, left: 10, top: 10, width: 10, height: 10 } ] 31 | ] ] 32 | ]); 33 | 34 | var dirtyRects = getDirtyRects(tree.screen, () => { 35 | tree.main.setStyleProperties({ left: 80, top: 80 }); 36 | }); 37 | 38 | expect(dirtyRects).to.deep.equal([ Rect.fromJS({ left: 10, top: 10, right: 80, bottom: 80, width: 10, height: 10 }), Rect.fromJS({ left: 80, top: 80, right: 10, bottom: 10, width: 10, height: 10 }) ]); 39 | 40 | }); 41 | 42 | }); 43 | -------------------------------------------------------------------------------- /sources/core/boxes/ClipBox.js: -------------------------------------------------------------------------------- 1 | import { Box } from '../boxes/Box'; 2 | 3 | export class ClipBox extends Box { 4 | 5 | refreshSize( axis ) { 6 | 7 | var contextBoxRect = this._context[ axis.get ]( ); 8 | var parentClipBoxRect = this._element.parentNode.clipContentBox[ axis.get ]( ); 9 | 10 | var doesIntersect = 11 | contextBoxRect[ axis.a ] < parentClipBoxRect[ axis.a ] + parentClipBoxRect[ axis.size ] && 12 | contextBoxRect[ axis.a ] + contextBoxRect[ axis.size ] > parentClipBoxRect[ axis.a ] && 13 | contextBoxRect[ axis.size ] > 0 && parentClipBoxRect[ axis.size ] > 0; 14 | 15 | if ( ! doesIntersect ) { 16 | 17 | this._rect[ axis.size ] = NaN; 18 | 19 | } else { 20 | 21 | var a = Math.max( contextBoxRect[ axis.a ], parentClipBoxRect[ axis.a ] ); 22 | 23 | this._rect[ axis.size ] = Math.min( contextBoxRect[ axis.a ] + contextBoxRect[ axis.size ], parentClipBoxRect[ axis.a ] + parentClipBoxRect[ axis.size ] ) - a; 24 | 25 | } 26 | 27 | } 28 | 29 | refreshPosition( axis ) { 30 | 31 | var contextBoxRect = this._context[ axis.get ]( ); 32 | var parentClipBoxRect = this._element.parentNode.clipContentBox[ axis.get ]( ); 33 | 34 | var doesIntersect = 35 | contextBoxRect[ axis.a ] < parentClipBoxRect[ axis.a ] + parentClipBoxRect[ axis.size ] && 36 | contextBoxRect[ axis.a ] + contextBoxRect[ axis.size ] > parentClipBoxRect[ axis.a ] && 37 | contextBoxRect[ axis.size ] > 0 && parentClipBoxRect[ axis.size ] > 0; 38 | 39 | if ( ! doesIntersect ) { 40 | 41 | this._rect[ axis.a ] = NaN; 42 | this._rect[ axis.b ] = NaN; 43 | 44 | } else { 45 | 46 | this._rect[ axis.a ] = Math.max( contextBoxRect[ axis.a ], parentClipBoxRect[ axis.a ] ); 47 | this._rect[ axis.b ] = Math.max( contextBoxRect[ axis.b ], parentClipBoxRect[ axis.b ] ); 48 | 49 | } 50 | 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /sources/core/utilities/KeySequence.js: -------------------------------------------------------------------------------- 1 | class Key { 2 | 3 | static parse(string) { 4 | 5 | let key = new Key(); 6 | 7 | for (let part of string.split(/[+-]/g)) { 8 | 9 | switch (part) { 10 | 11 | case `C`: { 12 | key.control = true; 13 | } break; 14 | 15 | case `M`: { 16 | key.meta = true; 17 | } break; 18 | 19 | case `S`: { 20 | key.shift = true; 21 | } break; 22 | 23 | default: { 24 | key.key = part; 25 | } break; 26 | 27 | } 28 | 29 | } 30 | 31 | return key; 32 | 33 | } 34 | 35 | constructor() { 36 | 37 | this.shift = false; 38 | this.control = false; 39 | this.meta = false; 40 | 41 | this.key = null; 42 | 43 | } 44 | 45 | match(event) { 46 | 47 | if (this.control !== event.control) 48 | return false; 49 | 50 | if (this.shift !== event.shift) 51 | return false; 52 | 53 | if (this.meta !== event.meta) 54 | return false; 55 | 56 | if (this.key !== event.key) 57 | return false; 58 | 59 | return true; 60 | 61 | } 62 | 63 | }; 64 | 65 | export class KeySequence { 66 | 67 | constructor(sequence) { 68 | 69 | this._buffer = []; 70 | 71 | this._sequence = String(sequence).split(/\s+/g) 72 | .map(key => Key.parse(key)); 73 | 74 | } 75 | 76 | match(event) { 77 | 78 | this._buffer.push(event); 79 | 80 | if (this._buffer.length > this._sequence.length) 81 | this._buffer.splice(0, this._buffer.length - this._sequence.length); 82 | 83 | if (this._buffer.length < this._sequence.length) 84 | return false; 85 | 86 | for (let t = 0, T = this._sequence.length; t < T; ++t) 87 | if (!this._sequence[t].match(this._buffer[t])) 88 | return false; 89 | 90 | return true; 91 | 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /sources/elements/Text.js: -------------------------------------------------------------------------------- 1 | import { Element } from '../core'; 2 | 3 | export class Text extends Element { 4 | 5 | constructor(style) { 6 | 7 | super(style); 8 | 9 | this._segmentSize = 0; 10 | this._originalContent = ``; 11 | this._parsedContent = []; 12 | 13 | Object.defineProperty(this, `innerText`, { 14 | get: () => this._originalContent, 15 | set: this.setContent.bind(this) 16 | }); 17 | 18 | Object.defineProperty(this, `scrollHeight`, { 19 | get: () => this._getParsedContent().length 20 | }); 21 | 22 | } 23 | 24 | setContent(content) { 25 | 26 | return this.applyElementBoxInvalidatingActions(false, true, () => { 27 | 28 | this._originalContent = content; 29 | this._parsedContent = null; 30 | 31 | }); 32 | 33 | } 34 | 35 | renderLine(x, y, l) { 36 | 37 | var line = this._getParsedContent()[y] || ``; 38 | 39 | if (this.activeStyle.textAlign === `center`) { 40 | 41 | var pad = Math.floor((this._segmentSize - line.length) / 2); 42 | var prefix = new Array(pad + 1).join(this.activeStyle.ch || ` `); 43 | 44 | return prefix + line.substr(x, l - pad); 45 | 46 | } else { 47 | 48 | return line.substr(x, l); 49 | 50 | } 51 | 52 | } 53 | 54 | _getParsedContent() { 55 | 56 | if (this._parsedContent) 57 | return this._parsedContent; 58 | 59 | var parsedContent = this._parsedContent = []; 60 | var segmentSize = this._segmentSize = this.contentBox.get(true, false).width || Infinity; 61 | 62 | var lines = this._originalContent.replace(/\t/g, ` `).split(/(?:\r\n|\r|\n)/g); 63 | 64 | lines.forEach(line => { 65 | 66 | var segmentCount = Math.ceil(line.length / segmentSize); 67 | 68 | for (var t = 0; t < segmentCount; ++ t) { 69 | var segment = line.substr(t * segmentSize, segmentSize); 70 | parsedContent.push(segment); 71 | } 72 | 73 | }); 74 | 75 | return parsedContent; 76 | 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /sources/elements/Input.js: -------------------------------------------------------------------------------- 1 | import extend from 'extend'; 2 | 3 | import { Event } from '../core'; 4 | import { ansiColors } from '../core'; 5 | 6 | import { Text } from './Text'; 7 | 8 | export class Input extends Text { 9 | 10 | constructor(style, { monoline } = { }) { 11 | 12 | super(extend(true, { 13 | 14 | minHeight: 1, 15 | 16 | focusable: true, 17 | ch: `.`, 18 | 19 | active: { 20 | color: { 21 | bg: ansiColors.BLUE 22 | } 23 | } 24 | 25 | }, style)); 26 | 27 | this._caretOffset = 0; 28 | this._innerValue = ``; 29 | 30 | this.declareEvent(`input`); 31 | 32 | Object.defineProperty(this, `value`, { 33 | 34 | get: () => { 35 | 36 | return this._innerValue; 37 | 38 | }, 39 | 40 | set: (newValue) => { 41 | 42 | if (monoline) 43 | newValue = newValue.replace(/(\r\n|\r|\n)/g, ``); 44 | 45 | this._innerValue = newValue; 46 | this._caretOffset = this._innerValue.length; 47 | 48 | this.innerText = this._innerValue; 49 | 50 | } 51 | 52 | }); 53 | 54 | this.addEventListener(`data`, e => { 55 | 56 | if (!e.data) 57 | return ; 58 | 59 | e.setDefault(() => { 60 | 61 | var data = e.data; 62 | 63 | if (monoline) 64 | data = data.replace(/(\r\n|\r|\n)/g, ``); 65 | 66 | if (data.length === 0) 67 | return ; 68 | 69 | if (this._caretOffset !== this._innerValue.length) { 70 | this._innerValue = this._innerValue.substr(0, this._caretOffset) + data + this._innerValue.substr(this._caretOffset); 71 | } else { 72 | this._innerValue += data; 73 | } 74 | 75 | this._caretOffset += data.length; 76 | this.innerText = this._innerValue; 77 | 78 | var event = new Event(`input`, { target: this }); 79 | this.dispatchEvent(event); 80 | 81 | }); 82 | 83 | }); 84 | 85 | this.addShortcutListener(`backspace`, e => { 86 | 87 | e.setDefault(() => { 88 | 89 | e.preventDefault(); 90 | 91 | if (this._caretOffset === 0) 92 | return ; 93 | 94 | if (this._caretOffset !== this._innerValue.length) { 95 | this._innerValue = this._innerValue.substr(0, this._caretOffset - 1) + this._innerValue.substr(this._caretOffset); 96 | } else { 97 | this._innerValue = this._innerValue.substr(0, this._caretOffset - 1); 98 | } 99 | 100 | this._caretOffset -= 1; 101 | this.innerText = this._innerValue; 102 | 103 | var event = new Event(`input`, { target: this }); 104 | this.dispatchEvent(event); 105 | 106 | }); 107 | 108 | }); 109 | 110 | } 111 | 112 | }; 113 | -------------------------------------------------------------------------------- /sources/core/boxes/Box.js: -------------------------------------------------------------------------------- 1 | import { isNull } from 'lodash'; 2 | 3 | import { Rect } from '../Rect'; 4 | 5 | var axisSet = { 6 | x: { get: 'getX', getSize: 'getWidth', a: 'left', b: 'right', size: 'width', minSize: 'minWidth', maxSize: 'maxWidth', scrollSize: 'scrollWidth', scrollPosition: 'scrollLeft', adaptiveFlag: 'hasAdaptativeWidth' }, 7 | y: { get: 'getY', getSize: 'getHeight', a: 'top', b: 'bottom', size: 'height', minSize: 'minHeight', maxSize: 'maxHeight', scrollSize: 'scrollHeight', scrollPosition: 'scrollTop', adaptiveFlag: 'hasAdaptativeHeight' } 8 | }; 9 | 10 | export class Box { 11 | 12 | constructor(context) { 13 | 14 | this._dirtyX = true; 15 | this._dirtyY = true; 16 | 17 | this._context = context; 18 | 19 | this._rect = new Rect(); 20 | this._stub = null; 21 | 22 | this._invalidateList = []; 23 | 24 | if (this._context instanceof Box) { 25 | 26 | this._context._invalidateList.push(this); 27 | this._element = this._context._element; 28 | 29 | } else { 30 | 31 | this._element = this._context; 32 | 33 | } 34 | 35 | } 36 | 37 | setStub(stub) { 38 | 39 | if (!isNull(stub) && !(stub instanceof Rect)) 40 | throw new Error(`Invalid stub`); 41 | 42 | this._stub = stub; 43 | this.invalidate(); 44 | 45 | } 46 | 47 | invalidate(invalidateX = true, invalidateY = true) { 48 | 49 | if (!invalidateX && !invalidateY) 50 | return this; 51 | 52 | if (invalidateX) 53 | this._dirtyX = true; 54 | 55 | if (invalidateY) 56 | this._dirtyY = true; 57 | 58 | this._invalidateList.forEach(box => { 59 | box.invalidate(invalidateX, invalidateY); 60 | }); 61 | 62 | return this; 63 | 64 | } 65 | 66 | get(refreshX = true, refreshY = true) { 67 | 68 | if (!isNull(this._stub)) 69 | return this._stub; 70 | 71 | if (refreshX) 72 | this.getX(); 73 | 74 | if (refreshY) 75 | this.getY(); 76 | 77 | return this._rect; 78 | 79 | } 80 | 81 | getX() { 82 | 83 | if (!isNull(this._stub)) 84 | return this._stub; 85 | 86 | if (this._dirtyX) { 87 | this.refreshSize(axisSet.x); 88 | this.refreshPosition(axisSet.x); 89 | this._dirtyX = false; 90 | } 91 | 92 | return this._rect; 93 | 94 | } 95 | 96 | getY() { 97 | 98 | if (!isNull(this._stub)) 99 | return this._stub; 100 | 101 | if (this._dirtyY) { 102 | this.refreshSize(axisSet.y); 103 | this.refreshPosition(axisSet.y); 104 | this._dirtyY = false; 105 | } 106 | 107 | return this._rect; 108 | 109 | } 110 | 111 | getWidth() { 112 | 113 | if (!isNull(this._stub)) 114 | return this._stub; 115 | 116 | if (this._dirtyX) 117 | this.refreshSize(axisSet.x); 118 | 119 | return this._rect.width; 120 | 121 | } 122 | 123 | getHeight() { 124 | 125 | if (!isNull(this._stub)) 126 | return this._stub; 127 | 128 | if (this._dirtyY) 129 | this.refreshSize(axisSet.y); 130 | 131 | return this._rect.height; 132 | 133 | } 134 | 135 | toRect() { 136 | 137 | return new Rect(this.get()); 138 | 139 | } 140 | 141 | }; 142 | -------------------------------------------------------------------------------- /sources/core/boxes/ElementBox.js: -------------------------------------------------------------------------------- 1 | import { isNil, isNumber, isString } from 'lodash'; 2 | 3 | import { numberRegexF, percentageRegexF } from '../constants'; 4 | 5 | import { Box } from './Box'; 6 | 7 | export class ElementBox extends Box { 8 | 9 | refreshSize(axis) { 10 | 11 | switch (this._element.activeStyle.position) { 12 | 13 | case `static`: 14 | case `relative`: { 15 | this._refreshSizeStatic(axis); 16 | } break; 17 | 18 | case `absolute`: 19 | case `fixed`: { 20 | this._refreshSizeAbsolute(axis); 21 | } break; 22 | 23 | } 24 | 25 | let min = this._resolveValue(axis, this._element.activeStyle[axis.minSize]); 26 | let max = this._resolveValue(axis, this._element.activeStyle[axis.maxSize]); 27 | 28 | if (!isNil(max) && max < this._rect[axis.size]) 29 | this._rect[axis.size] = max; 30 | 31 | if (!isNil(min) && min > this._rect[axis.size]) { 32 | this._rect[axis.size] = min; 33 | } 34 | 35 | } 36 | 37 | refreshPosition(axis) { 38 | 39 | switch (this._element.activeStyle.position) { 40 | 41 | case `static`: 42 | case `relative`: { 43 | this._refreshPositionStatic(axis); 44 | } break; 45 | 46 | case `absolute`: 47 | case `fixed`: { 48 | this._refreshPositionAbsolute(axis); 49 | } break; 50 | 51 | } 52 | 53 | } 54 | 55 | _refreshSizeStatic(axis) { 56 | 57 | let size = this._resolveValue(axis, this._element.activeStyle[axis.size]); 58 | 59 | this._rect[axis.size] = size; 60 | 61 | } 62 | 63 | _refreshPositionStatic(axis) { 64 | 65 | this._rect[axis.a] = 0; 66 | this._rect[axis.b] = this._getBaseSize(axis) - this._rect[axis.size]; 67 | 68 | if (axis.a === `top`) { 69 | 70 | let previous = this._element.previousSibling; 71 | 72 | while (previous && !previous.activeStyle.flags.staticPositioning) 73 | previous = previous.previousNode; 74 | 75 | if (previous) { 76 | 77 | let previousBoxRect = previous.elementBox.getY(); 78 | let top = previousBoxRect.top + previousBoxRect.height; 79 | 80 | this._rect[axis.a] += top; 81 | this._rect[axis.b] -= top; 82 | 83 | } 84 | 85 | } 86 | 87 | } 88 | 89 | _refreshSizeAbsolute(axis) { 90 | 91 | let size = this._resolveValue(axis, this._element.activeStyle[axis.size]); 92 | 93 | if (!isNil(size)) { 94 | 95 | this._rect[axis.size] = size; 96 | 97 | } else { 98 | 99 | let base = this._getBaseSize(axis); 100 | 101 | let a = this._resolveValue(axis, this._element.activeStyle[axis.a]); 102 | let b = this._resolveValue(axis, this._element.activeStyle[axis.b]); 103 | 104 | this._rect[axis.size] = base - a - b; 105 | 106 | } 107 | 108 | } 109 | 110 | _refreshPositionAbsolute(axis) { 111 | 112 | let base = this._getBaseSize(axis); 113 | let size = this._rect[axis.size]; 114 | 115 | let a = this._resolveValue(axis, this._element.activeStyle[axis.a]); 116 | let b = this._resolveValue(axis, this._element.activeStyle[axis.b]); 117 | 118 | if (!isNil(a)) { 119 | b = base - size - a; 120 | } else if (!isNil(b)) { 121 | a = base - size - b; 122 | } else { 123 | a = 0; 124 | b = base - size; 125 | } 126 | 127 | this._rect[axis.a] = a; 128 | this._rect[axis.b] = b; 129 | 130 | } 131 | 132 | _resolveValue(axis, value) { 133 | 134 | if (isNil(value)) 135 | return value; 136 | 137 | if (isNumber(value)) 138 | return Math.floor(value); 139 | 140 | if (!isString(value)) 141 | throw new Error(`Invalid value type`); 142 | 143 | if (value === `adaptive`) 144 | return this._getAdaptiveSize(axis); 145 | 146 | if (numberRegexF.test(value)) 147 | return Math.floor(value.match(numberRegexF)[1]); 148 | 149 | if (percentageRegexF.test(value)) 150 | return Math.floor(value.match(percentageRegexF)[1] * this._getBaseSize(axis) / 100); 151 | 152 | throw new Error(`Invalid value format (is "${value}")`); 153 | 154 | } 155 | 156 | _getAdaptiveSize(axis) { 157 | 158 | let size = this._element[axis.scrollSize]; 159 | 160 | if (this._element.activeStyle.border) 161 | size += 2; 162 | 163 | return size; 164 | 165 | } 166 | 167 | _getBaseSize(axis) { 168 | 169 | let baseElement = this._element.parentNode; 170 | 171 | while (baseElement && baseElement.activeStyle.flags[axis.adaptiveFlag]) 172 | baseElement = baseElement.parentNode; 173 | 174 | if (!baseElement) 175 | return 0; 176 | 177 | switch (axis.size) { 178 | 179 | case `width`: { 180 | return baseElement.contentBox.getWidth(); 181 | } break; 182 | 183 | case `height`: { 184 | return baseElement.contentBox.getHeight(); 185 | } break; 186 | 187 | } 188 | 189 | } 190 | 191 | } 192 | -------------------------------------------------------------------------------- /sources/core/Rect.js: -------------------------------------------------------------------------------- 1 | import { isNil } from 'lodash'; 2 | 3 | export class Rect { 4 | 5 | static fromJS({ top, bottom, left, right, width, height } = {}) { 6 | 7 | let rect = new this(); 8 | 9 | if (!isNil(left)) 10 | rect.left = left; 11 | 12 | if (!isNil(right)) 13 | rect.right = right; 14 | 15 | if (!isNil(top)) 16 | rect.top = top; 17 | 18 | if (!isNil(bottom)) 19 | rect.bottom = bottom; 20 | 21 | if (!isNil(width)) 22 | rect.width = width; 23 | 24 | if (!isNil(height)) 25 | rect.height = height; 26 | 27 | return rect; 28 | 29 | } 30 | 31 | constructor(other) { 32 | 33 | if (other instanceof Rect) { 34 | 35 | this.copySelf(other); 36 | 37 | } else { 38 | 39 | this.top = this.bottom = 0; 40 | this.left = this.right = 0; 41 | 42 | this.width = this.height = null; 43 | 44 | } 45 | 46 | } 47 | 48 | copySelf(other) { 49 | 50 | this.left = other.left; 51 | this.right = other.right; 52 | 53 | this.top = other.top; 54 | this.bottom = other.bottom; 55 | 56 | this.width = other.width; 57 | this.height = other.height; 58 | 59 | } 60 | 61 | contractSelf(top, right, bottom, left) { 62 | 63 | this.top += top; 64 | this.bottom += bottom; 65 | 66 | this.left += left; 67 | this.right += right; 68 | 69 | this.width -= left + right; 70 | this.height -= top + bottom; 71 | 72 | this.width = Math.max(0, this.width); 73 | this.height = Math.max(0, this.height); 74 | 75 | } 76 | 77 | setOriginSelf(top, right, bottom, left) { 78 | 79 | this.top += top; 80 | this.bottom += bottom; 81 | 82 | this.left += left; 83 | this.right += right; 84 | 85 | } 86 | 87 | isValid() { 88 | 89 | return !isNaN(this.width) && !isNaN(this.height); 90 | 91 | } 92 | 93 | contains(other) { 94 | 95 | return other.left >= this.left 96 | && other.top >= this.top 97 | && other.left + other.width <= this.left + this.width 98 | && other.top + other.height <= this.top + this.height; 99 | 100 | } 101 | 102 | exclude(other) { 103 | 104 | if (!this.width || !this.height) 105 | return []; 106 | 107 | let intersection = this.intersection(other); 108 | 109 | if (!intersection) 110 | return [ new Rect(this) ]; 111 | 112 | let workingRect = new Rect(this); 113 | let results = [], tmp; 114 | 115 | if (intersection.left > this.left) { 116 | results.push(tmp = new Rect()); 117 | tmp.left = this.left; 118 | tmp.right = intersection.right + intersection.width; 119 | tmp.top = intersection.top; 120 | tmp.bottom = intersection.bottom; 121 | tmp.width = intersection.left - this.left; 122 | tmp.height = intersection.height; 123 | } 124 | 125 | if (intersection.left + intersection.width < this.left + this.width) { 126 | results.push(tmp = new Rect()); 127 | tmp.left = intersection.left + intersection.width; 128 | tmp.right = this.right; 129 | tmp.top = intersection.top; 130 | tmp.bottom = intersection.bottom; 131 | tmp.width = this.left + this.width - intersection.left - intersection.width; 132 | tmp.height = intersection.height; 133 | } 134 | 135 | if (intersection.top > this.top) { 136 | results.push(tmp = new Rect()); 137 | tmp.left = this.left; 138 | tmp.right = this.right; 139 | tmp.top = this.top; 140 | tmp.bottom = intersection.bottom + intersection.height; 141 | tmp.width = this.width; 142 | tmp.height = intersection.top - this.top; 143 | } 144 | 145 | if (intersection.top + intersection.height < this.top + this.height) { 146 | results.push(tmp = new Rect()); 147 | tmp.left = this.left; 148 | tmp.right = this.right; 149 | tmp.top = intersection.top + intersection.height; 150 | tmp.bottom = this.bottom; 151 | tmp.width = this.width; 152 | tmp.height = this.top + this.height - intersection.top - intersection.height; 153 | } 154 | 155 | return results; 156 | 157 | } 158 | 159 | intersection(other) { 160 | 161 | let doesIntersect = 162 | 163 | other.left < this.left + this.width && 164 | other.left + other.width > this.left && 165 | 166 | other.top < this.top + this.height && 167 | other.top + other.height > this.top && 168 | 169 | this.width > 0 && this.height > 0 && 170 | other.width > 0 && other.height > 0; 171 | 172 | if (!doesIntersect) 173 | return false; 174 | 175 | let rect = new Rect(); 176 | 177 | rect.left = Math.max(this.left, other.left); 178 | rect.top = Math.max(this.top, other.top); 179 | 180 | rect.width = Math.min(this.left + this.width, other.left + other.width) - rect.left; 181 | rect.height = Math.min(this.top + this.height, other.top + other.height) - rect.top; 182 | 183 | rect.right = Math.min(this.right + this.width, other.right + other.width) - rect.width; 184 | rect.bottom = Math.min(this.bottom + this.height, other.bottom + other.height) - rect.height; 185 | 186 | return rect; 187 | 188 | } 189 | 190 | toString() { 191 | 192 | return ``; 193 | 194 | } 195 | 196 | } 197 | -------------------------------------------------------------------------------- /sources/core/TermString.js: -------------------------------------------------------------------------------- 1 | import { isArray, isNil, isRegExp, isUndefined } from 'lodash'; 2 | 3 | export class TermString { 4 | 5 | constructor(string) { 6 | 7 | this._content = [ '' ]; 8 | 9 | this.length = 0; 10 | 11 | if (!isUndefined(string)) { 12 | this.push(string); 13 | } 14 | 15 | } 16 | 17 | push(string, raw = false) { 18 | 19 | if (isNil(string)) 20 | return this; 21 | 22 | if (string instanceof TermString) { 23 | 24 | this.length += string.length; 25 | 26 | if (this._content.length % 2 === 1) { 27 | 28 | this._content[this._content.length - 1] += string._content[0]; 29 | this._content = this._content.concat(string._content.slice(1)); 30 | 31 | } else { 32 | 33 | this._content = this._content.concat(string._content); 34 | 35 | } 36 | 37 | } else if (raw) { 38 | 39 | let asString = String(string); 40 | 41 | if (asString.length === 0) 42 | return this; 43 | 44 | if (this._content.length % 2 === 0) { 45 | 46 | this._content[this._content.length - 1] += asString; 47 | 48 | } else { 49 | 50 | this._content.push(asString); 51 | 52 | } 53 | 54 | } else { 55 | 56 | let asString = String(string); 57 | 58 | if (asString.length === 0) 59 | return this; 60 | 61 | if (this._content.length % 2 === 1) { 62 | 63 | this._content[this._content.length - 1] += asString; 64 | this.length += asString.length; 65 | 66 | } else { 67 | 68 | this._content.push(asString); 69 | this.length += asString.length; 70 | 71 | } 72 | 73 | } 74 | 75 | return this; 76 | 77 | } 78 | 79 | unshift(string, raw = false) { 80 | 81 | if (isNil(string)) 82 | return this; 83 | 84 | if (string instanceof TermString) { 85 | 86 | this._content.length += string.length; 87 | 88 | if (string._content.length % 2 === 1) { 89 | 90 | this._content[0] = string._content[string._content.length - 1] + this._content[0]; 91 | this._content = [].concat(string._content.slice(0, string._content.length - 1), this._content); 92 | 93 | } else { 94 | 95 | this._content = [].concat(string._content, this._content); 96 | 97 | } 98 | 99 | } else if (raw) { 100 | 101 | let asString = String(raw); 102 | 103 | if (asString.length === 0) 104 | return this; 105 | 106 | this._content.unshift(string); 107 | this._content.unshift(''); 108 | 109 | } else { 110 | 111 | let asString = String(raw); 112 | 113 | if (asString.length === 0) 114 | return this; 115 | 116 | this._content[0] = string + this._content[0]; 117 | this.length += asString.length; 118 | 119 | } 120 | 121 | return this; 122 | 123 | } 124 | 125 | substr(offset, length = this.length - offset) { 126 | 127 | if (offset + length > this.length) 128 | length = Math.max(0, this.length - offset); 129 | 130 | let index = 0; 131 | 132 | while (index + 2 < this._content.length && offset >= this._content[index].length) { 133 | offset -= this._content[index].length; 134 | index += 2; 135 | } 136 | 137 | let prefix = ``; 138 | 139 | for (let escapeCodeIndex = index - 1; escapeCodeIndex >= 0; escapeCodeIndex -= 2) 140 | prefix = this._content[escapeCodeIndex] + prefix; 141 | 142 | let result = new TermString(); 143 | result.push(prefix, true); 144 | 145 | while (index < this._content.length && length > 0) { 146 | 147 | result.push(this._content[index - 1], true); 148 | result.push(this._content[index].substr(offset, length)); 149 | 150 | length -= this._content[index].length - offset; 151 | offset = 0; 152 | index += 2; 153 | 154 | } 155 | 156 | return result; 157 | 158 | } 159 | 160 | split(pattern) { 161 | 162 | let last = new TermString(), results = [ last ]; 163 | 164 | let prefix = ''; 165 | let match, offset, str; 166 | 167 | if (!isRegExp(pattern)) 168 | throw new Error('TermString can only split on regexp'); 169 | 170 | let regex = pattern.global ? pattern : new RegExp(pattern.source, 'g' + [ 171 | pattern.multiline ? 'm' : '', 172 | pattern.ignoreCase ? 'i' : '' 173 | ].join('')); 174 | 175 | for (let t = 0; t < this._content.length; t += 2) { 176 | 177 | let escapeCode = this._content[t - 1] || ''; 178 | 179 | last.push(escapeCode, true); 180 | prefix += escapeCode; 181 | 182 | str = this._content[t]; 183 | offset = 0; 184 | 185 | while ((match = regex.exec(str))) { 186 | 187 | last.push(str.substr(offset, match.index - offset)); 188 | offset = match.index + match[0].length; 189 | 190 | results.push(last = new TermString()); 191 | last.push(prefix, true); 192 | 193 | } 194 | 195 | last.push(this._content[t].substr(offset)); 196 | 197 | } 198 | 199 | return results; 200 | 201 | } 202 | 203 | replace(pattern, replacement, insideRaw = false) { 204 | 205 | let other = new TermString(this); 206 | 207 | for (let t = insideRaw ? 1 : 0; t < other._content.length; t += 2) { 208 | 209 | let part = other._content[t]; 210 | let replaced = part.replace(pattern, replacement); 211 | 212 | if (part === replaced) 213 | continue ; 214 | 215 | if (!insideRaw) { 216 | other.length -= part.length; 217 | other.length += replaced.length; 218 | } 219 | 220 | if (!pattern.global) { 221 | break ; 222 | } 223 | 224 | } 225 | 226 | return other; 227 | 228 | } 229 | 230 | padEnd(expectedLength, character = ` `) { 231 | 232 | let other = new TermString(this); 233 | 234 | if (other.length < expectedLength) 235 | other.push(character.repeat(expectedLength - other.length)); 236 | 237 | return other; 238 | 239 | } 240 | 241 | toString() { 242 | 243 | return this._content.join(''); 244 | 245 | } 246 | 247 | } 248 | -------------------------------------------------------------------------------- /sources/core/Block.js: -------------------------------------------------------------------------------- 1 | import { style } from '@manaflair/term-strings'; 2 | 3 | import { Element } from './Element'; 4 | import { TermString } from './TermString'; 5 | 6 | export class Block extends Element { 7 | 8 | constructor(style) { 9 | 10 | super(style); 11 | 12 | this.addShortcutListener(`home`, e => { 13 | 14 | e.setDefault(() => { 15 | 16 | this.scrollTo(0); 17 | 18 | }); 19 | 20 | }); 21 | 22 | this.addShortcutListener(`end`, e => { 23 | 24 | e.setDefault(() => { 25 | 26 | this.scrollTo(Infinity); 27 | 28 | }); 29 | 30 | }); 31 | 32 | this.addShortcutListener(`up`, e => { 33 | 34 | e.setDefault(() => { 35 | 36 | this.scrollBy(-1); 37 | 38 | }); 39 | 40 | }); 41 | 42 | this.addShortcutListener(`down`, e => { 43 | 44 | e.setDefault(() => { 45 | 46 | this.scrollBy(+1); 47 | 48 | }); 49 | 50 | }); 51 | 52 | this.addShortcutListener(`pageup`, e => { 53 | 54 | e.setDefault(() => { 55 | 56 | this.scrollBy(-10); 57 | 58 | }); 59 | 60 | }); 61 | 62 | this.addShortcutListener(`pagedown`, e => { 63 | 64 | e.setDefault(() => { 65 | 66 | this.scrollBy(+10); 67 | 68 | }); 69 | 70 | }); 71 | 72 | } 73 | 74 | scrollTo(offset) { 75 | 76 | offset = Math.min(offset, this.scrollHeight - this.contentBox.get().height); 77 | offset = Math.max(0, offset); 78 | 79 | if (offset === this.scrollTop) 80 | return this; 81 | 82 | return this.applyElementBoxInvalidatingActions(false, true, () => { 83 | 84 | this.scrollTop = offset; 85 | 86 | this.screenNode.invalidateRenderList(); 87 | 88 | }); 89 | 90 | } 91 | 92 | scrollBy(relative) { 93 | 94 | this.scrollTo(this.scrollTop + relative); 95 | 96 | return this; 97 | 98 | } 99 | 100 | scrollLineIntoView(line, anchor) { 101 | 102 | let scrollTop = this.scrollTop; 103 | let height = this.contentBox.getY().height; 104 | 105 | if (line >= scrollTop && line < scrollTop + height) 106 | return this; 107 | 108 | if (isUndefined(anchor)) { 109 | if (line <= scrollTop) { 110 | anchor = `top`; 111 | } else { 112 | anchor = `bottom`; 113 | } 114 | } 115 | 116 | switch (anchor) { 117 | 118 | case `top`: 119 | this.scrollTo(line); 120 | break ; 121 | 122 | case `bottom`: 123 | this.scrollTo(line + 1 - height); 124 | break ; 125 | 126 | default: 127 | throw new Error(`Invalid scroll anchor`); 128 | 129 | } 130 | 131 | return this; 132 | 133 | } 134 | 135 | renderElement(x, y, l) { 136 | 137 | let elementRect = this.elementBox.get(); 138 | let activeStyle = this.activeStyle; 139 | 140 | let processBorders = (x, y, l) => { 141 | 142 | if (!activeStyle.border) 143 | return processContent(x, y, l); 144 | 145 | let prepend = ``; 146 | let append = ``; 147 | 148 | if (y === 0) { 149 | 150 | let contentL = l; 151 | 152 | if (x === 0) { 153 | prepend = activeStyle.border.topLeft; 154 | contentL -= 1; 155 | } 156 | 157 | if (x + l === elementRect.width) { 158 | append = activeStyle.border.topRight; 159 | contentL -= 1; 160 | } 161 | 162 | let data = prepend + activeStyle.border.horizontal.repeat(contentL) + append; 163 | 164 | if (activeStyle.backgroundColor) 165 | data = style.back(activeStyle.backgroundColor) + data; 166 | 167 | if (activeStyle.borderColor) 168 | data = style.front(activeStyle.borderColor) + data; 169 | 170 | if (activeStyle.backgroundColor || activeStyle.borderColor) 171 | data += style.clear; 172 | 173 | return data; 174 | 175 | } else if (y === elementRect.height - 1) { 176 | 177 | let contentL = l; 178 | 179 | if (x === 0) { 180 | prepend = activeStyle.border.bottomLeft; 181 | contentL -= 1; 182 | } 183 | 184 | if (x + l === elementRect.width) { 185 | append = activeStyle.border.bottomRight; 186 | contentL -= 1; 187 | } 188 | 189 | let data = prepend + activeStyle.border.horizontal.repeat(contentL) + append; 190 | 191 | if (activeStyle.backgroundColor) 192 | data = style.back(activeStyle.backgroundColor) + data; 193 | 194 | if (activeStyle.borderColor) 195 | data = style.front(activeStyle.borderColor) + data; 196 | 197 | if (activeStyle.backgroundColor || activeStyle.borderColor) 198 | data += style.clear; 199 | 200 | return data; 201 | 202 | } else { 203 | 204 | let contentX = x; 205 | let contentY = y - 1; 206 | let contentL = l; 207 | 208 | if (x === 0) { 209 | prepend = activeStyle.border.vertical; 210 | contentL -= 1; 211 | } else { 212 | contentX -= 1; 213 | } 214 | 215 | if (x + l === elementRect.width) { 216 | append = activeStyle.border.vertical; 217 | contentL -= 1; 218 | } 219 | 220 | if (activeStyle.backgroundColor) { 221 | 222 | if (prepend) 223 | prepend = style.back(activeStyle.backgroundColor) + prepend; 224 | 225 | if (append) { 226 | append = style.back(activeStyle.backgroundColor) + append; 227 | } 228 | 229 | } 230 | 231 | if (activeStyle.borderColor) { 232 | 233 | if (prepend) 234 | prepend = style.front(activeStyle.borderColor) + prepend; 235 | 236 | if (append) { 237 | append = style.front(activeStyle.borderColor) + append; 238 | } 239 | 240 | } 241 | 242 | if (activeStyle.backgroundColor || activeStyle.borderColor) { 243 | 244 | if (prepend) 245 | prepend += style.clear; 246 | 247 | if (append) { 248 | append += style.clear; 249 | } 250 | 251 | } 252 | 253 | return prepend + processContent(contentX, contentY, contentL) + append; 254 | 255 | } 256 | 257 | }; 258 | 259 | let processContent = (x, y, l) => { 260 | 261 | let content = this.renderContent(x, y, l); 262 | 263 | if (content.length < l) { 264 | 265 | if (activeStyle.backgroundColor || activeStyle.borderColor) 266 | content = new TermString(content); 267 | 268 | if (activeStyle.backgroundColor) 269 | content = content.push(style.back(activeStyle.backgroundColor), true); 270 | 271 | if (activeStyle.color) 272 | content = content.push(style.front(activeStyle.color), true); 273 | 274 | content = content.padEnd(l, activeStyle.backgroundCharacter); 275 | 276 | if (activeStyle.backgroundColor || activeStyle.borderColor) { 277 | content = content.push(style.clear); 278 | } 279 | 280 | } 281 | 282 | return content; 283 | 284 | }; 285 | 286 | return processBorders(x, y, l); 287 | 288 | } 289 | 290 | renderContent(x, y, l) { 291 | 292 | return ``; 293 | 294 | } 295 | 296 | } 297 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![OhUI!](http://i.imgur.com/lsuXlRu.png) 2 | 3 | ![](http://i.imgur.com/6X57wMj.gif) 4 | 5 | > OhUI! is a graphical Node library, designed to make it easy to build pretty terminal interfaces. 6 | 7 | ## Features 8 | 9 | - DOM-like API (appendChild, removeChild, ...) 10 | - Supports common UI behaviors (such as focus) 11 | - Very easily extendable 12 | - Performant and fast 13 | - Wrote using ES6 14 | 15 | ## Example 16 | 17 | The following examples have all been ported on browsers using [xterm.js](https://github.com/sourcelair/xterm.js), but can run on your terminal as well. 18 | 19 | - [Bouncing Ball](https://arcanis.github.io/ohui-web/examples/bouncing-ball.html) 20 | 21 | ## Installation 22 | 23 | ``` 24 | $> npm install --save ohui 25 | ``` 26 | 27 | ## Usage 28 | 29 | ```js 30 | import { Screen, Box } from 'ohui'; 31 | 32 | let screen = new Screen(); 33 | 34 | let box = new Box({ width: `100%`, height: `100%`, borders: OhUI.borders.strong }); 35 | screen.appendChild(box); 36 | ``` 37 | 38 | ## Reference 39 | 40 | ### Elements 41 | 42 | - **new Element( style )** 43 | 44 | #### Methods 45 | 46 | - *setStyleProperty( property, value )* 47 | 48 | Set a unique style property. 49 | 50 | - *setStyleProperties( properties )* 51 | 52 | Set a batch of style properties. 53 | 54 | Using setStyleProperties is more efficient than calling setStyleProperty multiple times. 55 | 56 | - *appendChild( element )* 57 | 58 | Add a new child to the element. 59 | 60 | - *removeChild( element )* 61 | 62 | Remove a child from an element. 63 | 64 | - *scrollIntoView( element, anchor = "top" | "bottom" )* 65 | 66 | Scroll the element to show the specified element, anchored either at top or bottom. 67 | 68 | If the element is already on the screen, nothing happens. 69 | 70 | - *declareEvent( eventName )* 71 | 72 | Declare an event. Should not be called except by custom elements. 73 | 74 | - *dispatchEvent( event )* 75 | 76 | Trigger an event. Triggering an undeclared event won't work. 77 | 78 | - *addEventListener( eventName, callback )* 79 | 80 | Add an event listener. 81 | 82 | - *addShortcutListener( sequence, callback )* 83 | 84 | Add a *shortcut* listener. You can use the following format: 85 | 86 | `M-x, C-t, C-x C-f`, which means "Alt X, or Control T, or Control X followed with Control F". 87 | 88 | Please note that some keys cannot be correctly mapped due to terminal limitations (F11 and F12 are notorious at this regard). 89 | 90 | - *focus( )* 91 | 92 | Give the element the focus. If the element already has the focus, nothing happens. 93 | 94 | - *blur( )* 95 | 96 | Remove the focus from the element. If the element doesn't have the focus, nothing happens. 97 | 98 | #### Properties 99 | 100 | - *scrollLeft* 101 | 102 | The element's horizontal scroll offset. Read only. 103 | 104 | - *scrollTop* 105 | 106 | The element's vertical scroll offset. Read only. 107 | 108 | - *scrollWidth* 109 | 110 | The element's displayed width. Read only. 111 | 112 | - *scrollHeight* 113 | 114 | The element's displayed height. Read only. 115 | 116 | #### Events 117 | 118 | - *focus* 119 | 120 | Triggered when the element gets the focus. 121 | 122 | - *blur* 123 | 124 | Triggered when the element loses the focus. 125 | 126 | - *data* 127 | 128 | Triggered when the element gets data (escape codes are filtered out). 129 | 130 | - *keypress* 131 | 132 | Triggered when the element gets a keypress. 133 | 134 | - *click* 135 | 136 | Triggered when the element is clicked on. 137 | 138 | - **new Screen( { stdin, stdout } )** 139 | 140 | #### Events 141 | 142 | - *resize* 143 | 144 | Triggered when the screen resizes. 145 | 146 | - **new Block( style )** 147 | 148 | - **new Text( style )** 149 | 150 | #### Properties 151 | 152 | - *innerText* 153 | 154 | The element's content. Read/write. 155 | 156 | - **new Input( style )** 157 | 158 | #### Properties 159 | 160 | - *value* 161 | 162 | The element's value. Read/write. 163 | 164 | ### Styles 165 | 166 | - **focusable** 167 | 168 | - Undefined/Null: The element won't be focusable 169 | - Boolean: If true, the element will be focusable 170 | 171 | - **backgroundCharacter** 172 | 173 | - Undefined/Null: The background character will be a space. 174 | - String: The background character will be the specified string. Only use strings whose length is exactly 1. 175 | 176 | - **border** 177 | 178 | - Undefined/Null: The element won't have borders 179 | - Object: The element will have a border. The object has to contain the following fields: 180 | 181 | - topLeft 182 | - topRight 183 | - bottomLeft 184 | - bottomRight 185 | - horizontal 186 | - vertical 187 | 188 | - **position** 189 | 190 | - Undefined/Null: The position will defaults to "static". 191 | - "static": The element will be under the previous static element. 192 | - "relative": Just like "static", except that the element will also count as the anchor for "absolute" children. 193 | - "absolute": Will be positioned relative to the first "relative", "absolute" or "fixed" parent. Does not count in scrollHeight value. 194 | - "fixed": Just like "absolute", except that the positioning will ignore elements' scrolling. 195 | 196 | - **left**, **right**, **top** and **bottom** 197 | 198 | Totally ignored if the position is "static" (or "relative", which is a bug). 199 | 200 | - Neither *left* and *right* are Undefined/Null: The default width will be the space between the two values. 201 | - *left* is Undefined/Null but not *right*: The element will be right-aligned. 202 | - *right* is Undefined/Null but not *left*: The element will be left-aligned. 203 | - *left* and *right* are both Undefined/Null: The element will be left-aligned. 204 | 205 | As for the values: 206 | 207 | - Number: Will be positioned at *NNN* cells from the alignment. 208 | - Percentage: Will be positioned at *Percentage* cells from the alignment, relative to the first non-adaptive parent width. 209 | 210 | Same for *top*, *bottom* and height. 211 | 212 | - **width** and **height** 213 | 214 | - Number: The element will be *NNN* cells wide. 215 | - Percentage: Will be *Percentage* cells wide, relative to the first non-adaptive parent width. 216 | - "adaptive": The width will be the minimal width requested to hold the element content. 217 | 218 | Same for *height*. 219 | 220 | - **minWidth**, **minHeight**, **maxWidth** and **maxHeight** 221 | 222 | Supercedes *width* and *height*. The *min* values are prefered over *max* values. 223 | 224 | - **color** 225 | 226 | - Undefined/Null: The element won't have any color. 227 | - String: The element content will be colored with [Term-String](https://github.com/manaflair/term-strings). 228 | 229 | - **borderColor** 230 | 231 | - Undefined/Null: The element won't have any color. 232 | - String: The element borders will be colored with [Term-String](https://github.com/manaflair/term-strings). 233 | 234 | - **backgroundColor** 235 | 236 | - Undefined/Null: The element won't have any color. 237 | - String: The element background will be colored with [Term-String](https://github.com/manaflair/term-strings). 238 | 239 | - **zIndex** 240 | 241 | - Undefined/Null: The element won't have any kind of rendering priority. 242 | - Number: The element will be put on a layer in front every non-layered elements. The number is the layer index, greater means that the element will be in front of lesser layers. 243 | 244 | - **active** 245 | 246 | Think of it like an `:active` pseudo-class equivalent. 247 | 248 | - Undefined/Null: Nothing special happens. 249 | - Object: This object can contain any other style property. They will be applied as long as the element will be focused. 250 | 251 | ### Shortcuts 252 | 253 | OhUI! allows you to set up key sequence listeners. They can be set locally, bound to focusable elements, or globally, bound to the Screen instance. 254 | 255 | The sequences also work with key modifiers: you can prefix each key by some or multiple of `C-`, `M-` or `S-`. 256 | 257 | Note that some keys cannot be accessed in some cases. For example, the F11 key sequence is actually the Shift+F10 sequence (so you cannot distinguish those two keys). The issue does not come from OhUI!, but rather from the underlying terminal key encodings. 258 | 259 | ```js 260 | new OhUI.Screen().addShortcutListener(`C-d`, () => { 261 | // ... do something on ctrl-d 262 | }); 263 | ``` 264 | 265 | ### Colors 266 | 267 | In order to use colors, you have to use the TermString class, which is a kind of special-purpose string object where escape codes don't increase the string length. It is recommended to use it alongside the [Term-Strings](https://github.com/manaflair/term-strings) library to avoid hardcoding terminal sequences into your code. 268 | 269 | ```js 270 | import { style } from '@manaflair/term-strings'; 271 | 272 | let string = new TermString(); 273 | string.push('Hello'); 274 | string.push(style.bold); 275 | string.push('World'); 276 | 277 | let element = new Block(); 278 | element.innerText = string; 279 | ``` 280 | 281 | ## License (MIT) 282 | 283 | > **Copyright © 2014 Maël Nison** 284 | > 285 | > Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 286 | > 287 | > The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 288 | > 289 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 290 | -------------------------------------------------------------------------------- /tests/ElementBox.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { borders } from '../sources/core'; 4 | 5 | import { createTree, dummyScreen } from './extra/trees'; 6 | 7 | describe(`ElementBox`, () => { 8 | 9 | it(`should give full width to width-less static elements`, () => { 10 | 11 | var tree = createTree([ 12 | [ `screen`, dummyScreen, [ 13 | [ `main` ] 14 | ] ] 15 | ]); 16 | 17 | var elementRect = tree.main.elementBox.get(); 18 | 19 | expect(elementRect.left).to.equal(0, `The main element should be fixed to the X-border of its parent (left property)`); 20 | expect(elementRect.right).to.equal(0, `The main element should be fixed to the X-border of its parent (right property)`); 21 | expect(elementRect.width).to.equal(100, `The main element should have the width of its parent`); 22 | 23 | }); 24 | 25 | it(`should adapt the height of height-less elements to their content heights`, () => { 26 | 27 | var tree = createTree([ 28 | [ `screen`, dummyScreen, [ 29 | [ `main`, { border: borders.simple() }, [ 30 | [ `sub`, { height: 10 } ] 31 | ] ] 32 | ] ] 33 | ]); 34 | 35 | var elementRect = tree.sub.elementBox.get(); 36 | 37 | expect(elementRect.top).to.equal(0, `The sub element should have a position relative to its parent (top property)`); 38 | expect(elementRect.bottom).to.equal(0, `The sub element should have a position relative to its parent (bottom property)`); 39 | expect(elementRect.height).to.equal(10, `The sub element should have the height that has been given to it`); 40 | 41 | var elementRect = tree.main.elementBox.get(); 42 | 43 | expect(elementRect.top).to.equal(0, `The main element should be at the top of its parent (top property)`); 44 | expect(elementRect.bottom).to.equal(88, `The main element should be at the top of its parent (bottom property)`); 45 | expect(elementRect.height).to.equal(12, `The main element should have the size of its child, plus any border`); 46 | 47 | }); 48 | 49 | it(`should use the width/height specified in the properties with static elements`, () => { 50 | 51 | var tree = createTree([ 52 | [ `screen`, dummyScreen, [ 53 | [ `main`, { width: 20, height: 20 } ] 54 | ] ] 55 | ]); 56 | 57 | var elementRect = tree.main.elementBox.get(); 58 | 59 | expect(elementRect.left).to.equal(0, `The main element should be positioned at the left of its parent`); 60 | expect(elementRect.right).to.equal(80, `The main element should take its width in account when computing its right positioning`); 61 | expect(elementRect.width).to.equal(20, `The main element should use the width we gave it`); 62 | 63 | expect(elementRect.top).to.equal(0, `The main should be positioned at the top of its parent`); 64 | expect(elementRect.bottom).to.equal(80, `The main element should take its height in account when computing its bottom positioning`); 65 | expect(elementRect.height).to.equal(20, `The main element should use the height we gave it`); 66 | 67 | }); 68 | 69 | it(`should accept relative sizes when dealing with static elements`, () => { 70 | 71 | var tree = createTree([ 72 | [ `screen`, dummyScreen, [ 73 | [ `main`, { width: `50%` }, [ 74 | [ `sub`, { width: `50%` } ] 75 | ] ] 76 | ] ] 77 | ]); 78 | 79 | var elementRect = tree.main.elementBox.get(); 80 | 81 | expect(elementRect.left).to.equal(0, `The main element should be positioned at the left of its parent`); 82 | expect(elementRect.right).to.equal(50, `The main element should take its width in account when computing its right positioning`); 83 | expect(elementRect.width).to.equal(50, `The main element should resolve the width we gave it to 50% the size of its parent`); 84 | 85 | var elementRect = tree.sub.elementBox.get(); 86 | 87 | expect(elementRect.left).to.equal(0, `The sub element should be positioned at the left of its parent`); 88 | expect(elementRect.right).to.equal(25, `The sub element should take its width in account when computing its right positioning`); 89 | expect(elementRect.width).to.equal(25, `The sub element should resolve the width we gave it to 50% the size of its parent`); 90 | 91 | }); 92 | 93 | it(`should ignore positioning attributes when dealing with static and relative elements`, () => { 94 | 95 | var tree = createTree([ 96 | [ `screen`, dummyScreen, [ 97 | [ `main`, { left: 10, right: 10, top: 10, bottom: 10 } ] 98 | ] ] 99 | ]); 100 | 101 | var elementRect = tree.main.elementBox.get(); 102 | 103 | expect(elementRect.left).to.equal(0, `The main element left positioning should not be affected by its left style property`); 104 | expect(elementRect.right).to.equal(0, `The main element right positioning should not be affected by its right style property`); 105 | expect(elementRect.top).to.equal(0, `The main element top positioning should not be affected by its top style property`); 106 | expect(elementRect.bottom).to.equal(100, `The main element bottom positioning should not be affected by its bottom style property`); 107 | 108 | expect(elementRect.width).to.equal(100, `The main element width should not be affected by its positioning style properties`); 109 | expect(elementRect.height).to.equal(0, `The main element height should not be affected by its positioning style properties`); 110 | 111 | }); 112 | 113 | it(`should accept top/right/bottom/left positioning when dealing with absolute and fixed elements`, () => { 114 | 115 | var tree = createTree([ 116 | [ `screen`, dummyScreen, [ 117 | [ `main`, { position: `absolute`, left: 10, right: 20, top: 30, bottom: 40 } ] 118 | ] ] 119 | ]); 120 | 121 | var elementRect = tree.main.elementBox.get(); 122 | 123 | expect(elementRect.left).to.equal(10, `The main element left positioning should be affected by its left style property`); 124 | expect(elementRect.right).to.equal(20, `The main element right positioning should be affected by its right style property`); 125 | expect(elementRect.top).to.equal(30, `The main element top positioning should be affected by its top style property`); 126 | expect(elementRect.bottom).to.equal(40, `The main element bottom positioning should be affected by its bottom style property`); 127 | 128 | // Width / Height are tested in another "it" block 129 | 130 | }); 131 | 132 | it(`should automatically deduce its width/height when dealing with absolute and fixed elements`, () => { 133 | 134 | var tree = createTree([ 135 | [ `screen`, dummyScreen, [ 136 | [ `main`, { position: `absolute`, left: 10, right: 20, top: 30, bottom: 40 } ] 137 | ] ] 138 | ]); 139 | 140 | var elementRect = tree.main.elementBox.get(); 141 | 142 | expect(elementRect.width).to.equal(70, `The main element width should be affected by its positioning`); 143 | expect(elementRect.height).to.equal(30, `The main element height should be affected by its positioning`); 144 | 145 | }); 146 | 147 | it(`should put static elements one bottom of the other`, () => { 148 | 149 | var tree = createTree([ 150 | [ `screen`, dummyScreen, [ 151 | [ `main`, {}, [ 152 | [ `foo`, { height: 10 } ], 153 | [ `bar`, { height: 10 } ] 154 | ] ] 155 | ] ] 156 | ]); 157 | 158 | var elementRect = tree.foo.elementBox.get(); 159 | 160 | expect(elementRect.top).to.equal(0, `The first element should be at the top (top property)`); 161 | expect(elementRect.bottom).to.equal(10, `The first element should be at the top (bottom property)`); 162 | 163 | var elementRect = tree.bar.elementBox.get(); 164 | 165 | expect(elementRect.top).to.equal(10, `The second element should be at the bottom (top property)`); 166 | expect(elementRect.bottom).to.equal(0, `The second element should be at the bottom (bottom property)`); 167 | 168 | }); 169 | 170 | it(`should compute top/right/bottom/left relative to the content box of its first absolute ascendant`, () => { 171 | 172 | var tree = createTree([ 173 | [ `screen`, dummyScreen, [ 174 | [ `main`, { position: `absolute`, left: 1, right: 1, top: 1, bottom: 1, border: {} }, [ 175 | [ `sub`, { position: `absolute`, left: 0, right: 0, top: 0, bottom: 0 } ] 176 | ] ] 177 | ] ] 178 | ]); 179 | 180 | var subElementBoxRect = tree.sub.elementBox.get(); 181 | 182 | expect(subElementBoxRect.left).to.equal(0, `The element box of the sub element should be at the border of its parent content box (left property)`); 183 | expect(subElementBoxRect.right).to.equal(0, `The element box of the sub element should be at the border of its parent content box (right property)`); 184 | 185 | expect(subElementBoxRect.top).to.equal(0, `The element box of the sub element should be at the border of its parent content box (top property)`); 186 | expect(subElementBoxRect.bottom).to.equal(0, `The element box of the sub element should be at the border of its parent content box (bottom property)`); 187 | 188 | var mainWorldElementBoxRect = tree.main.worldElementBox.get(); 189 | 190 | expect(mainWorldElementBoxRect.left).to.equal(1, `The world element box of the main element should be at 1 cell from the border (left property)`); 191 | expect(mainWorldElementBoxRect.right).to.equal(1, `The world element box of the main element should be at 1 cell from the border (right property)`); 192 | 193 | expect(mainWorldElementBoxRect.top).to.equal(1, `The world element box of the main element should be at 1 cell from the border (top property)`); 194 | expect(mainWorldElementBoxRect.bottom).to.equal(1, `The world element box of the main element should be at 1 cell from the border (bottom property)`); 195 | 196 | var subWorldElementBoxRect = tree.sub.worldElementBox.get(); 197 | 198 | expect(subWorldElementBoxRect.left).to.equal(2, `The world element box of the sub element should be at 1+1 cell from the border (left property)`); 199 | expect(subWorldElementBoxRect.right).to.equal(2, `The world element box of the sub element should be at 1+1 cell from the border (right property)`); 200 | 201 | expect(subWorldElementBoxRect.top).to.equal(2, `The world element box of the sub element should be at 1+1 cell from the border (top property)`); 202 | expect(subWorldElementBoxRect.bottom).to.equal(2, `The world element box of the sub element should be at 1+1 cell from the border (bottom property)`); 203 | 204 | }); 205 | 206 | }); 207 | -------------------------------------------------------------------------------- /sources/core/Screen.js: -------------------------------------------------------------------------------- 1 | import { clear, moveTo, reset, style } from '@manaflair/term-strings'; 2 | import keypress from 'keypress'; 3 | import { isNull } from 'lodash'; 4 | import stable from 'stable'; 5 | 6 | import { TerminalBox } from './boxes/TerminalBox'; 7 | 8 | import { Element } from './Element'; 9 | import { Event } from './Event'; 10 | import { Rect } from './Rect'; 11 | 12 | let debugColors = [ `red`, `green`, `blue`, `magenta` ], currentDebugColorIndex = 0; 13 | let invalidUtf8Symbols = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/; 14 | 15 | export class Screen extends Element { 16 | 17 | constructor({ stdin = process.stdin, stdout = process.stdout, resetOnExit = true } = {}) { 18 | 19 | super({ left: 0, right: 0, top: 0, bottom: 0 }); 20 | 21 | let terminalBox = new TerminalBox(this); 22 | this.elementBox = this.scrollElementBox = this.worldElementBox = this.clipElementBox = terminalBox; 23 | this.contentBox = this.scrollContentBox = this.worldContentBox = this.clipContentBox = terminalBox; 24 | 25 | this.activeElement = this; 26 | 27 | this._nodeList = null; 28 | this._renderList = null; 29 | 30 | this._pending = []; 31 | this._nextRedraw = null; 32 | 33 | this._in = stdin; 34 | this._out = stdout; 35 | 36 | this._in.setRawMode(true); 37 | 38 | keypress(this._in); 39 | keypress.enableMouse(this._out); 40 | 41 | this._out.write(reset); 42 | this._out.write(style.cursor.hidden); 43 | 44 | if (resetOnExit) { 45 | 46 | process.on('exit', () => { 47 | 48 | keypress.disableMouse(this._out); 49 | this._out.write(reset); 50 | 51 | }); 52 | 53 | } 54 | 55 | this._out.on('resize', () => { 56 | 57 | this.applyElementBoxInvalidatingActions(true, true, () => { 58 | this._out.write(clear); 59 | }); 60 | 61 | }); 62 | 63 | this._in.on('keypress', (data, key) => { 64 | 65 | this._keyEvent(data, key); 66 | 67 | }); 68 | 69 | this._in.on('mousepress', (e) => { 70 | 71 | if (e.release) 72 | return ; 73 | 74 | this._mouseEvent(e); 75 | 76 | }); 77 | 78 | this.addShortcutListener('S-tab', e => { 79 | 80 | e.setDefault(() => { 81 | 82 | this._focusRelativeElement(-1); 83 | 84 | }); 85 | 86 | }); 87 | 88 | this.addShortcutListener('tab', e => { 89 | 90 | e.setDefault(() => { 91 | 92 | this._focusRelativeElement(+1); 93 | 94 | }); 95 | 96 | }); 97 | 98 | this.addShortcutListener('C-c', e => { 99 | 100 | e.setDefault(() => { 101 | 102 | process.exit(); 103 | 104 | }); 105 | 106 | }); 107 | 108 | this.screenNode = this; 109 | 110 | this.prepareRedraw(); 111 | 112 | } 113 | 114 | invalidateNodeList() { 115 | 116 | this._nodeList = null; 117 | 118 | this.invalidateRenderList(); 119 | 120 | } 121 | 122 | invalidateRenderList() { 123 | 124 | this._renderList = null; 125 | 126 | } 127 | 128 | getNodeList() { 129 | 130 | if (this._nodeList) 131 | return this._nodeList; 132 | 133 | let traverseList = [ this ]; 134 | let nodeList = this._nodeList = []; 135 | 136 | while (traverseList.length) { 137 | 138 | let element = traverseList.shift(); 139 | nodeList.push(element); 140 | 141 | traverseList = element.childNodes.concat(traverseList); 142 | 143 | } 144 | 145 | return nodeList; 146 | 147 | } 148 | 149 | getRenderList() { 150 | 151 | if (this._renderList) 152 | return this._renderList; 153 | 154 | let makeTreeNode = () => ({ layers: {}, elements: [] }); 155 | 156 | let renderList = this._renderList = []; 157 | let renderTree = makeTreeNode(); 158 | let currentTreeNode = renderTree; 159 | 160 | let getLayer = zIndex => { 161 | 162 | if (typeof currentTreeNode.layers[zIndex] === 'undefined') 163 | currentTreeNode.layers[zIndex] = makeTreeNode(); 164 | 165 | return currentTreeNode.layers[zIndex]; 166 | 167 | }; 168 | 169 | let layeringVisitor = element => { 170 | 171 | if (!element.activeStyle.flags.isVisible) 172 | return ; 173 | 174 | let clipRect = element.clipElementBox.get(); 175 | 176 | if (!clipRect.width || !clipRect.height) 177 | return ; 178 | 179 | let zIndex = element.activeStyle.zIndex; 180 | 181 | if (zIndex != null) { 182 | 183 | let previousTreeNode = currentTreeNode; 184 | currentTreeNode = getLayer(zIndex); 185 | 186 | currentTreeNode.elements.unshift(element); 187 | element.childNodes.forEach(child => { layeringVisitor(child); }); 188 | 189 | currentTreeNode = previousTreeNode; 190 | 191 | } else { 192 | 193 | currentTreeNode.elements.unshift(element); 194 | element.childNodes.forEach(child => { layeringVisitor(child); }); 195 | 196 | } 197 | 198 | }; 199 | 200 | let flatteningVisitor = treeNode => { 201 | 202 | let layers = Object.keys(treeNode.layers).sort((a, b) => a - b); 203 | 204 | layers.forEach(zIndex => { flatteningVisitor(treeNode.layers[zIndex]); }); 205 | 206 | treeNode.elements.forEach(element => { renderList.push(element); }); 207 | 208 | }; 209 | 210 | layeringVisitor(this); 211 | flatteningVisitor(renderTree); 212 | 213 | return renderList; 214 | 215 | } 216 | 217 | prepareRedrawRect(redrawRect) { 218 | 219 | if (!isNull(redrawRect)) { 220 | 221 | if (!redrawRect.width || !redrawRect.height) 222 | return this; 223 | 224 | this._queueRedraw([ redrawRect ]); 225 | 226 | } 227 | 228 | if (isNull(this._nextRedraw)) { 229 | 230 | this._nextRedraw = setImmediate(() => { 231 | this._nextRedraw = null; 232 | this._redraw(); 233 | }); 234 | 235 | } 236 | 237 | return this; 238 | 239 | } 240 | 241 | _keyEvent(data, key) { 242 | 243 | if (!data || invalidUtf8Symbols.test(data)) 244 | data = null; 245 | 246 | if (data && !key && data.length === 1) 247 | key = { ctrl: false, shift: false, meta: false, name: data }; 248 | 249 | if (!data && !key) 250 | return ; 251 | 252 | let keyDef = key ? { control: key.ctrl, shift: key.shift, meta: key.meta, key: key.name } : null; 253 | let dataEvent = new Event('data', { target: this.activeElement, data: data, key: keyDef }); 254 | 255 | for (let element = dataEvent.target; element; element = element.parentNode) 256 | element.dispatchEvent(dataEvent); 257 | 258 | dataEvent.resolveDefault(); 259 | 260 | } 261 | 262 | /** 263 | */ 264 | 265 | _mouseEvent(e) { 266 | 267 | let x = e.x - 1, y = e.y - 1; 268 | 269 | let renderList = this.getRenderList(); 270 | 271 | for (let t = 0, T = renderList.length; t < T; ++t) { 272 | 273 | let element = renderList[t]; 274 | let clipBox = element.clipElementBox.get(); 275 | 276 | if (x < clipBox.left || x >= clipBox.left + clipBox.width) 277 | continue ; 278 | if (y < clipBox.top || y >= clipBox.top + clipBox.height) 279 | continue ; 280 | 281 | if (e.scroll) { 282 | 283 | let event = new Event('scroll', { target: element, direction: e.scroll }); 284 | 285 | for (; element; element = element.parentNode) 286 | element.dispatchEvent(event); 287 | 288 | event.setDefault(() => { 289 | 290 | let element = event.target; 291 | 292 | while (element && !element.scrollBy) 293 | element = element.parentNode; 294 | 295 | if (element) { 296 | element.scrollBy(e.scroll * 3); 297 | } 298 | 299 | }); 300 | 301 | event.resolveDefault(); 302 | 303 | } else { 304 | 305 | let event = new Event('click', { target: element }); 306 | 307 | for (; element; element = element.parentNode) 308 | element.dispatchEvent(event); 309 | 310 | event.setDefault(() => { 311 | 312 | let element = event.target; 313 | 314 | while (element && !element.activeStyle.focusable) 315 | element = element.parentNode; 316 | 317 | if (element) { 318 | element.focus(); 319 | } 320 | 321 | }); 322 | 323 | event.resolveDefault(); 324 | 325 | } 326 | 327 | break ; 328 | 329 | } 330 | 331 | } 332 | 333 | renderElement(x, y, l) { 334 | 335 | return ` `.repeat(l); 336 | 337 | } 338 | 339 | /** 340 | * Render every requested rects. Each rect will be matched against every element of the scene, front-to-back. 341 | * 342 | * Once a rect matches, the rendering will go to the next rect without rendering the following elements; ie. there is no transparency. 343 | */ 344 | 345 | _redraw() { 346 | 347 | // We clear the next pending redraw if needed; useful when testing, so we can forcefully redraw the screen 348 | if (!isNull(this._nextRedraw)) { 349 | clearImmediate(this._nextRedraw); 350 | this._nextRedraw = null; 351 | } 352 | 353 | // We start by disabling the cursor (otherwise we would see it moving when redrawing) 354 | let buffer = style.cursor.hidden; 355 | 356 | // Choose a different color for each redraw 357 | let debugColor = debugColors[currentDebugColorIndex]; 358 | currentDebugColorIndex = (currentDebugColorIndex + 1) % debugColors.length; 359 | 360 | let renderList = this.getRenderList(); 361 | 362 | while (this._pending.length > 0) { 363 | 364 | let dirtyRect = this._pending.shift(); 365 | 366 | for (let t = 0, T = renderList.length; t < T; ++t) { 367 | 368 | let element = renderList[t]; 369 | 370 | let fullRect = element.worldElementBox.get(); 371 | let clipRect = element.clipElementBox.get(); 372 | 373 | if (!clipRect.width || !clipRect.height) 374 | continue ; 375 | 376 | let intersection = clipRect.intersection(dirtyRect); 377 | 378 | if (!intersection) 379 | continue ; 380 | 381 | let truncation = dirtyRect.exclude(intersection); 382 | this._queueRedraw(truncation.slice()); 383 | 384 | for (let y = 0, Y = intersection.height; y < Y; ++y) { 385 | 386 | let relativeX = intersection.left - fullRect.left; 387 | let relativeY = intersection.top - fullRect.top + y; 388 | 389 | let line = String(element.renderElement(relativeX, relativeY, intersection.width)); 390 | 391 | if (process.env.OHUI_DEBUG_RENDER) 392 | line = style.back(debugColor) + line + style.clear; 393 | 394 | buffer += moveTo({ x: intersection.left, y: intersection.top + y }); 395 | buffer += line; 396 | 397 | } 398 | 399 | break ; 400 | 401 | } 402 | 403 | } 404 | 405 | if (!isNull(this.activeElement) && !isNull(this.activeElement.caret)) { 406 | 407 | let activeElement = this.activeElement; 408 | 409 | let worldContentRect = activeElement.worldContentBox.get(); 410 | let caret = activeElement.caret; 411 | 412 | buffer += moveTo({ x: worldContentRect.left + caret.x, y: worldContentRect.top + caret.y }); 413 | buffer += style.cursor.normal; 414 | 415 | } 416 | 417 | this._out.write(buffer); 418 | 419 | } 420 | 421 | _queueRedraw(redrawList) { 422 | 423 | while (redrawList.length > 0) { 424 | 425 | let redrawRect = new Rect(redrawList.shift()); 426 | let intersection = null; 427 | 428 | for (let t = 0, T = this._pending.length; t < T && isNull(intersection); ++t) 429 | intersection = redrawRect.intersection(this._pending[t]); 430 | 431 | if (intersection) { 432 | redrawList = redrawRect.exclude(intersection).concat(redrawList); 433 | } else { 434 | this._pending.push(redrawRect); 435 | } 436 | 437 | } 438 | 439 | } 440 | 441 | _focusRelativeElement(relativeOffset) { 442 | 443 | if (relativeOffset === 0) 444 | return ; 445 | 446 | let direction = relativeOffset < 0 ? -1 : +1; 447 | relativeOffset = Math.abs(relativeOffset); 448 | 449 | let nodeList = this.getNodeList(); 450 | let nodeIndex = nodeList.indexOf(this.activeElement); 451 | 452 | let next = function (base) { 453 | if (base === 0 && direction === -1) 454 | return nodeList.length - 1; 455 | 456 | if (base === nodeList.length - 1 && direction === 1) 457 | return 0; 458 | 459 | return base + direction; 460 | 461 | }; 462 | 463 | if (nodeIndex === -1) { 464 | 465 | if (direction > 0) { 466 | nodeList[0].focus(); 467 | } else { 468 | nodeList[nodeList.length - 1].focus(); 469 | } 470 | 471 | } else for (; relativeOffset !== 0; --relativeOffset) { 472 | 473 | let nextIndex = next(nodeIndex); 474 | 475 | while (nextIndex !== nodeIndex && !nodeList[nextIndex].activeStyle.focusable) 476 | nextIndex = next(nextIndex); 477 | 478 | if (nextIndex === nodeIndex) 479 | break ; 480 | 481 | nodeIndex = nextIndex; 482 | 483 | } 484 | 485 | nodeList[nodeIndex].focus(); 486 | 487 | } 488 | 489 | } 490 | -------------------------------------------------------------------------------- /sources/core/Element.js: -------------------------------------------------------------------------------- 1 | import extend from 'extend'; 2 | import { isNull } from 'lodash'; 3 | 4 | import { ClipBox } from './boxes/ClipBox'; 5 | import { ContentBox } from './boxes/ContentBox'; 6 | import { ElementBox } from './boxes/ElementBox'; 7 | import { ScrollBox } from './boxes/ScrollBox'; 8 | import { WorldBox } from './boxes/WorldBox'; 9 | import { KeySequence } from './utilities/KeySequence'; 10 | 11 | import { Event } from './Event'; 12 | import { Rect } from './Rect'; 13 | import { percentageRegexF } from './constants'; 14 | 15 | let elementUniqueId = 0; 16 | 17 | export class Element { 18 | 19 | /** 20 | */ 21 | 22 | constructor(style = {}) { 23 | 24 | this.name = null; 25 | this.id = ++elementUniqueId; 26 | 27 | this.style = extend(true, { position: `static`, width: `auto`, height: `auto`, backgroundCharacter: ` ` }, style); 28 | this.activeStyle = { flags: {} }; 29 | 30 | this.screenNode = null; 31 | this.parentNode = null; 32 | 33 | this.previousSibling = null; 34 | this.nextSibling = null; 35 | 36 | this.childNodes = []; 37 | 38 | this.scrollTop = 0; 39 | this.scrollLeft = 0; 40 | 41 | this.scrollWidth = 0; 42 | this.scrollHeight = 0; 43 | 44 | this.caret = null; 45 | 46 | this.elementBox = new ElementBox(this); 47 | this.contentBox = new ContentBox(this.elementBox); 48 | 49 | this.scrollElementBox = new ScrollBox(this.elementBox); 50 | this.scrollContentBox = new ScrollBox(this.contentBox); 51 | 52 | this.worldElementBox = new WorldBox(this.scrollElementBox); 53 | this.worldContentBox = new WorldBox(this.scrollContentBox); 54 | 55 | this.clipElementBox = new ClipBox(this.worldElementBox); 56 | this.clipContentBox = new ClipBox(this.worldContentBox); 57 | 58 | this._events = {}; 59 | 60 | this.declareEvent(`data`); 61 | this.declareEvent(`scroll`); 62 | this.declareEvent(`click`); 63 | this.declareEvent(`focus`); 64 | this.declareEvent(`blur`); 65 | 66 | this.refreshActiveStyles(); 67 | 68 | Object.defineProperty(this, `scrollWidth`, { 69 | 70 | get: () => this.childNodes.reduce((max, element) => { 71 | 72 | if (!element.activeStyle.flags.staticPositioning) 73 | return max; 74 | 75 | let childWidth = element.elementBox.getWidth(); 76 | return Math.max(max, childWidth); 77 | 78 | }, 0) 79 | 80 | }); 81 | 82 | Object.defineProperty(this, `scrollHeight`, { 83 | 84 | get: () => this.childNodes.reduce((sum, child) => { 85 | 86 | if (!child.activeStyle.flags.staticPositioning) 87 | return sum; 88 | 89 | let childHeight = child.contentBox.getHeight(); 90 | return sum + childHeight; 91 | 92 | }, 0) 93 | 94 | }); 95 | 96 | } 97 | 98 | /** 99 | */ 100 | 101 | toString() { 102 | 103 | let name = !isNull(this.name) ? this.name : `???`; 104 | let id = this.id; 105 | 106 | return `<${name}#${id}>`; 107 | 108 | } 109 | 110 | /** 111 | */ 112 | 113 | setStyleProperty(name, value) { 114 | 115 | let namespaces = name.split(/\./g); 116 | let property = namespaces.shift(); 117 | 118 | let what = this.style; 119 | 120 | while (namespaces.length > 0) { 121 | 122 | let namespace = namespaces.shift(); 123 | 124 | if (typeof what[namespace] !== `object` || what[namespace] === null) 125 | what[namespace] = {}; 126 | 127 | what = what[namespace]; 128 | 129 | } 130 | 131 | what[property] = value; 132 | 133 | this.refreshActiveStyles(); 134 | 135 | } 136 | 137 | /** 138 | */ 139 | 140 | setStyleProperties(style) { 141 | 142 | extend(true, this.style, style); 143 | 144 | this.refreshActiveStyles(); 145 | 146 | } 147 | 148 | /** 149 | * Add an element at the end of the childNodes array. 150 | * 151 | * If the child is already the child of another element, it will be removed from that other element before adding it to the new parent. 152 | */ 153 | 154 | appendChild(element) { 155 | 156 | if (element.parentNode) 157 | element.parentNode.removeChild(element); 158 | 159 | return this.applyElementBoxInvalidatingActions(true, true, () => { 160 | 161 | element.screenNode = this.screenNode; 162 | element.parentNode = this; 163 | 164 | element._cascadeScreenNode(); 165 | 166 | if (this.childNodes.length !== 0) { 167 | element.previousSibling = this.childNodes[this.childNodes.length - 1]; 168 | this.childNodes[this.childNodes.length - 1].nextSibling = element; 169 | } 170 | 171 | this.childNodes.push(element); 172 | 173 | if (!this.firstChild) 174 | this.firstChild = element; 175 | 176 | this.lastChild = element; 177 | 178 | if (this.screenNode) { 179 | this.screenNode.invalidateNodeList(); 180 | } 181 | 182 | }); 183 | 184 | } 185 | 186 | /** 187 | * Remove an element from the childNodes array. 188 | */ 189 | 190 | removeChild(element) { 191 | 192 | if (element.parentNode !== this) 193 | return this; 194 | 195 | return this.applyElementBoxInvalidatingActions(true, true, () => { 196 | 197 | let screenNode = element.screenNode; 198 | 199 | element.elementBox.invalidate(); 200 | 201 | if (this.lastChild === element) 202 | this.lastChild = element.previousSibling; 203 | 204 | if (this.firstChild === element) 205 | this.firstChild = element.nextSibling; 206 | 207 | if (element.previousSibling) 208 | element.previousSibling.nextSibling = element.nextSibling; 209 | 210 | if (element.nextSibling) 211 | element.nextSibling.previousSibling = element.previousSibling; 212 | 213 | element.screenNode = null; 214 | element.parentNode = null; 215 | 216 | element._cascadeScreenNode(); 217 | 218 | element.previousSibling = null; 219 | element.nextSibling = null; 220 | 221 | let index = this.childNodes.indexOf(element); 222 | this.childNodes.splice(index, 1); 223 | 224 | if (screenNode) { 225 | screenNode.invalidateNodeList(); 226 | } 227 | 228 | }); 229 | 230 | } 231 | 232 | /** 233 | */ 234 | 235 | scrollIntoView(anchor) { 236 | 237 | if (!this.parentNode) 238 | return this; 239 | 240 | let elementBox = this.elementBox.get(); 241 | let top = elementBox.top, height = elementBox.height; 242 | 243 | let parentScrollTop = this.parentNode.scrollTop; 244 | let parentHeight = this.parentNode.contentBox.get().height; 245 | 246 | if (top >= parentScrollTop && top + height < parentScrollTop + parentHeight) 247 | return this; 248 | 249 | if (typeof anchor === `undefined`) { 250 | if (top <= parentScrollTop) { 251 | anchor = `top`; 252 | } else { 253 | anchor = `bottom`; 254 | } 255 | } 256 | 257 | switch (anchor) { 258 | 259 | case `top`: 260 | this.parentNode.scrollTo(top); 261 | break ; 262 | 263 | case `bottom`: 264 | this.parentNode.scrollTo(top + height - parentHeight); 265 | break ; 266 | 267 | default: 268 | throw new Error(`Invalid scroll anchor`); 269 | 270 | } 271 | 272 | return this; 273 | 274 | } 275 | 276 | /** 277 | */ 278 | 279 | focus() { 280 | 281 | if (!this.screenNode || this.screenNode.activeElement === this) 282 | return this; 283 | 284 | this.screenNode.activeElement.blur(); 285 | 286 | this.screenNode.activeElement = this; 287 | this.refreshActiveStyles(); 288 | 289 | let event = new Event(`focus`, { target: this, cancelable: false }); 290 | this.dispatchEvent(event); 291 | 292 | return this; 293 | 294 | } 295 | 296 | /** 297 | */ 298 | 299 | blur() { 300 | 301 | if (!this.screenNode || this.screenNode.activeElement !== this) 302 | return this; 303 | 304 | this.screenNode.activeElement = this.screenNode; 305 | this.refreshActiveStyles(); 306 | 307 | let event = new Event(`blur`, { target: this, cancelable: false }); 308 | this.dispatchEvent(event); 309 | 310 | return this; 311 | 312 | } 313 | 314 | /** 315 | */ 316 | 317 | declareEvent(name) { 318 | 319 | if (this._events[name]) 320 | throw new Error(`Event already declared: ` + name); 321 | 322 | this._events[name] = { userActions: [], defaultActions: [] }; 323 | 324 | return this; 325 | 326 | } 327 | 328 | /** 329 | */ 330 | 331 | dispatchEvent(event) { 332 | 333 | let name = event.name; 334 | 335 | if (!this._events[name]) 336 | throw new Error(`Invalid event name "${name}"`); 337 | 338 | let listeners = this._events[name]; 339 | 340 | event.currentTarget = this; 341 | 342 | for (let t = 0, T = listeners.userActions.length; t < T; ++t) 343 | listeners.userActions[t].call(this, event); 344 | 345 | for (let t = 0, T = listeners.defaultActions.length; t < T && !event.isDefaultPrevented(); ++t) 346 | listeners.defaultActions[t].call(this, event); 347 | 348 | return this; 349 | 350 | } 351 | 352 | /** 353 | */ 354 | 355 | addEventListener(name, callback, { isDefaultAction } = {}) { 356 | 357 | if (!this._events[name]) 358 | throw new Error(`Invalid event name "${name}"`); 359 | 360 | if (isDefaultAction) { 361 | this._events[name].defaultActions.unshift(callback); 362 | } else { 363 | this._events[name].userActions.push(callback); 364 | } 365 | 366 | return this; 367 | 368 | } 369 | 370 | /** 371 | */ 372 | 373 | addShortcutListener(descriptor, callback, options) { 374 | 375 | let sequence = new KeySequence(descriptor); 376 | 377 | this.addEventListener(`data`, e => { 378 | 379 | if (!e.key) 380 | return; 381 | 382 | if (!sequence.match(e.key)) 383 | return; 384 | 385 | callback.call(this, e); 386 | 387 | }, options); 388 | 389 | return this; 390 | 391 | } 392 | 393 | /** 394 | */ 395 | 396 | applyElementBoxInvalidatingActions(invalidateX, invalidateY, invalidatingActionsCallback) { 397 | 398 | // "topMostInvalidation" contains a reference to the top-most invalidated element 399 | // "invalidatedElement" contains the full set of invalidated elements 400 | 401 | let topMostInvalidation = this; 402 | let invalidatedElements = new Set(); 403 | 404 | // First step, we push the element itself 405 | 406 | invalidatedElements.add(this); 407 | 408 | // Second step, we push every direct flexible-size parents of the element, because their boxes might change because of us 409 | 410 | for (let element = this; element; element = element.parentNode) { 411 | 412 | if (!element.activeStyle.flags.staticPositioning) 413 | break; 414 | 415 | topMostInvalidation = element; 416 | invalidatedElements.add(element); 417 | 418 | } 419 | 420 | // Third step, we`re preparing a rendering of the top-most invalidated element 421 | // (If the element shrinks, then having prepared a redraw allow us to easily remove the extraneous space) 422 | 423 | if (this.screenNode) 424 | this.screenNode.prepareRedrawRect(topMostInvalidation.clipElementBox.get()); 425 | 426 | // Fourth step, we now execute every action that could invalidate the box of our elements 427 | 428 | let ret = invalidatingActionsCallback(); 429 | 430 | // Fifth step, each invalidated element also has to invalidate its children using relative sizes - recursively 431 | // Wanna know why we`re doing it here rather than in fourth step? It`s because the "invalidating actions" may have be to add (or remove) a child! 432 | 433 | let invalidateChildren = function (currentInvalidationPass) { 434 | 435 | let invalidatedChildren = new Set(); 436 | 437 | for (let element of currentInvalidationPass) { 438 | for (let child of element.childNodes) { 439 | invalidatedElements.add(child); 440 | invalidatedChildren.add(child); 441 | } 442 | } 443 | 444 | return invalidatedChildren; 445 | 446 | }; 447 | 448 | for (let currentPass = invalidatedElements; currentPass.size > 0;) 449 | currentPass = invalidateChildren(currentPass); 450 | 451 | // Sixth step, we can now actually invalidate the elements 452 | 453 | for (let element of invalidatedElements) 454 | element.elementBox.invalidate(); 455 | 456 | // Seventh step, we`re preparing a rendering of the top-most invalidated element (we`re doing it another time on top of the one we scheduled in the third step, because our element might have grown) 457 | // Don`t forget to invalidate the render list: if the element shrinks, then previously hidden elements may be revealed 458 | 459 | if (this.screenNode) { 460 | this.screenNode.invalidateRenderList(); 461 | this.screenNode.prepareRedrawRect(topMostInvalidation.clipElementBox.get()); 462 | } 463 | 464 | return ret; 465 | 466 | } 467 | 468 | /** 469 | */ 470 | 471 | prepareRedraw(contentRect = this.contentBox.get()) { 472 | 473 | if (!this.screenNode) 474 | return; 475 | 476 | if (!isNull(contentRect)) { 477 | 478 | this.contentBox.setStub(contentRect); 479 | let clipRect = this.clipContentBox.get(); 480 | this.contentBox.setStub(null); 481 | 482 | this.screenNode.prepareRedrawRect(clipRect); 483 | 484 | } else { 485 | 486 | this.screenNode.prepareRedrawRect(null); 487 | 488 | } 489 | 490 | } 491 | 492 | /** 493 | */ 494 | 495 | renderElement(x, y, l) { 496 | 497 | throw new Error(`renderElement is not implemented`); 498 | 499 | } 500 | 501 | /** 502 | */ 503 | 504 | renderContent(x, y, l) { 505 | 506 | throw new Error(`renderContent is not implemented`); 507 | 508 | } 509 | 510 | /** 511 | */ 512 | 513 | refreshActiveStyles() { 514 | 515 | let style = extend(true, {}, this.style); 516 | 517 | if (this.screenNode && this.screenNode.activeElement === this) 518 | extend(true, style, style.active); 519 | 520 | if (style.position === `static` || style.position === `relative`) 521 | style.left = style.right = style.top = style.bottom = null; 522 | 523 | if (style.left != null && style.right != null) 524 | style.width = style.minWidth = style.maxWidth = null; 525 | 526 | if (style.top != null && style.bottom != null) 527 | style.height = style.minHeight = style.maxHeight = null; 528 | 529 | if (style.width === `auto`) 530 | style.width = `100%`; 531 | 532 | if (style.height === `auto`) 533 | style.height = `adaptive`; 534 | 535 | extend(true, style, { flags: { 536 | isVisible: style.display !== `none`, 537 | staticPositioning: style.position === `static` || style.position === `relative`, 538 | absolutePositioning: style.position === `absolute` || style.position === `fixed`, 539 | parentRelativeWidth: (style.left != null && style.right != null) || [ style.left, style.right, style.width, style.minWidth, style.maxWidth ].some(value => percentageRegexF.test(value)), 540 | parentRelativeHeight: (style.top != null && style.bottom != null) || [ style.top, style.bottom, style.height, style.minHeight, style.maxHeight ].some(value => percentageRegexF.test(value)), 541 | hasAdaptiveWidth: style.width === `adaptive`, 542 | hasAdaptiveHeight: style.height === `adaptive` 543 | } }); 544 | 545 | this._switchActiveStyle(style); 546 | 547 | } 548 | 549 | /** 550 | * Change the style of the element. If required, reset some internal properties in order to compute them again later. 551 | * 552 | * - left/right/width require an update of the element boxes X axis 553 | * - top/bottom/height require an update of the element boxes Y axis 554 | * - border require an update of all the element boxes 555 | * - zIndex changes require to refresh the screen render list (in order to sort it again) 556 | */ 557 | 558 | _switchActiveStyle(newActiveStyle) { 559 | 560 | let changed = property => newActiveStyle[property] !== this.activeStyle[property]; 561 | 562 | let dirtyContentBox = [ `border`, `ch`, `textAlign` ].some(changed); 563 | let dirtyElementBoxesX = [ `position`, `left`, `right`, `width`, `minWidth`, `maxWidth` ].some(changed); 564 | let dirtyElementBoxesY = [ `position`, `top`, `bottom`, `height`, `minHeight`, `maxHeight` ].some(changed); 565 | let dirtyRenderList = [ `display`, `zIndex` ].some(changed); 566 | 567 | let actions = () => { this.activeStyle = newActiveStyle; }; 568 | this.applyElementBoxInvalidatingActions(true, true, actions); 569 | 570 | if (this.screenNode && dirtyRenderList) { 571 | this.screenNode.invalidateRenderList(); 572 | } 573 | 574 | } 575 | 576 | _cascadeScreenNode() { 577 | 578 | for (let child of this.childNodes) { 579 | child.screenNode = this.screenNode; 580 | child._cascadeScreenNode(); 581 | } 582 | 583 | } 584 | 585 | } 586 | --------------------------------------------------------------------------------