diff --git a/README.md b/README.md index 9e28343..fb2705d 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ The base element. (`0-100%`), or keyword (`half` or `shrink`). - **left, right, top, bottom** - offsets of the element **relative to its parent**. can be a number, percentage (`0-100%`), or keyword (`center`). + `right` and `bottom` do not accept keywords. ##### Properties: diff --git a/lib/widget.js b/lib/widget.js index 978cd69..4c96f52 100644 --- a/lib/widget.js +++ b/lib/widget.js @@ -9,6 +9,7 @@ */ var EventEmitter = require('events').EventEmitter + , assert = require('assert') , path = require('path') , fs = require('fs') , colors = require('./colors') @@ -150,6 +151,39 @@ Node.prototype.detach = function() { if (this.parent) this.parent.remove(this); }; +Node.prototype.forDescendants = function(iter, s) { + if (s) iter(this); + el.children.forEach(function emit(el) { + iter(el); + el.children.forEach(emit); + }); +}; + +Node.prototype.forAncestors = function(iter, s) { + var el = this; + if (s) iter(this); + while (el = el.parent) { + iter(el); + el.emit.apply(el, args); + } +}; + +Node.prototype.collectDescendants = function(s) { + var out = []; + this.forDescendants(function(el) { + out.push(el); + }, s); + return out; +}; + +Node.prototype.collectAncestors = function(s) { + var out = []; + this.forAncestors(function(el) { + out.push(el); + }, s); + return out; +}; + Node.prototype.emitDescendants = function() { var args = Array.prototype.slice(arguments) , iter; @@ -158,31 +192,28 @@ Node.prototype.emitDescendants = function() { iter = args.pop(); } - (function emit(el) { + return this.forDescendants(function(el) { if (iter) iter(el); el.emit.apply(el, args); - if (el.children) { - el.children.forEach(emit); - } - })(this); + }, true); }; Node.prototype.emitAncestors = function() { var args = Array.prototype.slice(arguments) - , el = this , iter; if (typeof args[args.length-1] === 'function') { iter = args.pop(); } - do { + return this.forAncestors(function(el) { if (iter) iter(el); el.emit.apply(el, args); - } while (el = el.parent); + }, true); }; Node.prototype.hasDescendant = function(target) { + // return this.collectDescendants().indexOf(target) !== -1; return (function find(el) { for (var i = 0; i < el.children.length; i++) { if (el.children[i] === target) { @@ -197,6 +228,7 @@ Node.prototype.hasDescendant = function(target) { }; Node.prototype.hasAncestor = function(target) { + // return this.collectAncestors().indexOf(target) !== -1; var el = this; while (el = el.parent) { if (el === target) return true; @@ -1557,7 +1589,6 @@ Element.prototype.hide = function() { Element.prototype.show = function() { if (!this.hidden) return; this.hidden = false; - // this.render(); this.emit('show'); }; @@ -1602,11 +1633,14 @@ Element.prototype.parseContent = function() { this._clines = this._wrapContent(content, width); this._clines.width = width; this._clines.content = this.content; + this._clines.attr = this._parseAttr(this._clines); this._pcontent = this._clines.join('\n'); this.emit('parsed content'); return true; } + this._clines.attr = this._parseAttr(this._clines) || this._clines.attr; + return false; }; @@ -1629,6 +1663,38 @@ Element.prototype._parseTags = function(text) { }); }; +Element.prototype._parseAttr = function(lines) { + var attr = this.sattr(this.style, this.style.fg, this.style.bg) + , attrs = [] + , line + , i + , j + , c; + + if (lines[0].attr === attr) { + return; + } + + if (this.contentIndex == null || this.childBase == null) { + return; + } + + for (j = 0; j < lines.length; j++) { + line = lines[j]; + attrs[j] = attr; + for (i = 0; i < line.length; i++) { + if (line[i] === '\x1b') { + if (c = /^\x1b\[[\d;]*m/.exec(line.substring(i))) { + attr = this.screen.attrCode(c[0], attr); + i += c[0].length - 1; + } + } + } + } + + return attrs; +}; + Element.prototype._align = function(line, width, align) { if (!align) return line; @@ -1798,241 +1864,223 @@ Element.prototype.clearPos = function() { * Positioning */ -// Help center shrunken nested elements. -Screen.prototype.spos = -Element.prototype.spos = function() { +// The below methods are a bit confusing: basically +// whenever Box.render is called `lpos` gets set on +// the element, an object containing the rendered +// coordinates. Since these don't update if the +// element is moved somehow, they're unreliable in +// that situation. However, if we can guarantee that +// lpos is good and up to date, it can be more +// accurate than the calculated positions below. +// In this case, if the element is being rendered, +// it's guaranteed that the parent will have been +// rendered first, in which case we can use the +// parant's lpos instead of recalculating it's +// position (since that might be wrong because +// it doesn't handle content shrinkage). + +Screen.prototype._getPos = function() { + return this; +}; + +Element.prototype._getPos = function() { var pos = this.lpos; - if (!pos || !this.shrink) return this; - pos.width = pos.xl - pos.xi; - pos.height = pos.yl - pos.yi; + + assert.ok(pos && !pos.changed); + + if (pos.left != null) return pos; + pos.left = pos.xi; pos.top = pos.yi; pos.right = this.screen.cols - pos.xl; pos.bottom = this.screen.rows - pos.yl; + pos.width = pos.xl - pos.xi; + pos.height = pos.yl - pos.yi; + return pos; }; -// NOTE: When coords are entered in the Element constructor, all of the coords -// are *relative* to their parent, when retrieving them from `.left`, `.right`, -// etc members, the coords are absolute. To see the *relative* coords again, -// use `.rleft`, `.rright`, etc. - -Element.prototype.__defineGetter__('left', function() { - var left = this.position.left; - - var parent = this.parent.spos(); - - if (typeof left === 'string') { - if (left === 'center') left = '50%'; - left = +left.slice(0, -1) / 100; - left = this.parent.width * left | 0; - if (this.position.left === 'center') { - left -= this.width / 2 | 0; - } +Element.prototype._bindPosChanged = function() { + function changed() { + self.forDescendants(function(el) { + if (el.lpos) el.lpos.changed = true; + }, true); } - if (this.options.left == null && this.options.right != null) { - return this.screen.cols - this.width - this.right; - } + this.on('move', changed); + // Resize might be tricky because it's + // emitted recursively for a screen resize. + this.on('resize', changed); + this.on('reparent', changed); +}; - return (parent.left || 0) + left; -}); +/** + * Position Getters + */ -Element.prototype.__defineGetter__('right', function() { - if (this.options.right == null && this.options.left != null) { - return this.screen.cols - (this.left + this.width); - } - return (this.parent.right || 0) + this.position.right; -}); - -Element.prototype.__defineGetter__('top', function() { - var top = this.position.top; - - var parent = this.parent.spos(); - - if (typeof top === 'string') { - if (top === 'center') top = '50%'; - top = +top.slice(0, -1) / 100; - top = this.parent.height * top | 0; - if (this.position.top === 'center') { - top -= this.height / 2 | 0; - } - } - - if (this.options.top == null && this.options.bottom != null) { - return this.screen.rows - this.height - this.bottom; - } - - return (parent.top || 0) + top; -}); - -Element.prototype.__defineGetter__('bottom', function() { - if (this.options.bottom == null && this.options.top != null) { - return this.screen.rows - (this.top + this.height); - } - return (this.parent.bottom || 0) + this.position.bottom; -}); - -// TODO: Move _getShrinkSize calculation here. This will in turn fix .left. -Element.prototype.__defineGetter__('width', function() { +Element.prototype._getWidth = function(get) { + var parent = get ? this.parent._getPos() : this.parent; var width = this.position.width; + if (typeof width === 'string') { if (width === 'half') width = '50%'; width = +width.slice(0, -1) / 100; - return this.parent.width * width | 0; + return parent.width * width | 0; } + + // This is for if the element is being streched or shrunken. + // Although the width for shrunken elements is calculated + // in the render function, it may be calculated based on + // the content width, and the content width is initially + // decided by the width the element, so it needs to be + // calculated here. if (!width) { - // Problem if .left is 'center', we can't calculate the width - // NOTE: This assume `right` cannot be a string. var left = this.position.left; if (typeof left === 'string') { if (left === 'center') left = '50%'; left = +left.slice(0, -1) / 100; - left = this.parent.width * left | 0; + left = parent.width * left | 0; } - width = this.parent.width - this.position.right - left; + width = parent.width - this.position.right - left; } + return width; +}; + +Element.prototype.__defineGetter__('width', function() { + return this._getWidth(false); }); -// TODO: Move _getShrinkSize calculation here. This will in turn fix .top. -Element.prototype.__defineGetter__('height', function() { +Element.prototype._getHeight = function(get) { + var parent = get ? this.parent._getPos() : this.parent; var height = this.position.height; + if (typeof height === 'string') { if (height === 'half') height = '50%'; height = +height.slice(0, -1) / 100; - return this.parent.height * height | 0; + return parent.height * height | 0; } + + // This is for if the element is being streched or shrunken. + // Although the width for shrunken elements is calculated + // in the render function, it may be calculated based on + // the content width, and the content width is initially + // decided by the width the element, so it needs to be + // calculated here. if (!height) { - // Problem if .top is 'center', we can't calculate the height - // NOTE: This assume `bottom` cannot be a string. var top = this.position.top; if (typeof top === 'string') { if (top === 'center') top = '50%'; top = +top.slice(0, -1) / 100; - top = this.parent.height * top | 0; + top = parent.height * top | 0; } - height = this.parent.height - this.position.bottom - top; + height = parent.height - this.position.bottom - top; } + return height; +}; + +Element.prototype.__defineGetter__('height', function() { + return this._getHeight(false); }); -Element.prototype.__defineGetter__('rleft', function() { +Element.prototype._getLeft = function(get) { + var parent = get ? this.parent._getPos() : this.parent; var left = this.position.left; if (typeof left === 'string') { if (left === 'center') left = '50%'; left = +left.slice(0, -1) / 100; - left = this.parent.width * left | 0; + left = parent.width * left | 0; if (this.position.left === 'center') { - left -= this.width / 2 | 0; + left -= this._getWidth(get) / 2 | 0; } } if (this.options.left == null && this.options.right != null) { - return this.parent.width - this.width - this.right; + return this.screen.cols - this._getWidth(get) - this._getRight(get); } - return left; + return (parent.left || 0) + left; +}; + +Element.prototype.__defineGetter__('left', function() { + return this._getLeft(false); }); -Element.prototype.__defineGetter__('rright', function() { +Element.prototype._getRight = function(get) { + var parent = get ? this.parent._getPos() : this.parent; if (this.options.right == null && this.options.left != null) { - return this.parent.width - (this.rleft + this.width); + return this.screen.cols - (this._getLeft(get) + this._getWidth(get)); } - return this.position.right; + return (parent.right || 0) + this.position.right; +}; + +Element.prototype.__defineGetter__('right', function() { + return this._getRight(false); }); -Element.prototype.__defineGetter__('rtop', function() { +Element.prototype._getTop = function(get) { + var parent = get ? this.parent._getPos() : this.parent; var top = this.position.top; if (typeof top === 'string') { if (top === 'center') top = '50%'; top = +top.slice(0, -1) / 100; - top = this.parent.height * top | 0; + top = parent.height * top | 0; if (this.position.top === 'center') { - top -= this.height / 2 | 0; + top -= this._getHeight(get) / 2 | 0; } } if (this.options.top == null && this.options.bottom != null) { - return this.parent.height - this.height - this.bottom; + return this.screen.rows - this._getHeight(get) - this._getBottom(get); } - return top; + return (parent.top || 0) + top; +}; + +Element.prototype.__defineGetter__('top', function() { + return this._getTop(false); +}); + +Element.prototype._getBottom = function(get) { + var parent = get ? this.parent._getPos() : this.parent; + if (this.options.bottom == null && this.options.top != null) { + return this.screen.rows - (this._getTop(get) + this._getHeight(get)); + } + return (parent.bottom || 0) + this.position.bottom; +}; + +Element.prototype.__defineGetter__('bottom', function() { + return this._getBottom(false); +}); + +Element.prototype.__defineGetter__('rleft', function() { + return this.left - this.parent.left; +}); + +Element.prototype.__defineGetter__('rright', function() { + return this.right - this.parent.right; +}); + +Element.prototype.__defineGetter__('rtop', function() { + return this.top - this.parent.top; }); Element.prototype.__defineGetter__('rbottom', function() { - if (this.options.bottom == null && this.options.top != null) { - return this.parent.height - (this.rtop + this.height); - } - return this.position.bottom; + return this.bottom - this.parent.bottom; }); -// TODO: Reconcile the fact the `position.left` is actually `.rleft`. etc. -// TODO: Allow string values for absolute coords below. -// TODO: Optimize clearing to only clear what is necessary. - -Element.prototype.__defineSetter__('left', function(val) { - if (typeof val === 'string') { - if (val === 'center') val = '50%'; - val = +val.slice(0, -1) / 100; - val = this.screen.width * val | 0; - } - val -= this.parent.left; - if (this.position.left === val) return; - this.emit('move'); - this.clearPos(); - return this.options.left = this.position.left = val; -}); - -Element.prototype.__defineSetter__('right', function(val) { - if (typeof val === 'string') { - if (val === 'center') val = '50%'; - val = +val.slice(0, -1) / 100; - val = this.screen.width * val | 0; - } - val -= this.parent.right; - if (this.position.right === val) return; - this.emit('move'); - this.clearPos(); - //if (this.options.right == null) { - // return this.options.left = this.position.left = - // this.screen.width - 1 - val; - //} - return this.options.right = this.position.right = val; -}); - -Element.prototype.__defineSetter__('top', function(val) { - if (typeof val === 'string') { - if (val === 'center') val = '50%'; - val = +val.slice(0, -1) / 100; - val = this.screen.height * val | 0; - } - val -= this.parent.top; - if (this.position.top === val) return; - this.emit('move'); - this.clearPos(); - return this.options.top = this.position.top = val; -}); - -Element.prototype.__defineSetter__('bottom', function(val) { - if (typeof val === 'string') { - if (val === 'center') val = '50%'; - val = +val.slice(0, -1) / 100; - val = this.screen.height * val | 0; - } - val -= this.parent.bottom; - if (this.position.bottom === val) return; - this.emit('move'); - this.clearPos(); - //if (this.options.bottom == null) { - // return this.options.top = this.position.top = - // this.screen.height - 1 - val; - //} - return this.options.bottom = this.position.bottom = val; -}); +/** + * Position Setters + */ +// NOTE: +// For right, bottom, rright, and rbottom: +// If position.bottom is null, we could simply set top instead. +// But it wouldn't replicate bottom behavior appropriately if +// the parent was resized, etc. Element.prototype.__defineSetter__('width', function(val) { if (this.position.width === val) return; this.emit('resize'); @@ -2047,6 +2095,56 @@ Element.prototype.__defineSetter__('height', function(val) { return this.options.height = this.position.height = val; }); +Element.prototype.__defineSetter__('left', function(val) { + if (typeof val === 'string') { + if (val === 'center') { + val = this.screen.width / 2 | 0; + val -= this.width / 2 | 0; + } else { + val = +val.slice(0, -1) / 100; + val = this.screen.width * val | 0; + } + } + val -= this.parent.left; + if (this.position.left === val) return; + this.emit('move'); + this.clearPos(); + return this.options.left = this.position.left = val; +}); + +Element.prototype.__defineSetter__('right', function(val) { + val -= this.parent.right; + if (this.position.right === val) return; + this.emit('move'); + this.clearPos(); + return this.options.right = this.position.right = val; +}); + +Element.prototype.__defineSetter__('top', function(val) { + if (typeof val === 'string') { + if (val === 'center') { + val = this.screen.height / 2 | 0; + val -= this.height / 2 | 0; + } else { + val = +val.slice(0, -1) / 100; + val = this.screen.height * val | 0; + } + } + val -= this.parent.top; + if (this.position.top === val) return; + this.emit('move'); + this.clearPos(); + return this.options.top = this.position.top = val; +}); + +Element.prototype.__defineSetter__('bottom', function(val) { + val -= this.parent.bottom; + if (this.position.bottom === val) return; + this.emit('move'); + this.clearPos(); + return this.options.bottom = this.position.bottom = val; +}); + Element.prototype.__defineSetter__('rleft', function(val) { if (this.position.left === val) return; this.emit('move'); @@ -2058,10 +2156,6 @@ Element.prototype.__defineSetter__('rright', function(val) { if (this.position.right === val) return; this.emit('move'); this.clearPos(); - //if (this.options.right == null) { - // return this.options.left = this.position.left = - // this.parent.width - 1 - val; - //} return this.options.right = this.position.right = val; }); @@ -2076,10 +2170,6 @@ Element.prototype.__defineSetter__('rbottom', function(val) { if (this.position.bottom === val) return; this.emit('move'); this.clearPos(); - //if (this.options.bottom == null) { - // return this.options.top = this.position.top = - // this.parent.height - 1 - val; - //} return this.options.bottom = this.position.bottom = val; }); @@ -2123,7 +2213,7 @@ Box.prototype.__proto__ = Element.prototype; Box.prototype.type = 'box'; -Box.prototype._getShrinkSize = function(content) { +Box.prototype._getShrinkSize = function() { // TODO: Possibly move this to parseContent. return { height: this._clines.length, @@ -2146,7 +2236,7 @@ Box.prototype._getShrinkBox = function(xi, xl, yi, yl) { for (i = 0; i < this.children.length; i++) { el = this.children[i]; - ret = el.getCoords(); + ret = el._getCoords(); if (!ret) continue; if (ret.xi < mxi) mxi = ret.xi; @@ -2183,8 +2273,8 @@ Box.prototype._getShrinkBox = function(xi, xl, yi, yl) { return { xi: xi, xl: xl, yi: yi, yl: yl }; }; -Box.prototype._getShrinkContent = function(xi, xl, yi, yl, content) { - var hw = this._getShrinkSize(content) +Box.prototype._getShrinkContent = function(xi, xl, yi, yl) { + var hw = this._getShrinkSize() , h = hw.height , w = hw.width; @@ -2212,9 +2302,9 @@ Box.prototype._getShrinkContent = function(xi, xl, yi, yl, content) { return { xi: xi, xl: xl, yi: yi, yl: yl }; }; -Box.prototype._getShrink = function(xi, xl, yi, yl, content) { +Box.prototype._getShrink = function(xi, xl, yi, yl) { var shrinkBox = this._getShrinkBox(xi, xl, yi, yl) - , shrinkContent = this._getShrinkContent(xi, xl, yi, yl, content) + , shrinkContent = this._getShrinkContent(xi, xl, yi, yl) , xll = xl , yll = yl; @@ -2251,48 +2341,30 @@ Box.prototype._getShrink = function(xi, xl, yi, yl, content) { return { xi: xi, xl: xl, yi: yi, yl: yl }; }; -Box.prototype.getCoords = function() { +Box.prototype._getCoords = function(get) { if (this.hidden) return; - var lines = this.screen.lines - , xi = this.left - //, xl = this.screen.cols - this.right - , xl = xi + this.width - , yi = this.top - //, yl = this.screen.rows - this.bottom - , yl = yi + this.height + var xi = this._getLeft(get) + , xl = xi + this._getWidth(get) + , yi = this._getTop(get) + , yl = yi + this._getHeight(get) , rtop , visible , coords; - //if (this.position.width) { - // xl = xi + this.width; - //} - - //if (this.position.height) { - // yl = yi + this.height; - //} - // Check to make sure we're visible and // inside of the visible scroll area. if (this.parent.childBase != null && (!this.parent.items || ~this.parent.items.indexOf(this))) { - rtop = this.rtop + rtop = yi - this.parent._getTop(get) - (this.parent.border ? 1 : 0) - this.parent.padding; - visible = this.parent.height + visible = this.parent._getHeight(get) - (this.parent.border ? 2 : 0) - this.parent.padding * 2; - yi -= this.parent.childBase; - - yl = Math.min(yl, this.screen.rows - - this.parent.bottom - - (this.parent.border ? 1 : 0) - - this.parent.padding); - if (rtop - this.parent.childBase < 0) { return; } @@ -2300,6 +2372,9 @@ Box.prototype.getCoords = function() { if (rtop - this.parent.childBase >= visible) { return; } + + yi -= this.parent.childBase; + yl -= this.parent.childBase; } // Attempt to shrink the element base on the @@ -2319,12 +2394,10 @@ Box.prototype.getCoords = function() { }; // Here be dragons. -// TODO: Potentially move all calculations performed on -// xi/xl/yi/yl here to Element offset and size getters. Box.prototype.render = function() { this.parseContent(); - var coords = this.getCoords(); + var coords = this._getCoords(true); if (!coords) return; var lines = this.screen.lines @@ -2351,17 +2424,10 @@ Box.prototype.render = function() { dattr = this.sattr(this.style, this.style.fg, this.style.bg); attr = dattr; - // Check previous line for escape codes. - if (this.contentIndex != null && this.childBase > 0 && this._clines) { - i = ci - (this._clines[this.childBase - 1].length + 1); - for (; i < ci; i++) { - if (content[i] === '\x1b') { - if (c = /^\x1b\[[\d;]*m/.exec(content.substring(i))) { - attr = this.screen.attrCode(c[0], attr); - i += c[0].length - 1; - } - } - } + // If we're in a scrollable text box, check to + // see which attributes this line starts with. + if (this.contentIndex != null && this.childBase != null) { + attr = this._clines.attr[this.childBase]; } if (this.border) xi++, xl--, yi++, yl--; @@ -2564,7 +2630,7 @@ Box.prototype.render = function() { // screen.render(); // box.left++; // box.insertTop('foobar'); -// Things will break because we're using lpos instead of getCoords(). +// Things will break because we're using lpos instead of _getCoords(). // Maybe lpos could be updated on .left, .right, etc setters? Box.prototype.insertLine = function(i, line) { @@ -4241,9 +4307,9 @@ function Checkbox(options) { } this.on('focus', function(old) { - if (!this.lpos) return; + if (!self.lpos) return; self.screen.program.saveCursor(); - self.screen.program.cup(this.lpos.yi, this.lpos.xi + 1); + self.screen.program.cup(self.lpos.yi, self.lpos.xi + 1); self.screen.program.showCursor(); });