blob: d99abe006d1ba1ed6762f536b5f20e1447aad89c [file]
// Copyright 2025 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
import 'dart:collection';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:devtools_app_shared/utils.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../primitives/trees.dart';
import '../primitives/utils.dart';
import 'column_widths.dart';
import 'table_data.dart';
/// Represents the various pinning modes for [FlatTable]:
///
/// - [FlatTablePinBehavior.none] disables item pinning
/// - [FlatTablePinBehavior.pinOriginalToTop] moves the original item from
/// the list of unpinned items to the list of pinned items.
/// - [FlatTablePinBehavior.pinCopyToTop] creates a copy of the original item
/// and inserts it into the list of pinned items, leaving the original item
/// in the list of unpinned items.
enum FlatTablePinBehavior { none, pinOriginalToTop, pinCopyToTop }
class FlatTableController<T> extends TableControllerBase<T> {
FlatTableController({
required super.columns,
required super.defaultSortColumn,
required super.defaultSortDirection,
super.secondarySortColumn,
super.columnGroups,
super.includeColumnGroupHeaders,
this.pinBehavior = FlatTablePinBehavior.none,
this.sizeColumnsToFit = true,
this.sortOriginalData = false,
this.onDataSorted,
});
/// Determines how elements that request to be pinned are displayed.
///
/// Defaults to [FlatTablePinBehavior.none], which disables pinning.
FlatTablePinBehavior pinBehavior;
/// Whether the columns for this table should be sized so that the entire
/// table fits in view (e.g. so that there is no horizontal scrolling).
final bool sizeColumnsToFit;
// TODO(kenz): should we enable this behavior by default? Does it ever matter
// to preserve the order of the original data passed to a flat table?
/// Whether the table controller should sort the original data list instead of
/// creating a copy.
final bool sortOriginalData;
/// Callback that will be called after each table sort operation.
final VoidCallback? onDataSorted;
/// The unmodified, original data for the active data set [_tableData.value].
///
/// This is reset each time [setData] is called, when [sortOriginalData] is
/// false.
late UnmodifiableListView<T> _unmodifiableOriginalData;
/// The modifiable, original data for the active data set [_tableData.value].
///
/// This is reset each time [setData] is called, when [sortOriginalData] is
/// true.
late List<T> _modifiableOriginalData;
@override
void setData(List<T> data, String key) {
if (sortOriginalData) {
_modifiableOriginalData = data;
} else {
_unmodifiableOriginalData = UnmodifiableListView(List<T>.of(data));
}
// Look up the UI state for [key], and sort accordingly.
final uiState = _tableUiStateForKey(key);
sortDataAndNotify(
columns[uiState.sortColumnIndex],
uiState.sortDirection,
secondarySortColumn: secondarySortColumn,
dataKey: key,
);
}
@override
void sortDataAndNotify(
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
String? dataKey,
}) {
List<T> data;
if (sortOriginalData) {
data = _modifiableOriginalData;
} else {
// TODO(kenz): copying the list for every sort could cause performance
// issues. We should only create a copy when sort is being called from
// [setData]. At all other times, the data has not been modified so there
// is no need to make a copy.
data = List<T>.of(_unmodifiableOriginalData);
}
pinnedData = <T>[];
data.sort(
(T a, T b) => _compareData<T>(
a,
b,
column,
direction,
secondarySortColumn: secondarySortColumn,
),
);
if (pinBehavior != FlatTablePinBehavior.none) {
// Collect the list of pinned entries. We don't need to sort again since
// we've already sorted the original data.
final dataCopy = <T>[];
for (final entry in data) {
final pinnableEntry = entry as PinnableListEntry;
if (pinnableEntry.pinToTop) {
pinnedData.add(entry);
}
if (!pinnableEntry.pinToTop ||
pinBehavior == FlatTablePinBehavior.pinCopyToTop) {
dataCopy.add(entry);
}
}
data = dataCopy;
}
if (!sizeColumnsToFit) {
columnWidths = computeColumnWidthsSizeToContent();
}
_tableData.value = TableData<T>(
data: data,
key: dataKey ?? _tableData.value.key,
);
setTableUiState(sortColumn: column, sortDirection: direction);
onDataSorted?.call();
}
}
class TreeTableController<T extends TreeNode<T>>
extends TableControllerBase<T> {
TreeTableController({
required super.columns,
required super.defaultSortColumn,
required super.defaultSortDirection,
super.secondarySortColumn,
super.columnGroups,
required this.treeColumn,
this.autoExpandRoots = false,
}) : assert(columns.contains(treeColumn)),
assert(columns.contains(defaultSortColumn));
/// The column of the table to treat as expandable.
final TreeColumnData<T> treeColumn;
final bool autoExpandRoots;
late List<T> dataRoots;
late int maxTableDepth;
@override
void setData(List<T> data, String key) {
dataRoots = data;
for (final root in dataRoots) {
if (autoExpandRoots && !root.isExpanded) {
root.expand();
}
}
// TODO(kenz): instead of using the maximum tree depth, consider using the
// maximum depth of expanded nodes.
maxTableDepth = dataRoots
.map((root) => root.depth)
.fold(
0,
(currentMaxDepth, nextDepth) => max(currentMaxDepth, nextDepth),
);
// Look up the UI state for [key], and sort accordingly.
final uiState = _tableUiStateForKey(key);
sortDataAndNotify(
columns[uiState.sortColumnIndex],
uiState.sortDirection,
secondarySortColumn: secondarySortColumn,
dataKey: key,
);
}
@override
void sortDataAndNotify(
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
String? dataKey,
}) {
pinnedData = <T>[];
int sortFunction(T a, T b) => _compareData<T>(
a,
b,
column,
direction,
secondarySortColumn: secondarySortColumn,
);
void sort(T dataObject) {
dataObject.children
..sort(sortFunction)
..forEach(sort);
}
dataRoots
..sort(sortFunction)
..forEach(sort);
setDataAndNotify(dataKey: dataKey);
setTableUiState(sortColumn: column, sortDirection: direction);
}
void setDataAndNotify({String? dataKey}) {
final dataFlatList = buildFlatList(dataRoots);
columnWidths = computeColumnWidths(maxTableDepth);
_tableData.value = TableData<T>(
data: dataFlatList,
key: dataKey ?? _tableData.value.key,
);
}
}
abstract class TableControllerBase<T> extends DisposableController {
TableControllerBase({
required this.columns,
required this.columnGroups,
required this.defaultSortColumn,
required this.defaultSortDirection,
this.secondarySortColumn,
this.includeColumnGroupHeaders = true,
});
final List<ColumnData<T>> columns;
final List<ColumnGroup>? columnGroups;
List<double>? columnWidths;
/// Determines if the headers for column groups should be rendered.
///
/// If set to false and `columnGroups` is non-null and non-empty, only
/// dividing lines will be drawn for each column group boundary.
final bool includeColumnGroupHeaders;
/// The default sort column for tables using this [TableController].
///
/// The currently active sort column will be stored as part of the
/// [TableUiState] for the current data (stored in [_tableUiStateByData]).
final ColumnData<T> defaultSortColumn;
/// The default [SortDirection] for tables using this [TableController].
///
/// The currently active [SortDirection] will be stored as part of the
/// [TableUiState] for the current data (stored in [_tableUiStateByData]).
final SortDirection defaultSortDirection;
/// The column to be used by the table sorting algorithm to break a tie for
/// two data rows that are "the same" when sorted by the primary sort column.
final ColumnData<T>? secondarySortColumn;
ScrollController? verticalScrollController;
void initScrollController([double initialScrollOffset = 0.0]) {
verticalScrollController = ScrollController(
initialScrollOffset: initialScrollOffset,
);
}
void storeScrollPosition() {
final scrollController = verticalScrollController;
if (scrollController != null && scrollController.hasClients) {
setTableUiState(scrollOffset: scrollController.offset);
}
}
/// The key for the current table data.
///
/// The value assigned to [TableData.key] will only be used if
/// [persistUiStates] has been set to true. Otherwise, all data sets will be
/// assigned to and looked up from the key [TableData.defaultDataKey].
String get _currentDataKey => _tableData.value.key;
ValueListenable<TableData<T>> get tableData => _tableData;
final _tableData = ValueNotifier<TableData<T>>(TableData<T>.empty());
/// The pinned data for the active data set [_tableData.value].
///
/// This value is reset each time [sortDataAndNotify] is called.
late List<T> pinnedData;
/// Returns the [TableUiState] for the current data [_tableData.value].
TableUiState get tableUiState => _tableUiStateForKey(_currentDataKey);
/// This method should be overridden by all subclasses.
void setData(List<T> data, String key);
void sortDataAndNotify(
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
});
TableUiState _tableUiStateForKey(String key) {
var state = TableUiStateStore.lookup(key);
if (state == null) {
TableUiStateStore.add(
key,
TableUiState(
sortColumnIndex: columns.indexOf(defaultSortColumn),
sortDirection: defaultSortDirection,
// Ignore this lint to make it clear what the default values are.
// ignore: avoid_redundant_argument_values
scrollOffset: 0.0,
),
);
state = TableUiStateStore.lookup(key)!;
}
return state;
}
void setTableUiState({
ColumnData<T>? sortColumn,
SortDirection? sortDirection,
double? scrollOffset,
}) {
final uiState = tableUiState;
if (sortColumn != null) {
uiState.sortColumnIndex = columns.indexOf(sortColumn);
}
if (sortDirection != null) {
uiState.sortDirection = sortDirection;
}
if (scrollOffset != null) {
uiState.scrollOffset = scrollOffset;
}
}
@override
void dispose() {
verticalScrollController?.dispose();
verticalScrollController = null;
super.dispose();
}
}
class TableData<T> {
const TableData({required this.data, String? key})
: key = key ?? defaultDataKey;
factory TableData.empty() => TableData<T>(data: const []);
static const defaultDataKey = 'defaultDataKey';
final List<T> data;
final String key;
}
class TableUiState {
TableUiState({
required this.sortColumnIndex,
required this.sortDirection,
this.scrollOffset = 0.0,
});
SortDirection sortDirection;
int sortColumnIndex;
double scrollOffset;
@override
String toString() {
return '_TableUiState($sortColumnIndex - $sortDirection - $scrollOffset)';
}
}
// Ignoring the 'avoid_classes_with_only_static_members' lint because the static
// members here allow us to add asserts that guarantee unique keys for tables
// across DevTools.
// ignore: avoid_classes_with_only_static_members
/// Stores the [TableUiState] for each table, keyed on a unique [String].
///
/// This store will remain alive for the entire life of the DevTools instance.
/// This allows us to cache the [TableUiState] for tables without having to
/// keep table [State] classes or table controller classes alive.
@visibleForTesting
abstract class TableUiStateStore<T> {
static final _tableUiStateStore = <String, TableUiState>{};
static void add(String key, TableUiState value) {
assert(
!_tableUiStateStore.containsKey(key),
'_TableUiState already exists for key: $key',
);
_tableUiStateStore[key] = value;
}
static TableUiState? lookup(String key) {
return _tableUiStateStore[key];
}
@visibleForTesting
static void clear() => _tableUiStateStore.clear();
}
int _compareFactor(SortDirection direction) =>
direction == SortDirection.ascending ? 1 : -1;
int _compareData<T>(
T a,
T b,
ColumnData<T> column,
SortDirection direction, {
ColumnData<T>? secondarySortColumn,
}) {
final compare = column.compare(a, b) * _compareFactor(direction);
if (compare != 0 || secondarySortColumn == null) return compare;
return secondarySortColumn.compare(a, b) * _compareFactor(direction);
}
/// Callback for when a specific item in a table is selected.
typedef ItemSelectedCallback<T> = void Function(T item);