add a customizable Layout element. see #23.

This commit is contained in:
Christopher Jeffrey 2015-05-06 12:32:19 -07:00
parent 583fa4f6f7
commit b21e535391
4 changed files with 505 additions and 1 deletions

171
README.md
View File

@ -168,6 +168,7 @@ nasty low-level terminal stuff.
- [ListTable](#listtable-from-list) - [ListTable](#listtable-from-list)
- [Terminal](#terminal-from-box) - [Terminal](#terminal-from-box)
- [Image](#image-from-box) - [Image](#image-from-box)
- [Layout](#layout-from-element)
#### Node (from EventEmitter) #### Node (from EventEmitter)
@ -1313,6 +1314,176 @@ terminals.
- __getPixelRatio(callback)__ - get the pixel to cell ratio for the terminal. - __getPixelRatio(callback)__ - get the pixel to cell ratio for the terminal.
#### Layout (from Element)
A layout which can position children automatically based on a `renderer` method
(__experimental__ - the mechanics of this element may be changed in the
future!).
By default, the Layout element automatically positions children as if they were
`display: inline-block;` in CSS.
##### Options:
- inherits all from Element.
- __renderer__ - a callback which is called right before the children are
iterated over to be rendered. should return an iterator callback which is
called on each child element: __iterator(el, i)__.
##### Properties:
- inherits all from Element.
##### Events:
- inherits all from Element.
##### Methods:
- inherits all from Element.
- __renderer(coords)__ - a callback which is called right before the children
are iterated over to be rendered. should return an iterator callback which is
called on each child element: __iterator(el, i)__.
- __isRendered(el)__ - check to see if a previous child element has been
rendered and is visible on screen. this is __only__ useful for checking child
elements that have already been attempted to be rendered! see the example
below.
- __getLast(i)__ - get the last rendered and visible child element based on an
index. this is useful for basing the position of the current child element on
the position of the last child element.
- __getLastCoords(i)__ - get the last rendered and visible child element coords
based on an index. this is useful for basing the position of the current
child element on the position of the last child element. see the example
below.
##### Rendering a Layout for child elements
###### Notes
You must __always__ give `Layout` a width and height. This is a chicken-and-egg
problem: blessed cannot calculate the width and height dynamically _before_ the
children are positioned.
`border` and `padding` are already calculated into the `coords` object the
`renderer` receives, so there is no need to account for it in your renderer.
Try to set position for children using `el.position`. `el.position` is the most
primitive "to-be-rendered" way to set coordinates. Setting `el.left` directly
has more dynamic behavior which may interfere with rendering.
Some definitions for `coords` (otherwise known as `el.lpos`):
- `coords.xi` - the absolute x coordinate of the __left__ side of a rendered
element. it is absolute: relative to the screen itself.
- `coords.xl` - the absolute x coordinate of the __right__ side of a rendered
element. it is absolute: relative to the screen itself.
- `coords.yi` - the absolute y coordinate of the __top__ side of a rendered
element. it is absolute: relative to the screen itself.
- `coords.yl` - the absolute y coordinate of the __bottom__ side of a rendered
element. it is absolute: relative to the screen itself.
Note again: the `coords` the renderer receives for the Layout already has
border and padding subtracted, so you do not have to account for these. The
children do not.
###### Example
Here is an example of how to provide a renderer. Note that this is also the
default renderer if none is provided. This renderer will render each child as
though they were `display: inline-block;` in CSS, as if there were a
dynamically sized horizontal grid from left to right.
``` js
var layout = blessed.layout({
parent: screen,
top: 'center',
left: 'center',
width: '50%',
height: '50%',
border: 'line',
style: {
bg: 'red',
border: {
fg: 'blue'
}
},
// NOTE: This is already the default renderer if none is provided!
renderer: function(coords) {
var self = this;
// The coordinates of the layout element
var width = coords.xl - coords.xi
, height = coords.yl - coords.yi
, xi = coords.xi
, xl = coords.xl
, yi = coords.yi
, yl = coords.yl;
// The current row offset in cells (which row are we on?)
var rowOffset = 0;
// The index of the first child in the row
var rowIndex = 0;
return function iterator(el, i) {
// Make our children shrinkable. If they don't have a height, for
// example, calculate it for them.
el.shrink = true;
// Find the previous rendered child's coordinates
var last = self.getLastCoords(i);
// If there is no previously rendered element, we are on the first child.
if (!last) {
el.position.left = 0;
el.position.top = 0;
} else {
// Otherwise, figure out where to place this child. We'll start by
// setting it's `left`/`x` coordinate to right after the previous
// rendered element. This child will end up directly to the right of it.
el.position.left = last.xl - xi;
// If our child does not overlap the right side of the Layout, set it's
// `top`/`y` to the current `rowOffset` (the coordinate for the current
// row).
if (el.position.left + el.width <= width) {
el.position.top = rowOffset;
} else {
// Otherwise we need to start a new row and calculate a new
// `rowOffset` and `rowIndex` (the index of the child on the current
// row).
rowOffset += self.children.slice(rowIndex, i).reduce(function(out, el) {
if (!self.isRendered(el)) return out;
out = Math.max(out, el.lpos.yl - el.lpos.yi);
return out;
}, 0);
rowIndex = i;
el.position.left = 0;
el.position.top = rowOffset;
}
}
// If our child overflows the Layout, do not render it!
// Disable this feature for now.
if (el.position.top + el.height > height) {
// Returning false tells blessed to ignore this child.
// return false;
}
};
}
});
for (var i = 0; i < 10; i++) {
blessed.box({
parent: layout,
width: i % 2 === 0 ? 10 : 20,
height: i % 2 === 0 ? 5 : 10,
border: 'line'
});
}
```
### Artificial Cursors ### Artificial Cursors
Terminal cursors can be tricky. They all have different custom escape codes to Terminal cursors can be tricky. They all have different custom escape codes to

View File

@ -35,7 +35,8 @@ widget.classes = [
'Table', 'Table',
'ListTable', 'ListTable',
'Terminal', 'Terminal',
'Image' 'Image',
'Layout'
]; ];
widget.classes.forEach(function(name) { widget.classes.forEach(function(name) {

202
lib/widgets/layout.js Normal file
View File

@ -0,0 +1,202 @@
/**
* layout.js - layout element for blessed
* Copyright (c) 2013-2015, Christopher Jeffrey and contributors (MIT License).
* https://github.com/chjj/blessed
*/
/**
* Modules
*/
var helpers = require('../helpers');
var Node = require('./node');
var Element = require('./element');
/**
* Layout
*/
function Layout(options) {
var self = this;
if (!(this instanceof Node)) {
return new Layout(options);
}
options = options || {};
if ((options.width == null
&& (options.left == null && options.right == null))
|| (options.height == null
&& (options.top == null && options.bottom == null))) {
throw new Error('`Layout` must have a width and height!');
}
Element.call(this, options);
if (options.renderer) {
this.renderer = options.renderer;
}
}
Layout.prototype.__proto__ = Element.prototype;
Layout.prototype.type = 'layout';
Layout.prototype.isRendered = function(el) {
if (!el.lpos) return false;
return (el.lpos.xl - el.lpos.xi) > 0
&& (el.lpos.yl - el.lpos.yi) > 0;
};
Layout.prototype.getLast = function(i) {
while (this.children[--i]) {
var el = this.children[i];
if (this.isRendered(el)) return el;
}
};
Layout.prototype.getLastCoords = function(i) {
var last = this.getLast(i);
if (last) return last.lpos;
};
Layout.prototype._renderCoords = function() {
var coords = this._getCoords(true);
var children = this.children;
this.children = [];
this._render();
this.children = children;
return coords;
};
Layout.prototype.renderer = function(coords) {
var self = this;
// The coordinates of the layout element
var width = coords.xl - coords.xi
, height = coords.yl - coords.yi
, xi = coords.xi
, xl = coords.xl
, yi = coords.yi
, yl = coords.yl;
// The current row offset in cells (which row are we on?)
var rowOffset = 0;
// The index of the first child in the row
var rowIndex = 0;
return function iterator(el, i) {
// Make our children shrinkable. If they don't have a height, for
// example, calculate it for them.
el.shrink = true;
// Find the previous rendered child's coordinates
var last = self.getLast(i);
// If there is no previously rendered element, we are on the first child.
if (!last) {
el.position.left = 0;
el.position.top = 0;
} else {
// Otherwise, figure out where to place this child. We'll start by
// setting it's `left`/`x` coordinate to right after the previous
// rendered element. This child will end up directly to the right of it.
el.position.left = last.lpos.xl - xi;
// If our child does not overlap the right side of the Layout, set it's
// `top`/`y` to the current `rowOffset` (the coordinate for the current
// row).
if (el.position.left + el.width <= width) {
el.position.top = rowOffset;
} else {
// Otherwise we need to start a new row and calculate a new
// `rowOffset` and `rowIndex` (the index of the child on the current
// row).
rowOffset += self.children.slice(rowIndex, i).reduce(function(out, el) {
if (!self.isRendered(el)) return out;
out = Math.max(out, el.lpos.yl - el.lpos.yi);
return out;
}, 0);
rowIndex = i;
el.position.left = 0;
el.position.top = rowOffset;
}
}
// If our child overflows the Layout, do not render it!
// Disable this feature for now.
if (el.position.top + el.height > height) {
// Returning false tells blessed to ignore this child.
// return false;
}
};
};
Layout.prototype.render = function() {
var self = this;
this._emit('prerender');
var coords = this._renderCoords();
if (!coords) {
delete this.lpos;
return;
}
if (coords.xl - coords.xi <= 0) {
coords.xl = Math.max(coords.xl, coords.xi);
return;
}
if (coords.yl - coords.yi <= 0) {
coords.yl = Math.max(coords.yl, coords.yi);
return;
}
this.lpos = coords;
if (this.border) coords.xi++, coords.xl--, coords.yi++, coords.yl--;
if (this.tpadding) {
coords.xi += this.padding.left, coords.xl -= this.padding.right;
coords.yi += this.padding.top, coords.yl -= this.padding.bottom;
}
var iterator = this.renderer(coords);
if (this.border) coords.xi--, coords.xl++, coords.yi--, coords.yl++;
if (this.tpadding) {
coords.xi -= this.padding.left, coords.xl += this.padding.right;
coords.yi -= this.padding.top, coords.yl += this.padding.bottom;
}
this.children.forEach(function(el, i) {
if (el.screen._ci !== -1) {
el.index = el.screen._ci++;
}
var rendered = iterator(el, i);
if (rendered === false) {
delete el.lpos;
return;
}
// if (el.screen._rendering) {
// el._rendering = true;
// }
el.render();
// if (el.screen._rendering) {
// el._rendering = false;
// }
});
this._emit('render', [coords]);
return coords;
};
/**
* Expose
*/
module.exports = Layout;

130
test/widget-layout.js Normal file
View File

@ -0,0 +1,130 @@
var blessed = require('../')
, screen;
screen = blessed.screen({
dump: __dirname + '/logs/layout.log',
smartCSR: true,
autoPadding: true
});
var layout = blessed.layout({
parent: screen,
top: 'center',
left: 'center',
width: '50%',
height: '50%',
border: 'line',
style: {
bg: 'red',
border: {
fg: 'blue'
}
}
});
var box1 = blessed.box({
parent: layout,
top: 'center',
left: 'center',
width: 20,
height: 10,
border: 'line'
});
var box2 = blessed.box({
parent: layout,
top: 0,
left: 0,
width: 10,
height: 5,
border: 'line'
});
var box2 = blessed.box({
parent: layout,
top: 0,
left: 0,
width: 10,
height: 5,
border: 'line'
});
var box2 = blessed.box({
parent: layout,
top: 0,
left: 0,
width: 10,
height: 5,
border: 'line'
});
var box2 = blessed.box({
parent: layout,
top: 0,
left: 0,
width: 10,
height: 5,
border: 'line'
});
var box2 = blessed.box({
parent: layout,
top: 0,
left: 0,
width: 10,
height: 5,
border: 'line'
});
var box2 = blessed.box({
parent: layout,
top: 0,
left: 0,
width: 10,
height: 5,
border: 'line'
});
var box1 = blessed.box({
parent: layout,
top: 'center',
left: 'center',
width: 20,
height: 10,
border: 'line'
});
var box2 = blessed.box({
parent: layout,
top: 0,
left: 0,
width: 10,
height: 5,
border: 'line'
});
var box1 = blessed.box({
parent: layout,
top: 'center',
left: 'center',
width: 20,
height: 10,
border: 'line'
});
/*
for (var i = 0; i < 10; i++) {
blessed.box({
parent: layout,
width: i % 2 === 0 ? 10 : 20,
height: i % 2 === 0 ? 5 : 10,
border: 'line'
});
}
*/
screen.key('q', function() {
return process.exit(0);
});
screen.render();