Source: term_state.js

var myUtil = require('./util.js');
var inherits = require('util').inherits;

/**
* map of graphical character aliases
* @enum
* @private
*/
var graphics = {
        '`': '\u25C6',
        'a': '\u2592',
        'b': '\u2409',
        'c': '\u240C',
        'd': '\u240D',
        'e': '\u240A',
        'f': '\u00B0',
        'g': '\u00B1',
        'h': '\u2424',
        'i': '\u240B',
        'j': '\u2518',
        'k': '\u2510',
        'l': '\u250C',
        'm': '\u2514',
        'n': '\u253C',
        'o': '\u23BA',
        'p': '\u23BB',
        'q': '\u2500',
        'r': '\u23BC',
        's': '\u23BD',
        't': '\u251C',
        'u': '\u2524',
        'v': '\u2534',
        'w': '\u252C',
        'x': '\u2502',
        'y': '\u2264',
        'z': '\u2265',
        '{': '\u03C0',
        '|': '\u2260',
        '}': '\u00A3',
        '~': '\u00B7'
};

/**
* Creates setter for a specific property used on attributes, modes, and meta
* properties
* @private
*/
function setterFor(objName) {
	return function(name, value) {
		if('_'+objName+'sCow' in this) {
			if(this['_'+objName+'sCow'] === true)
				this['_'+objName+'s'] = myUtil.extend({}, this['_'+objName+'s']);
			this['_'+objName+'sCow'] = false;
		}
		var obj = this['_'+objName+'s'];

		if(!(name in obj))
			throw new Error('Unknown '+objName+' `'+name+'`');
		this.emit(objName+'change', name, value, obj[name]);
		obj[name] = value;
	};
}

/**
* A class which holds the terminals state and content
* @param {number} columns - number of columns in the terminal
* @param {number} rows - number of rows in the terminal
* @param {object} attributes - initial attributes of the terminal
* @param {string} [attributes.fg=null] initial foreground color
* @param {string} [attributes.bg=null] initial background color
* @param {boolean} [attributes.bold=false] terminal is bold by default
* @param {boolean} [attributes.underline=false] terminal is underlined by default
* @param {boolean} [attributes.italic=false] terminal is italic by default
* @param {boolean} [attributes.blink=false] terminal blinks by default
* @param {boolean} [attributes.inverse=false] terminal has reverse colors by default
* @constructor
*/
function TermState(options) {
	TermState.super_.call(this, {
		decodeStrings: false
	});
	options = myUtil.extend({
		attributes: {},
	}, options);
	this._defaultAttr = myUtil.extend({
		fg: null,
		bg: null,
		bold: false,
		underline: false,
		italic: false,
		blink: false,
		inverse: false
	}, options.attributes);
	this._attributesCow = true;

	this.rows = options.rows || 24;
	this.columns = options.columns || 80;

	this
		.on('newListener', this._newListener)
		.on('removeListener', this._removeListener)
		.on('pipe', this._pipe);
	// Reset all on first use
	this.reset();
}
inherits(TermState, require('stream').Writable);
module.exports = TermState;

/**
* emits resize on the reader of this class
* @param {ReadableStream} a Readable Stream
* @private
*/
TermState.prototype._pipe = function(src) {
	var onresize = src.emit.bind(src, 'resize');
	this.on('resize', onresize)
		.on('unpipe', function(src) {
			src.removeListener(onresize);
		});
};

/**
* tells a new listener the terminals state when it is registered
* @param {string} ev - event name
* @param {function} cb - the listening function
* @private
*/
TermState.prototype._newListener = function(ev, cb) {
	var i;
	switch(ev) {
		case 'lineinsert':
			for(i = 0; i < this.getBufferRowCount(); i++)
				cb.call(this, i, this.getLine(i));
			break;
		case 'resize':
			cb.call(this, { columns: this.columns, rows: this.rows });
			break;
		case 'cursormove':
			cb.call(this, this.cursor.x, this.cursor.y);
			break;
	}
};

/**
* cleans up listener when it is removed from the terminal state
* @param {string} ev - event name
* @param {function} cb - the listening function
* @private
*/
TermState.prototype._removeListener = function(ev, cb) {
	var i;
	if(ev === 'lineremove') {
		for(i = 0; i < this.getBufferRowCount(); i++)
			cb.call(this, 0, this.getLine(i));
	}
};

/**
* resets the terminals state.
*/
TermState.prototype.reset = function() {
	if(this._buffer)
		this._removeLine(0, this.getBufferRowCount());
	this._buffer = this._defBuffer = {
		str: [], attr: []
	};
	this._altBuffer = {
		str: [], attr: []
	};
	this._modes = {
		cursor: true,
		cursorBlink: false,
		appKeypad: false,
		wrap: true,
		insert: false,
		crlf: false,
		mousebtn: false,
		mousemtn: false,
		reverse: false,
		graphic: false
	};
	this._metas = {
		title: '',
		icon: ''
	};
	this.resetAttribute();
	this.cursor = {x:0,y:0};
	this._savedCursor = {x:0,y:0};
	this._scrollRegion = [0, this.rows-1];
	this.resetLeds();
	this._tabs = [];

	this._lineAttr = {
		doubletop: false,
		doublebottom: false,
		doublewidth: false
	};

};

/**
* creates a new line in the buffer
* @param [line] - build line upon this value
* @private
*/
TermState.prototype._createLine = function(line) {
	if(line === undefined || typeof line !== 'object') {
		line = line ? line.toString() : '';
		line = { str: line, attr: {0: this._defaultAttr} };
	}
	else if(!line || typeof line.str !== 'string' || typeof line.attr !== 'object')
		throw new Error('line objects must contain attr and str' + line);

	for(var i in line.attr) {
		if(+i > line.str.length || line.attr[i] === undefined)
			delete line.attr[i];
	}
	return line;
};

/**
* @deprecated since 0.2
* @see write
*/
TermState.prototype.inject = function(str) {
	console.warn('inject() is deprecated. use write() instead.');
	this.write(str);
};

/**
* Takes a chunk of data and puts it in the buffer
* @alias TermState.prototype.write
* @see http://nodejs.org/docs/latest/api/stream.html#stream_writable_write_chunk_encoding_callback
*/
TermState.prototype._write = function(chunk, encoding, callback) {
	var i, j, line;
	var lines = chunk.split('\n');
	var wrapped;
	var c = this.cursor;

	for(i = 0; i < lines.length; i++) {
		wrapped = false;
		// Handle long lines
		if(lines[i].length >= this.columns - c.x) {
			if(c.x >= this.columns)
				c.x = this.columns - 1;
			if(this._modes.wrap) {
				lines.splice(i, 1,
					lines[i].substr(0, this.columns - c.x),
					lines[i].substr(this.columns - c.x)
				);
				wrapped = true;
			}
			else {
				lines[i] = lines[i].substr(0, this.columns - c.x - 1) +
					lines[i].substr(-1);
			}
		}

		// write line
		this._lineInject(lines[i]);

		if(i + 1 !== lines.length) {
			c.y++;
			if(this._modes.crlf || wrapped)
				c.x = 0;

			if(c.y > this._scrollRegion[1]) {
				c.y--;
				this._removeLine(this._scrollRegion[0]);
				this._insertLine(this._scrollRegion[1]);
			}
		}
	}
	this.setCursor();
	return callback();
};

/**
* converts graphics from ascii to utf8 characters when in graphics mode.
* @private
*/
TermState.prototype._graphConvert = function(content) {
	if(this._modes.graphic) {
		var result = '';
		for(i = 0; i < content.length; i++) {
			result += (content[i] in graphics) ?
				graphics[content[i]] :
				content[i];
		}
		return result;
	} else  {
		return content;
	}
};

/**
* injects a single line into the buffer.
* @see _write
* @private
*/
TermState.prototype._lineInject = function(content) {
	var c = this.cursor;
	var line = this.getLine();
	if(this._modes.insert) {
		var args = Array(content.length);
		args.unshift(line.attr, line.str.length+1, c.x, 0);
		myUtil.objSplice.apply(0, args);
		line.str = line.str.substr(0, c.x) + myUtil.repeat(' ',c.x - line.str.length) +
			this._graphConvert(content) + line.str.substr(c.x);
		line.str = line.str.substr(0, this.columns);
	}
	else {
		line.str = line.str.substr(0, c.x) +
			myUtil.repeat(' ', c.x - line.str.length) +
			this._graphConvert(content) + line.str.substr(c.x + content.length);
	}

	this._applyAttributes(line, c.x, content.length);
	this.setLine(line);

	c.x += content.length;
};

/**
* removes characters at cursor position.
* @params {number} count - number of characters to be removed
*/
TermState.prototype.removeChar = function(count) {
	var c = this.cursor, line = this.getLine(c.y);
	var last = line.attr[line.str.length];
	myUtil.objSplice(line.attr, line.str.length+1, c.x, count);
	line.str = line.str.substr(0, c.x) + line.str.substr(c.x+count);
	line.attr[line.str.length] = last;
	this.setLine(c.y, line);
};

/**
* inserts whitespaces at cursor position
* @params {number} count - number of whitespaces to be inserted
*/
TermState.prototype.insertBlank = function(count) {
	var c = this.cursor, line = this.getLine(c.y);
	var last = line.attr[line.str.length];
	myUtil.objSplice(line.attr, line.str.length+1, c.x, 0, Array(count));
	line.str = line.str.substr(0, c.x) +
		myUtil.repeat(' ', count) + line.str.substr(c.x+count);
	line.attr[line.str.length] = last;
	this.setLine(c.y, line);
};

/**
* removes lines at cursor position.
* @params {number} count - number of lines to be removed
*/
TermState.prototype.removeLine = function(count) {
	this._removeLine(this.cursor.y, +count);
	if(this._scrollRegion[1] !== this.rows-1 && this.cursor.y <= this._scrollRegion[1])
		this._insertLine(this._scrollRegion[1] + 1 - count, +count);
};

/**
* removes lines at given position
* @params {number} line - line number to start removing
* @params {number} count - number of lines to be removed
* @private
*/
TermState.prototype._removeLine = function(line, count) {
	var i;
	if(count === undefined)
		count = 1;
	var str = this._buffer.str.splice(line, count);
	var attr = this._buffer.attr.splice(line, count);
	for(i = 0; i < str.length; i++)
		this.emit('lineremove', line, {str: str[i], attr: attr[i] });
	return count;
};

/**
* sets the line to a value and emits 'linechanged' event
* @params {number} nbr - line number to set
* @params {object} line - line content to set
*/
TermState.prototype.setLine = function(nbr, line) {
	if(typeof nbr === 'object' && line === undefined) {
		line = nbr;
		nbr = this.cursor.y;
	}
	line = this._createLine(line);
	if(this._buffer.str.length <= nbr) {
		this._insertLine(nbr, line);
	}
	else {
		if(line.str.length > this.columns)
			line.attr[this.columns] = line.attr[line.str.length];
		this._buffer.str[nbr] = line.str.substr(0, this.columns);
		this._buffer.attr[nbr] = line.attr;
		this.emit('linechange', nbr, line);
	}
};

/**
* inserts lines at cursor position
* @params {number} count - number of lines to insert
*/
TermState.prototype.insertLine = function(count) {
	this._insertLine(this.cursor.y, +count);
};

/**
* inserts lines at given position
* @params {number} line - line number to start inserting
* @params {number} count - number of lines to be inserted
* @private
*/
TermState.prototype._insertLine = function(nbr, line) {
	var h = this.getBufferRowCount();
	var start = Math.min(h, nbr);
	var end = nbr + 1;
	if(typeof line === 'number') {
		end = nbr + line;
		line = undefined;
	}

	for(i = start; i < end; i++) {
		if(this.rows === this.getBufferRowCount())
			this._removeLine(this._scrollRegion[1], 1);
		line = this._createLine(line);
		this._buffer.str.splice(start, 0, line.str);
		this._buffer.attr.splice(start, 0, line.attr);
		this.emit('lineinsert', start, line);
		line = undefined;
	}
};

/**
* TODO
* @private
*/
TermState.prototype._applyAttributes = function(line, index, len) {
	var i, prev;

	for(i = index+len; i > 0 && line.attr[i] === undefined; i--);
	prev = line.attr[i];
	for(i = index; i < index+len; i++)
		delete line.attr[i];

	line.attr[index] = this._attributes;
	if(index + len <= this.columns)
		line.attr[index + len] = prev;

	this._attributesCow = true;
	return this;
};

/**
* sets cursor to a specific position
* @param {number} x - column of cursor starting at 0
* @param {number} y - row of cursor starting at 0
*/
TermState.prototype.setCursor = function(x, y) {
	var c = this.cursor, line;

	if(typeof x !== 'number')
		x = c.x;
	if(typeof y !== 'number')
		y = c.y;

	if(x < 0)
		x = 0;
	else if(x >= this.columns)
		x = this.columns - 1;

	if(y < 0)
		y = 0;
	else if(y >= this.rows)
		y = this.rows - 1;

	if(c.x !== x || c.y !== y || arguments.length === 0) {
		c.x = x;
		c.y = y;

		this.emit('cursormove', x, y);
	}

	return this;
};

/**
* resizes terminal to a specific dimension
* @param {object} size - new size of the terminal
*/
TermState.prototype.resize = function(size) {
	var line;
	this._removeLine(0, Math.max(0, this.rows - size.rows));

	this.rows = size.rows;
	this.columns = size.columns;

	for(var i = 0; i < this._buffer.str.length; i++)
		this.setLine(i, this.getLine(i));

	this.setScrollRegion(0, this.rows-1);

	this.emit('resize', size);

	this.setCursor();
	return this;
};

/**
* moves cursor relative
* @param {number} x - relative horizontal movement
* @param {number} y - relative vertical movement
*/
TermState.prototype.mvCursor = function(x, y) {
	if(x || y)
		this.setCursor(this.cursor.x + x, this.cursor.y + y);
	return this;
};

/**
* scrolls the scroll area of a buffer
* @param {number} scroll - number of lines to be scrolled (positive: up; negative: down)
*/
TermState.prototype.scroll = function(scroll) {
	var i;
	var count = Math.min(Math.abs(scroll), this._scrollRegion[1] - this._scrollRegion[0]);

	if(scroll > 0) {
		this._removeLine(this._scrollRegion[0], count);
		for(i = 0; i < count; i++) {
			this._insertLine(this._scrollRegion[1] +1  - count);
		}
	}
	else {
		this._removeLine(this._scrollRegion[1] +1 -count, count);
		for(i = 0; i < count; i++) {
			this._insertLine(this._scrollRegion[0]);
		}
	}
};

/**
* returns plain text representation of the buffer
*/
TermState.prototype.toString = function() {
	return this._buffer.str.join('\n');
};

/**
* moves cursor to previous line or scrolls up if at top
*/
TermState.prototype.prevLine = function() {
		if(this.cursor.y === this._scrollRegion[0])
			this.scroll(-1);
		else
			this.mvCursor(0, -1);
		return this;
};

/**
* moves cursor to next line or scrolls down if at bottom
*/
TermState.prototype.nextLine = function() {
		if(this.cursor.y === this._scrollRegion[1])
			this.scroll(1);
		else
			this.mvCursor(0, 1);
		return this;
};

/**
* resets the attributes
*/
TermState.prototype.resetAttribute = function(name) {
	if(name)
		this.setAttribute(name, this._defaultAttr[name]);
	else {
		this._attributesCow = true;
		this._attributes = this._defaultAttr;
	}
	return this;
};

/**
* saves cursor position
*/
TermState.prototype.saveCursor = function() {
	this._savedCursor.x = this.cursor.x;
	this._savedCursor.y = this.cursor.y;
	return this;
};

/**
* restore previously saved cursor position
*/
TermState.prototype.restoreCursor = function() {
	return this.setCursor(this._savedCursor.x, this._savedCursor.y);
};

/**
* truncate characters from buffer at cursor position.
* @param {number} count number of characters to truncate
*/
TermState.prototype.eraseCharacters = function(count) {
	var c = this.cursor, line = this.getLine(c.y);

	line.str = line.str.substr(0, c.x) + myUtil.repeat(' ', count) +
		line.str.substr(c.x + count);
	line.str = line.str.substr(0, this.columns);
	this._applyAttributes(line, c.x, count);
	this.setLine(c.y, line);
};

/**
* cleans lines
* @param n can be one of the following:
* <ul>
* 	<li>0 or "after": cleans below and after cursor</li>
* 	<li>1 or "before": cleans above and before cursor</li>
* 	<li>2 or "all": cleans entire screen</li>
* </ul>
*/
TermState.prototype.eraseInDisplay = function(n) {
	var c = this.cursor, i, line, self = this;
	var chLine = function() {
		line = self._createLine();
		self._applyAttributes(line, 0, self.columns);
		self.setLine(i, line);
	};
	switch(n || 0) {
		case 'below':
		case 'after':
		case 0:
			n = 0;
			for(i = c.y+1; i < this.rows; i++)
				chLine();
			break;
		case 'above':
		case 'before':
		case 1:
			n = 1;
			for(i = 0; i < c.y-1; i++)
				chLine();
			break;
		case 'all':
		case 2:
			for(i = 0; i < this.rows; i++)
				chLine();
			return this;
	}
	return this.eraseInLine(n);
};

/**
* cleans one line
* @param n can be one of the following:
* <ul>
* 	<li>0 or "after": cleans from the cursor to the end of the line</li>
* 	<li>1 or "before": cleans from the start of the line to the cursor</li>
* 	<li>2 or "all": cleans entire screen</li>
* </ul>
*/
TermState.prototype.eraseInLine = function(n) {
	var c = this.cursor;
	var line = this.getLine();
	switch(n || 0) {
		case 'after':
		case 0:
			line.str = line.str.substr(0, c.x);
			this._applyAttributes(line, c.x, this.columns);
			break;
		case 'before':
		case 1:
			line.str = myUtil.repeat(' ',c.x) + line.str.substr(c.x);
			this._applyAttributes(line, 0, c.x);
			break;
		case 'all':
		case 2:
			l = this._createLine();
			break;
	}
	this.setLine(c.y, line);
	return this;
};

/**
* sets scroll region
*/
TermState.prototype.setScrollRegion = function(n, m) {
	this._scrollRegion[0] = +n;
	this._scrollRegion[1] = +m;
	return this;
};

/**
* switches between default and alternative buffer
* @param alt {boolean} true for switch to alternative buffer, false for default
* buffer
*/
TermState.prototype.switchBuffer = function(alt) {
	var i;
	var active, inactive;
	if(alt) {
		active = this._altBuffer;
		inactive = this._defBuffer;
	}
	else {
		active = this._defBuffer;
		inactive = this._altBuffer;
	}
	if(active === this._buffer)
		return;

	for(i = active.length; i < inactive.length; i++)
		this.emit('lineremove', active.length, this.getLine(i));

	this._buffer = active;

	for(i = 0; i < active.length && i < inactive.length; i++)
		this.emit('linechange', active.length, this.getLine(i));

	for(; i < active.length; i++)
		this.emit('lineinsert', i, this.getLine(i));
	return this;
};

/**
* enables a LED
* @param led {number} LED 0 - 3
*/
TermState.prototype.ledOn = function(led) {
	this.setLed(led, true);
	return this;
};

/**
* enables a LED
* @param led {number} LED 0 - 3
* @param value {boolean} sets LED to value
*/
TermState.prototype.setLed = function(led, value) {
	if (led < this._leds.length) { // we only have 4 leds (0,1,2,3)
		this._leds[led] = !!value;
		this.emit('ledchange', Array.apply(null, this._leds));
	}
	return this;
};

/**
* disables all LEDs
*/
TermState.prototype.resetLeds = function() {
	this._leds = [!!0,!!0,!!0,!!0];
	this.emit('ledchange', Array.apply(null, this._leds));
	return this;
};

/**
* gets the internal buffer row count. Will be lesser equal than actual number of
* rows
*/
TermState.prototype.getBufferRowCount = function() {
	return this._buffer.str.length;
};

/**
* gets the current value of an LED
* @param led {number} LED 0 - 3
* @returns true if LED is enabled, false otherwise
*/
TermState.prototype.getLed = function(n) {
	return this._leds[n];
};

/**
* gets the line definition
* @param n {number} - line number starting at 0
* @returns line definition
*/
TermState.prototype.getLine = function(n) {
	if(n === undefined)
		n = this.cursor.y;

	if(this._buffer.str[n])
		return {
			str: this._buffer.str[n],
			attr: this._buffer.attr[n]
		};
	else
		return this._createLine();
};

/**
* returns the current value of a given mode
* @param n {string} - mode
*/
TermState.prototype.getMode = function(n) {
	return this._modes[n];
};


/**
* moves Cursor forward or backward a specified amount of tabs
* @param n {number} - number of tabs to move. <0 moves backward, >0 moves
* forward
*/
TermState.prototype.mvTab = function(n) {
	var x = this.cursor.x;
	var tabMax = this._tabs[this._tabs.length - 1] || 0;
	var positive = n > 0;
	n = Math.abs(n);
	while(n !== 0 && x > 0 && x < this.columns-1) {
		x += positive ? 1 : -1;
		if(~myUtil.indexOf(this._tabs, x) || (x > tabMax && x % 8 === 0))
			n--;
	}
	this.setCursor(x);
};

/**
* set tab at specified position
* @param pos {number} - position to set a tab at
*/
TermState.prototype.setTab = function(pos) {
	// Set the default to current cursor if no tab position is specified
	if(pos === undefined) {
		pos = this.cursor.x;
	}
	// Only add the tab position if it is not there already
	if (~myUtil.indexOf(this._tabs, pos)) {
		this._tabs.push(pos);
		this._tabs.sort();
	}
};

/**
* remove a tab
* @param pos {number} - position to remove a tab. Do nothing if the tab isn't
* set at this position
*/
TermState.prototype.removeTab = function(pos) {
	var i, tabs = this._tabs;
	for(i = 0; i < tabs.length && tabs[i] !== pos; i++);
	tabs.splice(index,1);
};

/**
* removes a tab at a given index
* @params n {number} - can be one of the following
* <ul>
* 	<li>"current" or 0: searches tab at current position. no tab is at current
* 	position delete the next tab</li>
* 	<li>"all" or 3: deletes all tabs</li>
*/
TermState.prototype.tabClear = function(n) {
	switch(n || 'current') {
		case 'current':
		case 0:
			for(var i = this._tabs.length - 1; i >= 0; i--) {
				if(this._tabs[i] < this.cursor.x) {
					this._tabs.splice(i, 1);
					break;
				}
			}
			break;
		case 'all':
		case 3:
			this._tabs = [];
			break;
	}
};

/**
* sets a given Attribute
*/
TermState.prototype.setAttribute = setterFor('attribute');
/**
* sets a given Mode
*/
TermState.prototype.setMode = setterFor('mode');
/**
* sets a given Meta date
*/
TermState.prototype.setMeta = setterFor('meta');