blob: ab947aa8504d90c0f489d71713507447e66c7b49 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:meta/meta.dart';
import 'trees.dart';
import 'ui/fake_flutter/fake_flutter.dart';
import 'utils.dart';
class HoverCellData<T> {
HoverCellData(this.data);
final T data;
}
abstract class TableDataClient<T> {
void rebuildTable();
/// Override to update data after setting rows.
void onSetRows();
void setState(VoidCallback modifyState);
/// The way the columns are sorted have changed.
///
/// Update the UI to reflect the new state.
void onColumnSortChanged(ColumnData<T> column, SortOrder sortDirection);
/// Selects by index. Note: This is index of the row as it's rendered
/// and not necessarily for rows[] since it may be being rendered in reverse.
/// This way, +1 will always move down the visible table.
/// scrollBehaviour is a string as defined for the HTML scrollTo() method
/// https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollTo (eg.
/// `smooth`, `instance`, `auto`).
void selectByIndex(
int newIndex, {
bool keepVisible = true,
String scrollBehavior = 'smooth',
});
void scrollToIndex(int rowIndex, {String scrollBehavior = 'smooth'});
void clearSelection();
}
class TableData<T> extends Object {
void setState(VoidCallback modifyState) {
client?.setState(modifyState);
}
TableDataClient<T> client;
bool _hasPendingRebuild = false;
List<ColumnData<T>> get columns => _columns;
final List<ColumnData<T>> _columns = <ColumnData<T>>[];
List<T> data = [];
int get rowCount => data?.length ?? 0;
ColumnData<T> _sortColumn;
SortOrder _sortDirection;
set sortColumn(ColumnData<T> column) {
_sortColumn = column;
_sortDirection =
column.numeric ? SortOrder.descending : SortOrder.ascending;
}
@protected
final StreamController<T> selectController = StreamController<T>.broadcast();
final StreamController<HoverCellData<T>> selectElementController =
StreamController<HoverCellData<T>>.broadcast();
@mustCallSuper
void dispose() {
client = null;
}
Stream<T> get onSelect => selectController.stream;
Stream<void> get onRowsChanged => _rowsChangedController.stream;
final _rowsChangedController = StreamController<void>.broadcast();
Stream<HoverCellData<T>> get onCellHover => selectElementController.stream;
void addColumn(ColumnData<T> column) {
_columns.add(column);
}
void setRows(List<T> data) {
// If the selected object is no longer valid, clear the selection and
// scroll to the top.
if (!data.contains(_selectedObject)) {
if (rowCount > 0) {
client?.scrollToIndex(0, scrollBehavior: 'auto');
}
client?.clearSelection();
}
// Copy the list, so that changes to it don't affect us.
this.data = data.toList();
_rowsChangedController.add(null);
client?.onSetRows();
if (_sortColumn == null) {
final ColumnData<T> column = _columns.firstWhere(
(ColumnData<T> c) => c.supportsSorting,
orElse: () => null);
if (column != null) {
sortColumn = column;
}
}
if (_sortColumn != null) {
_doSort();
}
scheduleRebuild();
}
void scrollTo(T row, {String scrollBehavior = 'smooth'}) {
final index = data.indexOf(row);
if (index == -1) {
return;
}
if (_hasPendingRebuild) {
// Wait for the content to be rendered before we scroll otherwise we may
// not be able to scroll far enough.
setState(() {
// We assume the index is still valid. Alternately we search for the
// index again. The one thing we should absolutely not do is call the
// scrollTo helper method again as there is a significant risk scrollTo
// would never be called if items are added to the table every frame.
client?.scrollToIndex(index, scrollBehavior: scrollBehavior);
});
return;
}
client?.scrollToIndex(index, scrollBehavior: scrollBehavior);
}
void scheduleRebuild() {
if (!_hasPendingRebuild) {
// Set a flag to ensure we don't schedule rebuilds if there's already one
// in the queue.
_hasPendingRebuild = true;
setState(() {
_hasPendingRebuild = false;
client?.rebuildTable();
});
}
}
void _doSort() {
final ColumnData<T> column = _sortColumn;
final int direction = _sortDirection == SortOrder.ascending ? 1 : -1;
client?.onColumnSortChanged(column, _sortDirection);
_sortData(column, direction);
}
void _sortData(ColumnData column, int direction) {
data.sort((T a, T b) => _compareData(a, b, column, direction));
}
int _compareData(T a, T b, ColumnData column, int direction) {
return column.compare(a, b) * direction;
}
T get selectedObject => _selectedObject;
T _selectedObject;
int get selectedObjectIndex => _selectedObjectIndex;
int _selectedObjectIndex;
void setSelection(T object, int index) {
assert(index == null || data[index] == object);
_selectedObjectIndex = index;
if (selectedObject != object) {
_selectedObject = object;
selectController.add(object);
}
}
bool get hasSelection => _selectedObject != null;
void onColumnClicked(ColumnData<T> column) {
if (!column.supportsSorting) {
return;
}
if (_sortColumn == column) {
_sortDirection = _sortDirection == SortOrder.ascending
? SortOrder.descending
: SortOrder.ascending;
} else {
sortColumn = column;
}
_doSort();
scheduleRebuild();
}
/// Override this method if the table should handle left key strokes.
void handleLeftKey() {}
/// Override this method if the table should handle right key strokes.
void handleRightKey() {}
}
/// Data for a tree table where nodes in the tree can be expanded and
/// collapsed.
///
/// A TreeTableData must have exactly one column that is a [TreeColumnData].
class TreeTableData<T extends TreeNode<T>> extends TableData<T> {
@override
void addColumn(ColumnData<T> column) {
// Avoid adding multiple TreeColumnData columns.
assert(column is! TreeColumnData<T> ||
_columns.where((c) => c is TreeColumnData).isEmpty);
super.addColumn(column);
}
@override
void handleLeftKey() {
if (_selectedObject != null) {
if (_selectedObject.isExpanded) {
// Collapse node and preserve selection.
collapseNode(_selectedObject);
} else {
// Select the node's parent.
final parentIndex = data.indexOf(_selectedObject.parent);
if (parentIndex != null && parentIndex != -1) {
client?.selectByIndex(parentIndex);
}
}
}
}
@override
void handleRightKey() {
if (_selectedObject != null) {
if (_selectedObject.isExpanded) {
// Select the node's first child.
final firstChildIndex = data.indexOf(_selectedObject.children.first);
client?.selectByIndex(firstChildIndex);
} else if (_selectedObject.isExpandable) {
// Expand node and preserve selection.
expandNode(_selectedObject);
} else {
// The node is not expandable. Select the next node in range.
final nextIndex = data.indexOf(_selectedObject) + 1;
if (nextIndex != data.length) {
client?.selectByIndex(nextIndex);
}
}
}
}
@override
void _sortData(ColumnData column, int direction) {
final List<T> sortedData = [];
void _addToSortedData(T dataObject) {
sortedData.add(dataObject);
if (dataObject.isExpanded) {
dataObject.children
..sort((T a, T b) => _compareData(a, b, column, direction))
..forEach(_addToSortedData);
}
}
data.where((dataObject) => dataObject.level == 0).toList()
..sort((T a, T b) => _compareData(a, b, column, direction))
..forEach(_addToSortedData);
data = sortedData;
}
void collapseNode(T dataObject) {
void cascadingRemove(T _dataObject) {
if (!data.contains(_dataObject)) return;
data.remove(_dataObject);
(_dataObject.children).forEach(cascadingRemove);
}
assert(data.contains(dataObject));
dataObject.children.forEach(cascadingRemove);
dataObject.collapse();
_selectedObject ??= dataObject;
setRows(data);
}
void expandNode(T dataObject) {
void expand(T node) {
assert(data.contains(node));
int insertIndex = data.indexOf(node) + 1;
for (T child in node.children) {
data.insert(insertIndex, child);
if (child.isExpanded) {
expand(child);
}
insertIndex++;
}
node.expand();
}
expand(dataObject);
_selectedObject ??= dataObject;
setRows(data);
}
void expandAll() {
// Store visited nodes so that we do not expand the same root multiple
// times.
final visited = <T>{};
for (T dataObject in data) {
final root = dataObject.root;
if (!visited.contains(root)) {
root.expandCascading();
visited.add(root);
}
}
setRows(data);
}
void collapseAll() {
// Store visited nodes so that we do not expand the same root multiple
// times.
final visited = <T>{};
for (T dataObject in data) {
final root = dataObject.root;
if (!visited.contains(root)) {
root.collapseCascading();
visited.add(root);
}
}
setRows(data);
}
}
abstract class ColumnData<T> {
ColumnData(
this.title, {
this.alignment = ColumnAlignment.left,
this.fixedWidthPx,
this.percentWidth,
this.usesHtml = false,
this.hover = false,
this.cssClass,
}) {
if (percentWidth != null) {
percentWidth.clamp(0, 100);
}
}
ColumnData.wide(
this.title, {
this.alignment = ColumnAlignment.left,
this.usesHtml = false,
this.hover = false,
this.cssClass,
}) : percentWidth = defaultWideColumnPercentWidth;
static const defaultWideColumnPercentWidth = 100;
final String title;
// TODO(jacobr): remove this field from the data before porting to FlutterWeb.
final String cssClass;
/// Width of the column expressed as a fixed number of pixels.
///
/// If both [fixedWidthPx] and [percentWidth] are specified, [fixedWidthPx]
/// will be used.
int fixedWidthPx;
/// Width of the column expressed as a percent value between 0 and 100.
///
/// TODO(jacobr): make this a double.
int percentWidth;
final ColumnAlignment alignment;
final bool usesHtml;
final bool hover;
bool get numeric => false;
bool get supportsSorting => numeric;
int compare(T a, T b) {
final Comparable valueA = getValue(a);
final Comparable valueB = getValue(b);
return valueA.compareTo(valueB);
}
/// Get the cell's value from the given [dataObject].
dynamic getValue(T dataObject);
/// Get the cell's display value from the given [dataObject].
dynamic getDisplayValue(T dataObject) => getValue(dataObject);
/// Get the cell's tooltip value from the given [dataObject].
String getTooltip(T dataObject) => '';
/// Given a value from [getValue], render it to a String.
String render(dynamic value) {
if (value is num) {
return fastIntl(value);
}
return value.toString();
}
static String fastIntl(num value) {
if (value is int && value < 1000) {
return value.toString();
} else {
return nf.format(value);
}
}
@override
String toString() => title;
}
abstract class TreeColumnData<T extends TreeNode<T>> extends ColumnData<T> {
TreeColumnData(
String title, {
int fixedWidthPx,
int percentWidth,
}) : super(title, fixedWidthPx: fixedWidthPx, percentWidth: percentWidth);
static const treeToggleWidth = 14;
final StreamController<T> nodeExpandedController =
StreamController<T>.broadcast();
Stream<T> get onNodeExpanded => nodeExpandedController.stream;
final StreamController<T> nodeCollapsedController =
StreamController<T>.broadcast();
Stream<T> get onNodeCollapsed => nodeCollapsedController.stream;
int getNodeIndentPx(T dataObject) {
int indentWidth = dataObject.level * treeToggleWidth;
if (!dataObject.isExpandable) {
// If the object is not expandable, we need to increase the width of our
// spacer to account for the missing tree toggle.
indentWidth += TreeColumnData.treeToggleWidth;
}
return indentWidth;
}
}
enum ColumnAlignment {
left,
right,
center,
}
enum SortOrder {
ascending,
descending,
}