blob: 76033ebbdd4611c391ecb36fb63a15d7e6e94bb7 [file] [log] [blame]
// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
part of app;
abstract class VirtualTreeRow {
// Number of ems each subtree is indented.
static const subtreeIndent = 2.0;
static const redColor = '#F44336';
static const blueColor = '#3F51B5';
static const purpleColor = '#673AB7';
static const greenColor = '#4CAF50';
static const orangeColor = '#FF9800';
static const lightGrayColor = '#FAFAFA';
List backgroundColors = const [
purpleColor,
redColor,
greenColor,
blueColor,
orangeColor,
];
final VirtualTree tree;
final List<VirtualTreeRow> children = [];
final List<StreamSubscription> _listeners = [];
final int depth;
bool _expanded = false;
VirtualTreeRow(this.tree, this.depth);
bool get expanded => _expanded;
set expanded(bool expanded) {
var changed = _expanded != expanded;
_expanded = expanded;
if (!changed) {
return;
}
if (_expanded) {
_expand();
} else {
_collapse();
}
}
Element makeColorBar(int depth) {
var element = new SpanElement();
element.style.paddingLeft = '2px';
element.style.paddingRight = '2px';
element.style.flexBasis = '2px';
element.style.height = '${tree.rowHeight}px';
element.style.minHeight = '${tree.rowHeight}px';
if (depth > 0) {
var colorIndex = (depth - 1) % backgroundColors.length;
element.style.backgroundColor = backgroundColors[colorIndex];
}
return element;
}
Element makeExpander() {
SpanElement element = new SpanElement();
element.style.flexBasis = '2em';
if (!hasChildren()) {
element.style.visibility = 'hidden';
} else {
element.style.visibility = 'visible';
element.children.add(expanded ?
new Element.tag('icon-expand-more') :
new Element.tag('icon-chevron-right'));
}
_listeners.add(element.onClick.listen((e) {
e.stopPropagation();
toggle();
}));
return element;
}
Element makeIndenter(int depth, {colored: true}) {
SpanElement element = new SpanElement();
element.style.paddingLeft = '${subtreeIndent * depth}em';
element.style.flexBasis = '${subtreeIndent * depth}em';
element.style.height = '${tree.rowHeight}px';
element.style.minHeight = '${tree.rowHeight}px';
if (colored) {
if (depth > 0) {
var colorIndex = (depth - 1) % backgroundColors.length;
element.style.backgroundColor = backgroundColors[colorIndex];
}
}
return element;
}
Element makeText(String text, {String toolTip,
String flexBasis: '4.5em',
String cssClass}) {
SpanElement element = new SpanElement();
element.text = text;
if (toolTip != null) {
element.title = toolTip;
}
if (flexBasis != null) {
element.style.flexBasis = flexBasis;
}
if (cssClass != null) {
element.classes.add(cssClass);
}
return element;
}
Element makeGap({double ems: 0.5}) {
SpanElement element = new SpanElement();
var flexBasis = '${ems}em';
element.style.flexBasis = flexBasis;
return element;
}
void _cleanupListeners() {
for (var listener in _listeners) {
listener.cancel();
}
_listeners.clear();
}
void _expand() {
onShow();
tree._onExpand(this);
if (children.length == 1) {
children[0]._expand();
}
_expanded = true;
}
void _collapse() {
_expanded = false;
for (var i = 0; i < children.length; i++) {
if (children[i].expanded) {
children[i]._collapse();
}
}
tree._onCollapse(this);
}
void toggle() {
expanded = !expanded;
}
void _render(DivElement rowDiv) {
rowDiv.style.display = 'flex';
rowDiv.style.alignItems = 'center';
_cleanupListeners();
onShow();
onRender(rowDiv);
}
/// Called when you should render into [rowDiv].
void onRender(DivElement rowDiv);
// Called when this row is visible.
void onShow();
// Return the number of children this node has.
int get childCount => 0;
// Return true if this node can be expanded.
bool hasChildren() => childCount > 0;
// Called when this row is not visible.
void onHide() {
_cleanupListeners();
}
}
class VirtualTree {
final int rowHeight;
final List<VirtualTreeRow> rows = [];
final DivElement root;
final Stopwatch _clock = new Stopwatch();
DivElement _treeHeightElement;
DivElement _tree;
StreamSubscription _scrollSubscription;
StreamSubscription _resizeSubscription;
// Height of [root] in pixels.
int viewHeight;
// Number of pixels view can be scrolled before a redraw occurs.
int redrawThresholdPixels;
// Number of rows visible at any given time.
int numVisibleRows;
// Number of rows above the current view that are in the dom.
int extraRowsAbove;
// Number of rows below the current view that are in the dom.
int extraRowsBelow;
// The scroll top of the last scroll event.
int lastPaintScrollTop;
// The starting row of the last paint.
int lastPaintStartingRow = 0;
bool paintScheduled = false;
bool scrolled = false;
static const scrollStopThresholdMilliseconds = 100;
VirtualTree(this.rowHeight, this.root) {
_clock.start();
_install();
_resize();
_schedulePaint(0);
}
void uninstall() => _uninstall();
void refresh() {
_resize();
_schedulePaint(lastPaintStartingRow);
}
// Clear the tree.
void clear() {
rows.clear();
_resize();
}
void _onExpand(VirtualTreeRow parent) {
int index = rows.indexOf(parent);
if (index == -1) {
return;
}
rows.insertAll(index + 1, parent.children);
refresh();
}
void _onCollapse(VirtualTreeRow parent) {
int index = rows.indexOf(parent);
if (index == -1) {
return;
}
int start = index + 1;
int end = start + parent.children.length;
rows.removeRange(start, end);
refresh();
}
void _resize() {
if (viewHeight != root.offsetHeight) {
viewHeight = root.offsetHeight;
numVisibleRows = (viewHeight ~/ rowHeight) + 1;
extraRowsAbove = numVisibleRows ~/ 2;
extraRowsBelow = numVisibleRows - extraRowsAbove;
redrawThresholdPixels =
math.min(extraRowsAbove, extraRowsBelow) * rowHeight;
}
_treeHeightElement.style.height = '${_treeHeight()}px';
}
int _treeHeight() {
return rows.length * rowHeight;
}
int _pixelsFromLastScroll(int currentScrollTop) {
if (lastPaintScrollTop == null) {
return currentScrollTop;
}
return (currentScrollTop - lastPaintScrollTop).abs();
}
int _pixelToRow(int pixelY) {
int result = pixelY ~/ rowHeight;
return result;
}
void _install() {
// This element controls the height of the tree's scrollable region.
// It is one pixel wide and the height is set to rowHeight * numRows.
_treeHeightElement = new DivElement();
_treeHeightElement.style.position = 'absolute';
_treeHeightElement.style.top = '0';
_treeHeightElement.style.left = '0';
_treeHeightElement.style.width = '1px';
// This element holds the visible tree rows and the height controlling
// element. It takes the full width and height of its parent element.
_tree = new DivElement();
_tree.children.add(_treeHeightElement);
_tree.style.width = '100%';
_tree.style.height = '100%';
_tree.style.position = 'relative';
_tree.style.overflow = 'auto';
// Listen for scroll events on the tree.
_scrollSubscription = _tree.onScroll.listen(_onScroll);
root.children.add(_tree);
// Listen for resize events.
_resizeSubscription = window.onResize.listen((_) {
_resize();
_schedulePaint(lastPaintStartingRow);
});
}
void _uninstall() {
root.children.clear();
_scrollSubscription?.cancel();
_scrollSubscription = null;
_resizeSubscription?.cancel();
_resizeSubscription = null;
}
void _onScroll(Event scrollEvent) {
Element target = scrollEvent.target;
int scrollTop = target.scrollTop;
if (_pixelsFromLastScroll(scrollTop) > redrawThresholdPixels) {
_schedulePaint(lastPaintStartingRow);
scrolled = true;
}
scrollEvent.preventDefault();
}
void _schedulePaint(int startingRow) {
if (paintScheduled) {
return;
}
paintScheduled = true;
window.requestAnimationFrame(
(timestamp) => _onRenderFrame(timestamp, startingRow));
}
void _onRenderFrame(int timestamp, int startingRow) {
paintScheduled = false;
_paint(startingRow);
}
void _paint(int startingRow) {
if (scrolled) {
startingRow = math.max(_pixelToRow(_tree.scrollTop), 0);
scrolled = false;
}
lastPaintScrollTop = _tree.scrollTop;
lastPaintStartingRow = startingRow;
int endingRow =
math.min(rows.length, startingRow + numVisibleRows + extraRowsBelow);
startingRow =
math.max(0, startingRow - extraRowsAbove);
print('PAINT $startingRow $endingRow');
for (int i = startingRow; i < endingRow; i++) {
// We add 1 because _tree.children[0] contains the height control element.
int cacheIndex = (i - startingRow) + 1;
DivElement row;
if (cacheIndex < _tree.children.length) {
// re-use existing row.
row = _tree.children[cacheIndex];
row.children.clear();
} else {
// Allocate a new row.
row = new DivElement();
row.style.position = 'absolute';
row.style.height = '${rowHeight}px';
row.style.maxHeight = '${rowHeight}px';
row.style.margin = '0';
row.style.width = '100%';
row.style.left = '0';
_tree.children.add(row);
}
row.style.top = '${(i * rowHeight)}px';
// Render the row.
rows[i]._render(row);
}
int necessaryChildren = (endingRow - startingRow) + 1;
while (_tree.children.length > necessaryChildren) {
_tree.children.removeLast();
}
}
}
abstract class TableTreeRow extends Observable {
static const arrowRight = '\u2192';
static const arrowDownRight = '\u21b3';
// Number of ems each subtree is indented.
static const subtreeIndent = 2;
TableTreeRow(this.tree, TableTreeRow parent) :
parent = parent,
depth = parent != null ? parent.depth + 1 : 0 {
}
final TableTree tree;
final TableTreeRow parent;
final int depth;
final List<TableTreeRow> children = new List<TableTreeRow>();
final List<TableCellElement> _tableColumns = new List<TableCellElement>();
final List<DivElement> flexColumns = new List<DivElement>();
final List<StreamSubscription> listeners = new List<StreamSubscription>();
SpanElement _expander;
TableRowElement _tr;
TableRowElement get tr => _tr;
bool _expanded = false;
bool get expanded => _expanded;
set expanded(bool expanded) {
var changed = _expanded != expanded;
_expanded = expanded;
if (changed) {
// If the state has changed, fire callbacks.
if (_expanded) {
_onExpand();
} else {
_onCollapse();
}
}
}
/// Fired when the tree row is being expanded.
void _onExpand() {
_updateExpanderView();
}
/// Fired when the tree row is being collapsed.
void _onCollapse() {
for (var child in children) {
child.onHide();
}
_updateExpanderView();
}
bool toggle() {
expanded = !expanded;
return expanded;
}
HtmlElement _makeColorBlock(String backgroundColor) {
var colorBlock = new DivElement();
colorBlock.style.minWidth = '2px';
colorBlock.style.backgroundColor = backgroundColor;
return colorBlock;
}
HtmlElement _makeExpander() {
var expander = new SpanElement();
expander.style.minWidth = '24px';
expander.style.minHeight = '24px';
listeners.add(expander.onClick.listen(onClick));
return expander;
}
void _cleanUpListeners() {
for (var i = 0; i < listeners.length; i++) {
listeners[i].cancel();
}
listeners.clear();
}
void onClick(Event e) {
e.stopPropagation();
tree.toggle(this);
}
static const redColor = '#F44336';
static const blueColor = '#3F51B5';
static const purpleColor = '#673AB7';
static const greenColor = '#4CAF50';
static const orangeColor = '#FF9800';
static const lightGrayColor = '#FAFAFA';
void _buildRow() {
const List backgroundColors = const [
purpleColor,
redColor,
greenColor,
blueColor,
orangeColor,
];
_tr = new TableRowElement();
for (var i = 0; i < tree.columnCount; i++) {
var cell = _tr.insertCell(-1);
_tableColumns.add(cell);
var flex = new DivElement();
flex.classes.add('flex-row');
cell.children.add(flex);
flexColumns.add(flex);
}
var firstColumn = flexColumns[0];
_tableColumns[0].style.paddingLeft = '${(depth - 1) * subtreeIndent}em';
var backgroundColor = lightGrayColor;
if (depth > 1) {
var colorIndex = (depth - 1) % backgroundColors.length;
backgroundColor = backgroundColors[colorIndex];
}
var colorBlock = _makeColorBlock(backgroundColor);
firstColumn.children.add(colorBlock);
_expander = _makeExpander();
firstColumn.children.add(_expander);
// Enable expansion by clicking anywhere on the first column.
listeners.add(firstColumn.onClick.listen(onClick));
_updateExpanderView();
}
void _updateExpanderView() {
if (_expander == null) {
return;
}
if (!hasChildren()) {
_expander.style.visibility = 'hidden';
_expander.classes.remove('pointer');
return;
} else {
_expander.style.visibility = 'visible';
_expander.classes.add('pointer');
}
_expander.children.clear();
_expander.children.add(expanded ?
new Element.tag('icon-expand-more') :
new Element.tag('icon-chevron-right'));
}
bool hasChildren();
/// Fired when the tree row is being shown.
/// Populate tr and add logical children here.
void onShow() {
assert(_tr == null);
_buildRow();
}
/// Fired when the tree row is being hidden.
void onHide() {
_tr = null;
_expander = null;
if (_tableColumns != null) {
_tableColumns.clear();
}
if (flexColumns != null) {
flexColumns.clear();
}
_cleanUpListeners();
}
}
class TableTree extends Observable {
final TableSectionElement tableBody;
final List<TableTreeRow> rows = [];
final int columnCount;
Future _pendingOperation;
/// Create a table tree with column [headers].
TableTree(this.tableBody, this.columnCount);
void clear() {
tableBody.children.clear();
for (var i = 0; i < rows.length; i++) {
rows[i]._cleanUpListeners();
}
rows.clear();
}
/// Initialize the table tree with the list of root children.
void initialize(TableTreeRow root) {
clear();
root.onShow();
toggle(root);
}
/// Toggle expansion of row in tree.
toggle(TableTreeRow row) async {
if (_pendingOperation != null) {
return;
}
if (row.toggle()) {
document.body.classes.add('busy');
_pendingOperation = _expand(row);
await _pendingOperation;
_pendingOperation = null;
document.body.classes.remove('busy');
if (row.children.length == 1) {
// Auto expand single child.
await toggle(row.children[0]);
}
} else {
document.body.classes.add('busy');
_pendingOperation = _collapse(row);
await _pendingOperation;
_pendingOperation = null;
document.body.classes.remove('busy');
}
}
int _index(TableTreeRow row) => rows.indexOf(row);
_insertRow(index, child) {
rows.insert(index, child);
tableBody.children.insert(index, child.tr);
}
_expand(TableTreeRow row) async {
int index = _index(row);
if ((index == -1) && (rows.length != 0)) {
return;
}
assert((index != -1) || (rows.length == 0));
var i = 0;
var addPerIteration = 10;
while (i < row.children.length) {
await window.animationFrame;
for (var j = 0; j < addPerIteration; j++) {
if (i == row.children.length) {
break;
}
var child = row.children[i];
child.onShow();
child._updateExpanderView();
_insertRow(index + i + 1, child);
i++;
}
}
}
_collapseSync(TableTreeRow row) {
var childCount = row.children.length;
if (childCount == 0) {
return;
}
for (var i = 0; i < childCount; i++) {
// Close all inner rows.
if (row.children[i].expanded) {
_collapseSync(row.children[i]);
}
}
// Collapse this row.
row.expanded = false;
// Remove all children.
int index = _index(row);
for (var i = 0; i < childCount; i++) {
rows.removeAt(index + 1);
tableBody.children.removeAt(index + 1);
}
}
_collapse(TableTreeRow row) async {
_collapseSync(row);
}
}
typedef String ValueFormatter(dynamic value);
class SortedTableColumn {
static String toStringFormatter(dynamic v) {
return v != null ? v.toString() : '<null>';
}
final String label;
final ValueFormatter formatter;
SortedTableColumn.withFormatter(this.label, this.formatter);
SortedTableColumn(this.label)
: formatter = toStringFormatter;
}
class SortedTableRow {
final List values;
SortedTableRow(this.values);
}
class SortedTable extends Observable {
final List<SortedTableColumn> columns;
final List<SortedTableRow> rows = new List<SortedTableRow>();
final List<int> sortedRows = [];
SortedTable(this.columns);
int _sortColumnIndex = 0;
set sortColumnIndex(var index) {
assert(index >= 0);
assert(index < columns.length);
_sortColumnIndex = index;
notifyPropertyChange(#getColumnLabel, 0, 1);
}
int get sortColumnIndex => _sortColumnIndex;
bool _sortDescending = true;
bool get sortDescending => _sortDescending;
set sortDescending(var descending) {
_sortDescending = descending;
notifyPropertyChange(#getColumnLabel, 0, 1);
}
dynamic getSortKeyFor(int row, int col) {
return rows[row].values[col];
}
int _sortFuncDescending(int i, int j) {
var a = getSortKeyFor(i, _sortColumnIndex);
var b = getSortKeyFor(j, _sortColumnIndex);
return b.compareTo(a);
}
int _sortFuncAscending(int i, int j) {
var a = getSortKeyFor(i, _sortColumnIndex);
var b = getSortKeyFor(j, _sortColumnIndex);
return a.compareTo(b);
}
void sort() {
assert(_sortColumnIndex >= 0);
assert(_sortColumnIndex < columns.length);
if (_sortDescending) {
sortedRows.sort(_sortFuncDescending);
} else {
sortedRows.sort(_sortFuncAscending);
}
}
void clearRows() {
rows.clear();
sortedRows.clear();
}
void addRow(SortedTableRow row) {
sortedRows.add(rows.length);
rows.add(row);
}
String getFormattedValue(int row, int column) {
var value = getValue(row, column);
var formatter = columns[column].formatter;
return formatter(value);
}
@observable String getColumnLabel(int column) {
assert(column >= 0);
assert(column < columns.length);
// TODO(johnmccutchan): Move expander display decisions into html once
// tables and templates are better supported.
const arrowUp = '\u25BC';
const arrowDown = '\u25B2';
if (column != _sortColumnIndex) {
return columns[column].label + '\u2003';
}
return columns[column].label + (_sortDescending ? arrowUp : arrowDown);
}
dynamic getValue(int row, int column) {
return rows[row].values[column];
}
}