blob: f115ffa5e3b35ceaaa9c69fcefba91442332ebca [file] [log] [blame]
// Copyright 2019 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:async';
import 'dart:math';
import 'package:devtools_app_shared/ui.dart';
import 'package:devtools_app_shared/utils.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide TableRow;
import 'package:flutter/services.dart';
import '../primitives/collapsible_mixin.dart';
import '../primitives/extent_delegate_list.dart';
import '../primitives/flutter_widgets/linked_scroll_controller.dart';
import '../primitives/trees.dart';
import '../primitives/utils.dart';
import '../ui/common_widgets.dart';
import '../ui/search.dart';
import '../ui/utils.dart';
import '../utils/utils.dart';
import 'column_widths.dart';
import 'table_controller.dart';
import 'table_data.dart';
part '_flat_table.dart';
part '_table_column.dart';
part '_table_row.dart';
part '_tree_table.dart';
// TODO(devoncarew): We need to render the selected row with a different
// background color.
typedef IndexedScrollableWidgetBuilder =
Widget Function({
required BuildContext context,
required LinkedScrollControllerGroup linkedScrollControllerGroup,
required int index,
required List<double> columnWidths,
required bool isPinned,
required bool enableHoverHandling,
});
typedef TableKeyEventHandler =
KeyEventResult Function(
KeyEvent event,
ScrollController scrollController,
BoxConstraints constraints,
);
class Selection<T> {
Selection({
this.node,
this.nodeIndex,
this.nodeIndexCalculator,
this.scrollIntoView = false,
}) : assert(nodeIndex == null || nodeIndexCalculator == null);
Selection.empty()
: node = null,
nodeIndex = null,
nodeIndexCalculator = null,
scrollIntoView = true;
final T? node;
// TODO (carolynqu): get rid of nodeIndex and only use nodeIndexCalculator, https://github.com/flutter/devtools/issues/4266
final int? nodeIndex;
final int? Function(T?)? nodeIndexCalculator;
final bool scrollIntoView;
}
// TODO(kenz): https://github.com/flutter/devtools/issues/1522. The table code
// needs to be refactored to support flexible column widths.
@visibleForTesting
class DevToolsTable<T> extends StatefulWidget {
const DevToolsTable({
super.key,
required this.tableController,
required this.columnWidths,
required this.rowBuilder,
this.selectionNotifier,
this.focusNode,
this.handleKeyEvent,
this.autoScrollContent = false,
this.startScrolledAtBottom = false,
this.preserveVerticalScrollPosition = false,
this.activeSearchMatchNotifier,
this.rowItemExtent,
this.tallHeaders = false,
this.headerColor,
this.fillWithEmptyRows = false,
this.enableHoverHandling = false,
});
final TableControllerBase<T> tableController;
final bool autoScrollContent;
final bool startScrolledAtBottom;
final List<double> columnWidths;
final IndexedScrollableWidgetBuilder rowBuilder;
final double? rowItemExtent;
final FocusNode? focusNode;
final TableKeyEventHandler? handleKeyEvent;
final ValueNotifier<Selection<T?>>? selectionNotifier;
final ValueListenable<T?>? activeSearchMatchNotifier;
final bool preserveVerticalScrollPosition;
final bool tallHeaders;
final Color? headerColor;
final bool fillWithEmptyRows;
final bool enableHoverHandling;
@override
DevToolsTableState<T> createState() => DevToolsTableState<T>();
}
@visibleForTesting
class DevToolsTableState<T> extends State<DevToolsTable<T>>
with AutoDisposeMixin {
late LinkedScrollControllerGroup _linkedHorizontalScrollControllerGroup;
late ScrollController scrollController;
late ScrollController pinnedScrollController;
late List<T> _data;
/// An adjusted copy of [widget.columnWidths] where any variable width columns
/// may be increased so that the sum of all column widths equals the available
/// screen space.
///
/// This must be calculated where we have access to the Flutter view
/// constraints (e.g. the [LayoutBuilder] below).
@visibleForTesting
late List<double> adjustedColumnWidths;
@override
void initState() {
super.initState();
_initDataAndAddListeners();
_linkedHorizontalScrollControllerGroup = LinkedScrollControllerGroup();
final initialScrollOffset =
widget.preserveVerticalScrollPosition
? widget.tableController.tableUiState.scrollOffset
: 0.0;
widget.tableController.initScrollController(initialScrollOffset);
scrollController = widget.tableController.verticalScrollController!;
if (widget.startScrolledAtBottom) {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(scrollController.autoScrollToBottom(jump: true));
});
}
pinnedScrollController = ScrollController();
adjustedColumnWidths = List.of(widget.columnWidths);
}
@override
void didUpdateWidget(covariant DevToolsTable<T> oldWidget) {
super.didUpdateWidget(oldWidget);
final notifiersChanged =
widget.tableController.tableData !=
oldWidget.tableController.tableData ||
widget.selectionNotifier != oldWidget.selectionNotifier ||
widget.activeSearchMatchNotifier != oldWidget.activeSearchMatchNotifier;
if (notifiersChanged) {
cancelListeners();
_initDataAndAddListeners();
}
adjustedColumnWidths = List.of(widget.columnWidths);
}
void _initDataAndAddListeners() {
_data = widget.tableController.tableData.value.data;
addAutoDisposeListener(widget.tableController.tableData, () {
setState(() {
_data = widget.tableController.tableData.value.data;
});
});
_initScrollOnSelectionListener(widget.selectionNotifier);
_initSearchListener();
}
void _initScrollOnSelectionListener(
ValueNotifier<Selection<T?>>? selectionNotifier,
) {
if (selectionNotifier != null) {
addAutoDisposeListener(selectionNotifier, () {
WidgetsBinding.instance.addPostFrameCallback((_) {
final selection = selectionNotifier.value;
final T? node = selection.node;
final int? Function(T?)? nodeIndexCalculator =
selection.nodeIndexCalculator;
final nodeIndex = selection.nodeIndex;
if (selection.scrollIntoView && node != null) {
final selectedDisplayRow =
nodeIndexCalculator != null
? nodeIndexCalculator(node)!
: nodeIndex!;
final newPos = selectedDisplayRow * defaultRowHeight;
maybeScrollToPosition(scrollController, newPos);
}
});
});
}
}
void _initSearchListener() {
if (widget.activeSearchMatchNotifier != null) {
addAutoDisposeListener(
widget.activeSearchMatchNotifier,
_onActiveSearchChange,
);
}
}
void _onActiveSearchChange() async {
final activeSearch = widget.activeSearchMatchNotifier!.value;
if (activeSearch == null) return;
final index = _data.indexOf(activeSearch);
if (index == -1) return;
final y = index * defaultRowHeight;
final indexInView =
y > scrollController.offset &&
y < scrollController.offset + scrollController.position.extentInside;
if (!indexInView) {
await scrollController.animateTo(
index * defaultRowHeight,
duration: defaultDuration,
curve: defaultCurve,
);
}
}
@override
void deactivate() {
if (widget.preserveVerticalScrollPosition) {
widget.tableController.storeScrollPosition();
}
super.deactivate();
}
@override
void dispose() {
widget.tableController.dispose();
pinnedScrollController.dispose();
super.dispose();
}
/// The width of all columns in the table with additional padding.
double get _tableWidthForOriginalColumns {
var tableWidth = 2 * defaultSpacing;
final numColumnGroupSpacers =
widget.tableController.columnGroups?.numSpacers ?? 0;
final numColumnSpacers =
widget.tableController.columns.numSpacers - numColumnGroupSpacers;
tableWidth += numColumnSpacers * columnSpacing;
tableWidth += numColumnGroupSpacers * columnGroupSpacingWithPadding;
for (final columnWidth in widget.columnWidths) {
tableWidth += columnWidth;
}
return tableWidth;
}
/// Modifies [adjustedColumnWidths] so that any available view space greater
/// than [_tableWidthForOriginalColumns] is distributed evenly across variable
/// width columns.
void _adjustColumnWidthsForViewSize(double viewWidth) {
final extraSpace = viewWidth - _tableWidthForOriginalColumns;
if (extraSpace <= 0) {
adjustedColumnWidths = List.of(widget.columnWidths);
return;
}
final adjustedColumnWidthsByIndex = <int, double>{};
/// Helper method to evenly distribute [space] among the columns at
/// [columnIndices].
///
/// This method stores the adjusted width values in
/// [adjustedColumnWidthsByIndex].
void evenlyDistributeColumnSizes(List<int> columnIndices, double space) {
final targetSize = space / columnIndices.length;
var largestColumnIndex = -1;
var largestColumnWidth = 0.0;
for (final index in columnIndices) {
final columnWidth = widget.columnWidths[index];
if (columnWidth >= largestColumnWidth) {
largestColumnIndex = index;
largestColumnWidth = columnWidth;
}
}
if (targetSize < largestColumnWidth) {
// We do not have enough extra space to evenly distribute to all
// columns. Remove the largest column and recurse.
adjustedColumnWidthsByIndex[largestColumnIndex] = largestColumnWidth;
final newColumnIndices = List.of(columnIndices)
..remove(largestColumnIndex);
return evenlyDistributeColumnSizes(
newColumnIndices,
space - largestColumnWidth,
);
}
for (int i = 0; i < columnIndices.length; i++) {
final columnIndex = columnIndices[i];
adjustedColumnWidthsByIndex[columnIndex] = targetSize;
}
}
final variableWidthColumnIndices = <int>[];
var sumVariableWidthColumnSizes = 0.0;
for (int i = 0; i < widget.tableController.columns.length; i++) {
final column = widget.tableController.columns[i];
if (column.fixedWidthPx == null) {
variableWidthColumnIndices.add(i);
sumVariableWidthColumnSizes += widget.columnWidths[i];
}
}
final totalVariableWidthColumnSpace =
sumVariableWidthColumnSizes + extraSpace;
evenlyDistributeColumnSizes(
variableWidthColumnIndices,
totalVariableWidthColumnSpace,
);
adjustedColumnWidths.clear();
for (int i = 0; i < widget.columnWidths.length; i++) {
final originalWidth = widget.columnWidths[i];
final isVariableWidthColumn = variableWidthColumnIndices.contains(i);
adjustedColumnWidths.add(
isVariableWidthColumn ? adjustedColumnWidthsByIndex[i]! : originalWidth,
);
}
}
double _pinnedDataHeight(BoxConstraints tableConstraints) => min(
widget.rowItemExtent! * pinnedData.length,
tableConstraints.maxHeight / 2,
);
int _dataRowCount(
BoxConstraints tableConstraints,
bool showColumnGroupHeader,
) {
if (!widget.fillWithEmptyRows) {
return _data.length;
}
var maxHeight = tableConstraints.maxHeight;
final columnHeadersCount = showColumnGroupHeader ? 2 : 1;
maxHeight -=
columnHeadersCount *
(defaultHeaderHeight +
(widget.tallHeaders ? scaleByFontFactor(densePadding) : 0.0));
if (pinnedData.isNotEmpty) {
maxHeight -=
_pinnedDataHeight(tableConstraints) + ThickDivider.thickDividerHeight;
}
return max(_data.length, maxHeight ~/ widget.rowItemExtent!);
}
Widget _buildItem(BuildContext context, int index, {bool isPinned = false}) {
return widget.rowBuilder(
context: context,
linkedScrollControllerGroup: _linkedHorizontalScrollControllerGroup,
index: index,
columnWidths: adjustedColumnWidths,
isPinned: isPinned,
enableHoverHandling: widget.enableHoverHandling,
);
}
List<T> get pinnedData => widget.tableController.pinnedData;
@override
Widget build(BuildContext context) {
// If we're at the end already, scroll to expose the new content.
if (widget.autoScrollContent) {
if (scrollController.hasClients && scrollController.atScrollBottom) {
unawaited(scrollController.autoScrollToBottom());
}
}
final columnGroups = widget.tableController.columnGroups;
final includeColumnGroupHeaders =
widget.tableController.includeColumnGroupHeaders;
final showColumnGroupHeader =
columnGroups != null &&
columnGroups.isNotEmpty &&
includeColumnGroupHeaders;
final tableUiState = widget.tableController.tableUiState;
final sortColumn =
widget.tableController.columns[tableUiState.sortColumnIndex];
if (widget.preserveVerticalScrollPosition && scrollController.hasClients) {
scrollController.jumpTo(tableUiState.scrollOffset);
}
// TODO(kenz): add horizontal scrollbar.
return LayoutBuilder(
builder: (context, constraints) {
final viewWidth = constraints.maxWidth;
_adjustColumnWidthsForViewSize(viewWidth);
return SelectionArea(
child: SizedBox(
width: max(viewWidth, _tableWidthForOriginalColumns),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showColumnGroupHeader)
TableRow<T>.tableColumnGroupHeader(
linkedScrollControllerGroup:
_linkedHorizontalScrollControllerGroup,
columnGroups: columnGroups,
columnWidths: adjustedColumnWidths,
sortColumn: sortColumn,
sortDirection: tableUiState.sortDirection,
secondarySortColumn:
widget.tableController.secondarySortColumn,
onSortChanged: widget.tableController.sortDataAndNotify,
tall: widget.tallHeaders,
backgroundColor: widget.headerColor,
),
// TODO(kenz): add support for excluding column headers.
TableRow<T>.tableColumnHeader(
key: const Key('Table header'),
linkedScrollControllerGroup:
_linkedHorizontalScrollControllerGroup,
columns: widget.tableController.columns,
columnGroups: columnGroups,
columnWidths: adjustedColumnWidths,
sortColumn: sortColumn,
sortDirection: tableUiState.sortDirection,
secondarySortColumn:
widget.tableController.secondarySortColumn,
onSortChanged: widget.tableController.sortDataAndNotify,
tall: widget.tallHeaders,
backgroundColor: widget.headerColor,
),
if (pinnedData.isNotEmpty) ...[
SizedBox(
height: _pinnedDataHeight(constraints),
child: Scrollbar(
thumbVisibility: true,
controller: pinnedScrollController,
child: ListView.builder(
controller: pinnedScrollController,
itemCount: pinnedData.length,
itemExtent: widget.rowItemExtent,
itemBuilder:
(context, index) =>
_buildItem(context, index, isPinned: true),
),
),
),
const ThickDivider(),
],
Expanded(
child: Scrollbar(
thumbVisibility: true,
controller: scrollController,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: (a) => widget.focusNode?.requestFocus(),
child: Focus(
autofocus: true,
onKeyEvent:
(_, event) =>
widget.handleKeyEvent != null
? widget.handleKeyEvent!(
event,
scrollController,
constraints,
)
: KeyEventResult.ignored,
focusNode: widget.focusNode,
child: ListView.builder(
controller: scrollController,
itemCount: _dataRowCount(
constraints,
showColumnGroupHeader,
),
itemExtent: widget.rowItemExtent,
itemBuilder: _buildItem,
),
),
),
),
),
],
),
),
);
},
);
}
}