From 4b4628811ba82bad190eda2ae2e41468e61f3bc3 Mon Sep 17 00:00:00 2001 From: Andy Wolff Date: Fri, 21 Aug 2026 09:16:47 -0700 Subject: [PATCH] Migrate from LatticeScrollView to TableView --- dashboard/lib/main.dart | 5 +- dashboard/lib/widgets/lattice.dart | 1160 +++++--------------- dashboard/lib/widgets/task_grid.dart | 47 +- dashboard/pubspec.yaml | 1 + dashboard/test/widgets/task_grid_test.dart | 19 +- 5 files changed, 305 insertions(+), 927 deletions(-) diff --git a/dashboard/lib/main.dart b/dashboard/lib/main.dart index 1215e12db0..a8fc87e3d0 100644 --- a/dashboard/lib/main.dart +++ b/dashboard/lib/main.dart @@ -36,7 +36,10 @@ Usage: cocoon [--use-production-service | --no-use-production-service] } void main([List args = const []]) async { - var useProductionService = kReleaseMode; + var useProductionService = const bool.fromEnvironment( + 'USE_PRODUCTION_SERVICE', + defaultValue: kReleaseMode, + ); if (args.contains('--help')) { usage(); if (!kIsWeb) { diff --git a/dashboard/lib/widgets/lattice.dart b/dashboard/lib/widgets/lattice.dart index 18756c8257..57e462c895 100644 --- a/dashboard/lib/widgets/lattice.dart +++ b/dashboard/lib/widgets/lattice.dart @@ -3,12 +3,12 @@ // found in the LICENSE file. import 'dart:math' as math; -import 'dart:math'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart'; import 'task_box.dart'; @@ -18,22 +18,45 @@ typedef LatticeTapCallback = void Function(Offset? offset); /// A cell in a [LatticeScrollView]. @immutable -class LatticeCell extends _LatticeCell { - const LatticeCell({super.painter, this.builder, super.onTap, this.taskName}); +class LatticeCell { + const LatticeCell({this.painter, this.builder, this.onTap, this.taskName}); + final Painter? painter; final WidgetBuilder? builder; - + final LatticeTapCallback? onTap; final String? taskName; - @override bool get hasChild => builder != null; + + static const LatticeCell empty = LatticeCell(); +} + +class _LatticeVerticalScrollPhysics extends ScrollPhysics { + const _LatticeVerticalScrollPhysics({super.parent}); + + @override + _LatticeVerticalScrollPhysics applyTo(ScrollPhysics? ancestor) { + return _LatticeVerticalScrollPhysics(parent: buildParent(ancestor)); + } + + @override + bool shouldAcceptUserOffset(ScrollMetrics position) { + final keys = HardwareKeyboard.instance.logicalKeysPressed; + final isShiftPressed = + keys.contains(LogicalKeyboardKey.shiftLeft) || + keys.contains(LogicalKeyboardKey.shiftRight) || + HardwareKeyboard.instance.isShiftPressed; + + if (isShiftPressed) { + return false; + } + return super.shouldAcceptUserOffset(position); + } } /// A bidirectional scrollable view that draws arrays of arrays of [LatticeCell]s. /// -/// Only the [cells] that are visible are drawn. -/// -/// The cells will be sized according to [cellSize]. +/// Implemented using [TableView.builder] from `two_dimensional_scrollables`. class LatticeScrollView extends StatelessWidget { const LatticeScrollView({ super.key, @@ -43,988 +66,309 @@ class LatticeScrollView extends StatelessWidget { this.verticalPhysics, this.verticalController, this.dragStartBehavior = DragStartBehavior.start, + this.cacheExtent = 250.0, required this.cells, }); final ScrollPhysics? horizontalPhysics; - final ScrollController? horizontalController; - final TextDirection? textDirection; - final ScrollPhysics? verticalPhysics; - final ScrollController? verticalController; - final DragStartBehavior dragStartBehavior; - + final double cacheExtent; final List> cells; - @override - Widget build(BuildContext context) { - final textDirection = this.textDirection ?? Directionality.of(context); - return Scrollbar( - controller: horizontalController, - thumbVisibility: true, - child: Scrollable( - dragStartBehavior: dragStartBehavior, - axisDirection: textDirectionToAxisDirection(textDirection), - controller: horizontalController, - physics: horizontalPhysics, - scrollBehavior: _MouseDragScrollBehavior.instance, - viewportBuilder: - ( - BuildContext context, - ViewportOffset horizontalOffset, - ) => NotificationListener( - onNotification: (notification) => - notification.metrics.axisDirection != AxisDirection.right && - notification.metrics.axisDirection != AxisDirection.left, - child: Scrollbar( - thumbVisibility: true, - controller: verticalController, - child: Scrollable( - dragStartBehavior: dragStartBehavior, - axisDirection: AxisDirection.down, - controller: verticalController, - physics: verticalPhysics, - scrollBehavior: _MouseDragScrollBehavior.instance, - viewportBuilder: - (BuildContext context, ViewportOffset verticalOffset) => - Listener( - onPointerSignal: (PointerSignalEvent event) => - _handlePointerSignal(event, horizontalOffset), - child: _LatticeBody( - textDirection: textDirection, - horizontalOffset: horizontalOffset, - verticalOffset: verticalOffset, - cells: cells, - cellSize: Size.square(TaskBox.of(context)), - ), - ), - ), - ), - ), - ), - ); + bool get _isShiftPressed { + final keys = HardwareKeyboard.instance.logicalKeysPressed; + return keys.contains(LogicalKeyboardKey.shiftLeft) || + keys.contains(LogicalKeyboardKey.shiftRight) || + HardwareKeyboard.instance.isShiftPressed; } - /// Intercepts pointer signal events (e.g. mouse scroll wheel) over the grid. - /// - /// Redirects vertical wheel scrolling to [horizontalOffset] when the Shift key - /// is pressed, or when native horizontal scroll deltas are received. - void _handlePointerSignal( - PointerSignalEvent event, - ViewportOffset horizontalOffset, - ) { + void _handlePointerSignal(PointerSignalEvent event) { if (event case final PointerScrollEvent scrollEvent) { - final delta = _getHorizontalScrollDelta(scrollEvent); - if (delta == 0) return; - - GestureBinding.instance.pointerSignalResolver.register(scrollEvent, ( - PointerSignalEvent resolvedEvent, - ) { - if (resolvedEvent case final PointerScrollEvent resolvedScrollEvent) { - if (horizontalOffset case final ScrollPosition position - when position.hasContentDimensions) { - final resolvedDelta = _getHorizontalScrollDelta( - resolvedScrollEvent, - ); - final newOffset = (position.pixels + resolvedDelta).clamp( - position.minScrollExtent, - position.maxScrollExtent, - ); - position.jumpTo(newOffset); - } - } - }); - } - } - - /// Returns the horizontal scroll delta for a given [PointerScrollEvent]. - /// - /// Uses [PointerScrollEvent.scrollDelta.dy] if the Shift key is held down, - /// otherwise uses [PointerScrollEvent.scrollDelta.dx]. - static double _getHorizontalScrollDelta(PointerScrollEvent event) { - final isShiftPressed = HardwareKeyboard.instance.isShiftPressed; - if (isShiftPressed && event.scrollDelta.dy != 0) { - return event.scrollDelta.dy; + final delta = _isShiftPressed && scrollEvent.scrollDelta.dy != 0 + ? scrollEvent.scrollDelta.dy + : scrollEvent.scrollDelta.dx; + + if (delta != 0 && + horizontalController != null && + horizontalController!.hasClients) { + GestureBinding.instance.pointerSignalResolver.register(event, ( + PointerSignalEvent event, + ) { + final current = horizontalController!.offset; + final maxScroll = horizontalController!.position.maxScrollExtent; + final target = (current + delta).clamp(0.0, maxScroll); + horizontalController!.jumpTo(target); + }); + } } - return event.scrollDelta.dx; - } -} - -/// Used to mark classes that would be made public if the rendering object side -/// of this contraption is ever made public. -const Object _public = Object(); - -@_public -class _LatticeBody extends RenderObjectWidget { - const _LatticeBody({ - required this.textDirection, - required this.horizontalOffset, - required this.verticalOffset, - required this.cells, - required this.cellSize, - }); - - final TextDirection textDirection; - final ViewportOffset horizontalOffset; - final ViewportOffset verticalOffset; - final List> cells; - final Size cellSize; - - @override - _RenderLatticeBody createRenderObject(BuildContext context) { - return _RenderLatticeBody( - textDirection: textDirection, - horizontalOffset: horizontalOffset, - verticalOffset: verticalOffset, - cells: cells, - cellSize: cellSize, - delegate: context as _LatticeBodyElement, - ); - } - - @override - void updateRenderObject( - BuildContext context, - _RenderLatticeBody renderObject, - ) { - renderObject - ..textDirection = textDirection - ..horizontalOffset = horizontalOffset - ..verticalOffset = verticalOffset - ..cells = cells - ..cellSize = cellSize - ..delegate = context as _LatticeBodyElement; } - @override - RenderObjectElement createElement() => _LatticeBodyElement(this); -} - -@_public -class _LatticeBodyElement extends RenderObjectElement - implements _LatticeDelegate { - _LatticeBodyElement(_LatticeBody super.widget); - - @override - _LatticeBody get widget => super.widget as _LatticeBody; - - @override - _RenderLatticeBody get renderObject => - super.renderObject as _RenderLatticeBody; - - // This element uses _Coordinate objects as slots. + void _handleTapUp(TapUpDetails details, double cellSize) { + final scrollX = horizontalController?.hasClients == true + ? horizontalController!.offset + : 0.0; + final scrollY = verticalController?.hasClients == true + ? verticalController!.offset + : 0.0; - Map _newChildrenByKey = {}; - Map? _oldChildrenByKey; - Map<_Coordinate, Element?> _newChildrenByCoordinate = - <_Coordinate, Element?>{}; - Map<_Coordinate, Element?>? _oldChildrenByCoordinate; + final dx = details.localPosition.dx; + final dy = details.localPosition.dy; - @override - void beginLayout() { - _oldChildrenByKey = _newChildrenByKey; - _newChildrenByKey = {}; - _oldChildrenByCoordinate = _newChildrenByCoordinate; - _newChildrenByCoordinate = <_Coordinate, Element?>{}; - } + final int col; + if (dx <= cellSize) { + col = 0; + } else { + col = ((dx - cellSize + scrollX) / cellSize).floor() + 1; + } - @override - RenderBox? updateLatticeChild( - _Coordinate coordinate, - LatticeCell cell, - RenderBox? oldChild, - ) { - Widget? newWidget; - Element? newElement; - owner!.buildScope(this, () { - try { - newWidget = cell.builder!(this); - debugWidgetBuilderValue(widget, newWidget); - } catch (exception, stack) { - newWidget = ErrorWidget.builder( - _debugReportException( - FlutterErrorDetails( - context: ErrorDescription( - 'building cell $coordinate for $widget', - ), - exception: exception, - stack: stack, - library: 'Flutter Dashboard', - informationCollector: () sync* { - yield DiagnosticsDebugCreator(DebugCreator(this)); - }, - ), - ), - ); - } - Element? oldElement; - if (newWidget!.key != null) { - oldElement = _oldChildrenByKey![newWidget!.key]; - if (oldElement != null) { - _oldChildrenByKey![newWidget!.key] = - null; // null indicates it exists but is not in the grid - _oldChildrenByCoordinate!.remove(oldElement.slot as _Coordinate?); - } - } else { - oldElement = _oldChildrenByCoordinate![coordinate]; - if (oldElement != null && oldElement.widget.key != null) { - oldElement = null; - } - _oldChildrenByCoordinate!.remove(coordinate); - } - try { - newElement = updateChild(oldElement, newWidget, coordinate); - } catch (e, stack) { - newWidget = ErrorWidget.builder( - _debugReportException( - FlutterErrorDetails( - context: ErrorDescription( - 'building widget $newWidget at cell $coordinate for $widget', - ), - exception: e, - stack: stack, - library: 'Flutter Dashboard', - informationCollector: () sync* { - yield DiagnosticsDebugCreator(DebugCreator(this)); - }, - ), - ), - ); - newElement = updateChild(null, newWidget, slot); - } - }); - assert(newElement!.slot == coordinate); - if (newWidget!.key != null) { - _newChildrenByKey[newWidget!.key] = newElement; + final int row; + if (dy <= cellSize) { + row = 0; + } else { + row = ((dy - cellSize + scrollY) / cellSize).floor() + 1; } - _newChildrenByCoordinate[coordinate] = newElement; - return newElement!.renderObject as RenderBox?; - } - @override - void endLayout() { - for (final oldChild in _oldChildrenByCoordinate!.values) { - if (oldChild!.widget.key == null) { - updateChild(oldChild, null, null); + if (row >= 0 && row < cells.length && col >= 0 && col < cells[row].length) { + final cell = cells[row][col]; + if (cell.onTap != null) { + final screenX = col == 0 ? 0.0 : (col * cellSize - scrollX); + final screenY = row == 0 ? 0.0 : (row * cellSize - scrollY); + cell.onTap!(Offset(screenX, screenY)); } } - for (final oldChild in _oldChildrenByKey!.values) { - updateChild(oldChild, null, null); - } - _oldChildrenByKey = null; - _oldChildrenByCoordinate = null; } @override - void forgetChild(Element child) { - if (child.widget.key != null) { - _newChildrenByKey.remove(child.widget.key); + Widget build(BuildContext context) { + if (cells.isEmpty || cells.first.isEmpty) { + return const SizedBox.shrink(); } - _newChildrenByCoordinate.remove(child.slot as _Coordinate?); - super.forgetChild(child); - } - @override - void insertRenderObjectChild(RenderObject child, _Coordinate? slot) { - renderObject.placeChild(null, slot, null, child as RenderBox); - } - - @override - void moveRenderObjectChild( - RenderObject child, - _Coordinate? oldSlot, - _Coordinate? newSlot, - ) { - renderObject.placeChild( - oldSlot, - newSlot, - child as RenderBox?, - child as RenderBox, + final rowCount = cells.length; + final columnCount = cells.fold( + 0, + (max, row) => math.max(max, row.length), ); - } - @override - void removeRenderObjectChild(RenderObject child, _Coordinate? slot) { - renderObject.removeChild(slot, child as RenderBox); - } + final cellSize = TaskBox.of(context); + final span = TableSpan(extent: FixedTableSpanExtent(cellSize)); - @override - void visitChildren(ElementVisitor visitor) { - (_newChildrenByCoordinate.values.whereType().toList() - ..sort(_compareChildren)) - .forEach(visitor); - } - - int _compareChildren(Element a, Element b) { - final aSlot = a.slot as _Coordinate; - final bSlot = b.slot as _Coordinate; - return aSlot.compareTo(bSlot); - } + final effectiveVerticalPhysics = _LatticeVerticalScrollPhysics( + parent: verticalPhysics ?? const ClampingScrollPhysics(), + ); - @override - List debugDescribeChildren() { - final children = - _newChildrenByCoordinate.values.whereType().toList() - ..sort(_compareChildren); - return children.map((Element? child) { - return child!.toDiagnosticsNode( - name: child.slot != null ? '${child.slot}' : '(lost)', - ); - }).toList(); + return RepaintBoundary( + child: Listener( + onPointerSignal: _handlePointerSignal, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTapUp: (TapUpDetails details) => _handleTapUp(details, cellSize), + child: Scrollbar( + controller: horizontalController, + thumbVisibility: true, + notificationPredicate: (notification) => + notification.metrics.axis == Axis.horizontal, + child: Scrollbar( + controller: verticalController, + thumbVisibility: true, + notificationPredicate: (notification) => + notification.metrics.axis == Axis.vertical, + child: TableView.builder( + cacheExtent: cacheExtent, + diagonalDragBehavior: DiagonalDragBehavior.free, + pinnedRowCount: rowCount > 1 ? 1 : 0, + pinnedColumnCount: columnCount > 1 ? 1 : 0, + columnCount: columnCount, + rowCount: rowCount, + rowBuilder: (int row) => span, + columnBuilder: (int column) => span, + cellBuilder: (BuildContext context, TableVicinity vicinity) { + final y = vicinity.row; + final x = vicinity.column; + + if (y >= cells.length || x >= cells[y].length) { + return TableViewCell( + key: ValueKey(vicinity), + addRepaintBoundaries: false, + child: const SizedBox.shrink(), + ); + } + + final cell = cells[y][x]; + Widget child; + + if (cell.builder != null) { + final innerChild = cell.builder!(context); + if (cell.painter != null) { + child = _LatticeCellChildBox( + cellSize: cellSize, + painter: cell.painter, + child: innerChild, + ); + } else { + child = innerChild; + } + } else if (cell.painter != null) { + child = _LatticeCellBox( + cellSize: cellSize, + painter: cell.painter, + ); + } else { + child = const SizedBox.shrink(); + } + + return TableViewCell( + key: ValueKey(vicinity), + addRepaintBoundaries: false, + child: Listener( + onPointerSignal: _handlePointerSignal, + child: child, + ), + ); + }, + verticalDetails: ScrollableDetails.vertical( + controller: verticalController, + physics: effectiveVerticalPhysics, + ), + horizontalDetails: ScrollableDetails.horizontal( + controller: horizontalController, + physics: horizontalPhysics, + ), + ), + ), + ), + ), + ), + ); } } -@immutable -@_public -class _Coordinate implements Comparable<_Coordinate> { - const _Coordinate(this.x, this.y); - - final int x; +class _LatticeCellBox extends LeafRenderObjectWidget { + const _LatticeCellBox({required this.cellSize, this.painter}); - final int y; + final double cellSize; + final Painter? painter; @override - int compareTo(_Coordinate other) { - if (y == other.y) { - return x - other.x; - } - return y - other.y; + RenderObject createRenderObject(BuildContext context) { + return _RenderLatticeCellBox(cellSize: cellSize, painter: painter); } @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) { - return false; - } - return other is _Coordinate && other.x == x && other.y == y; + void updateRenderObject( + BuildContext context, + _RenderLatticeCellBox renderObject, + ) { + renderObject + ..cellSize = cellSize + ..painter = painter; } - - @override - int get hashCode => Object.hash(x, y); - - @override - String toString() => '($x,$y)'; - - Offset asOffset(Size cellSize) => - Offset(x.toDouble() * cellSize.width, y.toDouble() * cellSize.height); -} - -@_public -class _LatticeParentData extends ParentData { - _Coordinate? coordinate; -} - -@immutable -@_public -class _LatticeCell { - const _LatticeCell({this.painter, this.onTap}); - - static const _LatticeCell empty = _LatticeCell(); - - final Painter? painter; - - final LatticeTapCallback? onTap; - - @protected - bool get hasChild => false; -} - -@_public -abstract class _LatticeDelegate { - const _LatticeDelegate(); - void beginLayout(); - RenderBox? updateLatticeChild( - _Coordinate coordinate, - covariant _LatticeCell cell, - RenderBox? oldChild, - ); - void endLayout(); } -@_public -class _RenderLatticeBody extends RenderBox { - _RenderLatticeBody({ - required TextDirection textDirection, - required ViewportOffset horizontalOffset, - required ViewportOffset verticalOffset, - required List> cells, - required Size cellSize, - required _LatticeDelegate delegate, - }) : assert(!cellSize.isEmpty), - _textDirection = textDirection, - _horizontalOffset = horizontalOffset, - _verticalOffset = verticalOffset, - _cells = cells, - _cellSize = cellSize, - _delegate = delegate { - _handleOffsetChange(); - _recomputeCellDimensions(); - } - - TextDirection get textDirection => _textDirection; - TextDirection _textDirection; - set textDirection(TextDirection value) { - if (value == _textDirection) { - return; - } - _textDirection = value; - markNeedsPaint(); - } - - ViewportOffset get horizontalOffset => _horizontalOffset; - ViewportOffset _horizontalOffset; - set horizontalOffset(ViewportOffset value) { - if (value == _horizontalOffset) { - return; - } - if (attached) { - _horizontalOffset.removeListener(_handleOffsetChange); - } - _horizontalOffset = value; - if (attached) { - _horizontalOffset.addListener(_handleOffsetChange); - } - _handleOffsetChange(); - } - - ViewportOffset get verticalOffset => _verticalOffset; - ViewportOffset _verticalOffset; - set verticalOffset(ViewportOffset value) { - if (value == _verticalOffset) { - return; - } - if (attached) { - _verticalOffset.removeListener(_handleOffsetChange); - } - _verticalOffset = value; - if (attached) { - _verticalOffset.addListener(_handleOffsetChange); - } - _handleOffsetChange(); - } - - int _cellWidthCount = 0, _cellHeightCount = 0; +class _RenderLatticeCellBox extends RenderBox { + _RenderLatticeCellBox({required double cellSize, Painter? painter}) + : _cellSize = cellSize, + _painter = painter; - List> get cells => _cells; - List> _cells; - set cells(List> value) { - if (value == _cells) { - return; - } - _cells = value; - markNeedsLayout(); - _recomputeCellDimensions(); - } - - void _recomputeCellDimensions() { - _cellWidthCount = cells.fold( - 0, - (int current, List<_LatticeCell> row) => math.max(current, row.length), - ); - _cellHeightCount = cells.length; - _handleOffsetChange(); - } - - Size get cellSize => _cellSize; - Size _cellSize; - set cellSize(Size value) { - assert(!value.isEmpty); - if (value == _cellSize) { - return; - } + double get cellSize => _cellSize; + double _cellSize; + set cellSize(double value) { + if (_cellSize == value) return; _cellSize = value; markNeedsLayout(); } - _LatticeDelegate get delegate => _delegate; - _LatticeDelegate _delegate; - set delegate(_LatticeDelegate value) { - if (value == _delegate) { - return; - } - _delegate = value; - markNeedsLayout(); - } - - // TODO(ianh): rather than store and paint the children directly in - // this render object, dynamically create _RenderLatticeTiles that - // handle cacheStride x cacheStride sections of the grid. This would - // give us more efficient scrolling since we would not need to - // update them. We would need to make sure to mark them all as - // needing layout when the list of widgets changed. - // - // Currently, we have to repaint everything when we scroll because - // we have no way to cache the paint in a layer. - - _LatticeCell? _getCellFor(_Coordinate coordinate) { - if (coordinate.y < 0 || coordinate.x < 0) { - return null; - } - if (coordinate.y >= cells.length) { - return null; - } - if (coordinate.x >= cells[coordinate.y].length) { - return null; - } - return cells[coordinate.y][coordinate.x]; - } - - bool _hasTapHandler(_Coordinate coordinate) { - return _getCellFor(coordinate)?.onTap != null; - } - - final Map<_Coordinate?, RenderBox> _childrenByCoordinate = - <_Coordinate?, RenderBox>{}; - - void placeChild( - _Coordinate? oldCoordinate, - _Coordinate? newCoordinate, - RenderBox? oldChild, - RenderBox newChild, - ) { - if (oldChild == newChild) { - return; - } - if (oldChild != null) { - final oldChildParentData = oldChild.parentData as _LatticeParentData; - oldChildParentData.coordinate = null; - } - if (oldCoordinate != null) { - _childrenByCoordinate.remove(oldCoordinate); - } - _childrenByCoordinate[newCoordinate] = newChild; - if (newChild.parent != this) { - adoptChild(newChild); - } - final newChildParentData = newChild.parentData as _LatticeParentData; - newChildParentData.coordinate = newCoordinate; - } - - void removeChild(_Coordinate? coordinate, RenderBox child) { - if (coordinate != null) { - _childrenByCoordinate.remove(coordinate); - } - dropChild(child); - } - - @override - void setupParentData(RenderObject child) { - if (child.parentData is! ParentData) { - child.parentData = _LatticeParentData(); - } + Painter? get painter => _painter; + Painter? _painter; + set painter(Painter? value) { + if (_painter == value) return; + _painter = value; + markNeedsPaint(); } - TapGestureRecognizer? _tap; - @override - void attach(PipelineOwner owner) { - super.attach(owner); - _horizontalOffset.addListener(_handleOffsetChange); - _verticalOffset.addListener(_handleOffsetChange); - _tap = TapGestureRecognizer(debugOwner: this) - ..onTapDown = _handleTapDown - ..onTapUp = _handleTapUp; - for (final child in _childrenByCoordinate.values) { - child.attach(owner); - } + void performLayout() { + size = Size(cellSize, cellSize); } @override - void detach() { - super.detach(); - _horizontalOffset.removeListener(_handleOffsetChange); - _verticalOffset.removeListener(_handleOffsetChange); - _tap?.dispose(); - for (final child in _childrenByCoordinate.values) { - child.detach(); + void paint(PaintingContext context, Offset offset) { + if (_painter != null) { + _painter!(context.canvas, offset & size); } } +} - @override - void dispose() { - super.dispose(); - _clipLabelColumnHandle.layer = null; - _clipLabelRowHandle.layer = null; - _clipDataHandle.layer = null; - } - - @override - void redepthChildren() { - _childrenByCoordinate.values.forEach(redepthChild); - } - - @override - void visitChildren(RenderObjectVisitor visitor) { - _childrenByCoordinate.values.forEach(visitor); - } - - @override - bool get isRepaintBoundary => true; - - @override - double computeMinIntrinsicWidth(double? height) { - return _cellWidthCount * cellSize.width; - } +class _LatticeCellChildBox extends SingleChildRenderObjectWidget { + const _LatticeCellChildBox({ + required this.cellSize, + this.painter, + required Widget super.child, + }); - @override - double computeMaxIntrinsicWidth(double height) { - return computeMinIntrinsicWidth(height); - } + final double cellSize; + final Painter? painter; @override - double computeMinIntrinsicHeight(double? width) { - return _cellHeightCount * cellSize.height; + RenderObject createRenderObject(BuildContext context) { + return _RenderLatticeCellChildBox(cellSize: cellSize, painter: painter); } @override - double computeMaxIntrinsicHeight(double width) { - return computeMinIntrinsicHeight(width); + void updateRenderObject( + BuildContext context, + _RenderLatticeCellChildBox renderObject, + ) { + renderObject + ..cellSize = cellSize + ..painter = painter; } +} - @override - bool get sizedByParent => true; +class _RenderLatticeCellChildBox extends RenderBox + with RenderObjectWithChildMixin { + _RenderLatticeCellChildBox({required double cellSize, Painter? painter}) + : _cellSize = cellSize, + _painter = painter; - @override - void performResize() { - size = Size( - constraints.hasBoundedWidth - ? constraints.maxWidth - : constraints.constrainWidth(computeMinIntrinsicWidth(null)), - constraints.hasBoundedHeight - ? constraints.maxHeight - : constraints.constrainHeight(computeMinIntrinsicHeight(null)), - ); - horizontalOffset.applyViewportDimension(size.width); - verticalOffset.applyViewportDimension(size.height); - _handleOffsetChange(duringLayout: true); + double get cellSize => _cellSize; + double _cellSize; + set cellSize(double value) { + if (_cellSize == value) return; + _cellSize = value; + markNeedsLayout(); } - Offset? _scrollOffset; - int? _firstX, _firstY, _lastX, _lastY; - - void _handleOffsetChange({bool duringLayout = false}) { - if (!hasSize) { - assert(_scrollOffset == null); - return; - } - final scrollOffset = Offset(horizontalOffset.pixels, verticalOffset.pixels); - final firstX = scrollOffset.dx ~/ cellSize.width; - final lastX = ((scrollOffset.dx + size.width) / cellSize.width).ceil() - 1; - final firstY = scrollOffset.dy ~/ cellSize.height; - final lastY = - math.min( - ((scrollOffset.dy + size.height) / cellSize.height).ceil(), - _cellHeightCount, - ) - - 1; - if (scrollOffset != _scrollOffset) { - _scrollOffset = scrollOffset; - markNeedsPaint(); - } - if (firstX != _firstX || - lastX != _lastX || - firstY != _firstY || - lastY != _lastY) { - _firstX = firstX; - _lastX = lastX; - _firstY = firstY; - _lastY = lastY; - if (!duringLayout) { - markNeedsLayout(); - } - } + Painter? get painter => _painter; + Painter? _painter; + set painter(Painter? value) { + if (_painter == value) return; + _painter = value; + markNeedsPaint(); } - /// Lays out only the visible cells in the lattice view. - /// - /// To maintain high scrolling performance on large grids, layout is restricted - /// to the top-left corner cell, the visible sticky header row/column, and the - /// visible data cells within current viewport bounds [_firstX, _lastX] and [_firstY, _lastY]. @override void performLayout() { - assert(_scrollOffset != null); - final childConstraints = BoxConstraints.tight(cellSize); - invokeLayoutCallback((BoxConstraints constraints) { - delegate.beginLayout(); - }); - - void layoutCell(_Coordinate here) { - assert(here.y < cells.length); - final cell = here.x < cells[here.y].length - ? cells[here.y][here.x] - : _LatticeCell.empty; - if (cell.hasChild) { - RenderBox? child; - invokeLayoutCallback((BoxConstraints constraints) { - child = delegate.updateLatticeChild( - here, - cell, - _childrenByCoordinate[here], - ); - }); - assert(child != null); - assert(child!.parent == this); - assert(_childrenByCoordinate[here] == child); - child!.layout(childConstraints); - } - } - - if (_cellHeightCount > 0 && _cellWidthCount > 0) { - // Top-left corner cell (0, 0) - layoutCell(const _Coordinate(0, 0)); - - final minX = math.max(1, _firstX ?? 0); - final maxX = math.min(_lastX ?? 0, _cellWidthCount - 1); - final minY = math.max(1, _firstY ?? 0); - final maxY = math.min(_lastY ?? 0, _cellHeightCount - 1); - - // Header column (x = 0) - for (var y = minY; y <= maxY; y += 1) { - layoutCell(_Coordinate(0, y)); - } - - // Header row (y = 0) - for (var x = minX; x <= maxX; x += 1) { - layoutCell(_Coordinate(x, 0)); - } - - // Data cells inside viewport - for (var y = minY; y <= maxY; y += 1) { - for (var x = minX; x <= maxX; x += 1) { - layoutCell(_Coordinate(x, y)); - } - } - } - - invokeLayoutCallback((BoxConstraints constraints) { - delegate.endLayout(); - }); - horizontalOffset.applyContentDimensions( - 0.0, - math.max(0.0, computeMinIntrinsicWidth(null) - size.width), - ); - verticalOffset.applyContentDimensions( - 0.0, - math.max(0.0, computeMinIntrinsicHeight(null) - size.height), - ); - } - - final LayerHandle _clipLabelRowHandle = - LayerHandle(); - final LayerHandle _clipLabelColumnHandle = - LayerHandle(); - final LayerHandle _clipDataHandle = - LayerHandle(); - - void _paintCell(PaintingContext context, Offset offset, int x, int y) { - final here = _Coordinate(x, y); - assert(y < cells.length); - final cell = x < cells[y].length ? cells[y][x] : _LatticeCell.empty; - final topLeft = _coordinateToOffset(here)! + offset; - final painter = cell.painter; - final child = cell.hasChild ? _childrenByCoordinate[here] : null; - assert(child == _childrenByCoordinate[here]); - assert(cell.hasChild == (child != null)); - if (painter != null) { - painter(context.canvas, topLeft & cellSize); - } - if (child != null) { - context.paintChild(child, topLeft); - } + size = Size(cellSize, cellSize); + child?.layout(BoxConstraints.tight(size), parentUsesSize: false); } @override void paint(PaintingContext context, Offset offset) { - assert(needsCompositing); - final dataOffset = Offset(cellSize.width, cellSize.height); - final dataSize = size - dataOffset as Size; - if (dataSize.isEmpty || - _firstX == null || - _firstY == null || - _lastX == null || - _lastY == null) { - return; - } - _clipLabelColumnHandle.layer = context.pushClipRect( - needsCompositing, - offset, - Rect.fromLTWH(0, dataOffset.dy, cellSize.width, dataSize.height), - (PaintingContext context, Offset offset) { - for (int y = max(1, _firstY!); y <= _lastY!; y += 1) { - _paintCell(context, offset, 0, y); - } - }, - oldLayer: _clipLabelColumnHandle.layer, - ); - _clipLabelRowHandle.layer = context.pushClipRect( - needsCompositing, - offset, - Rect.fromLTWH(dataOffset.dx, 0, dataSize.width, cellSize.height), - (PaintingContext context, Offset offset) { - for (int x = max(1, _firstX!); x <= _lastX!; x += 1) { - _paintCell(context, offset, x, 0); - } - }, - oldLayer: _clipLabelRowHandle.layer, - ); - _clipDataHandle.layer = context.pushClipRect( - needsCompositing, - offset, - dataOffset & dataSize, - (PaintingContext context, Offset offset) { - for (var y = _firstY! + 1; y <= _lastY!; y += 1) { - for (var x = _firstX! + 1; x <= _lastX!; x += 1) { - _paintCell(context, offset, x, y); - } - } - }, - oldLayer: _clipDataHandle.layer, - ); - } - - @override - void applyPaintTransform(RenderBox child, Matrix4 transform) { - final childParentData = child.parentData as _LatticeParentData; - final offset = _coordinateToOffset(childParentData.coordinate!)!; - transform.translateByDouble(offset.dx, offset.dy, 0.0, 1.0); - } - - @override - Rect describeApproximatePaintClip(RenderObject child) => Offset.zero & size; - - @override - void showOnScreen({ - RenderObject? descendant, - Rect? rect, - Duration duration = Duration.zero, - Curve curve = Curves.ease, - }) { - if (descendant != null) { - // TODO(ianh): Implement this. Not having this implemented means - // accessibility scrolling won't work for this viewport. - // - // The implementation should honor allowImplicitScrolling on - // horizontalOffset and verticalOffset, descendant and rect, and - // duration and curve. (If duration is Duration.zero, use jumpTo - // on the offsets, otherwise use animateTo.) - } - super.showOnScreen(rect: rect, duration: duration, curve: curve); - } - - _Coordinate? _offsetToCoordinate(Offset? position) { - late Offset absolute; - switch (textDirection) { - case TextDirection.rtl: - absolute = Offset( - position!.dx - _scrollOffset!.dx, - position.dy + _scrollOffset!.dy, - ); - break; - case TextDirection.ltr: - absolute = position! + _scrollOffset!; - break; - } - switch (textDirection) { - case TextDirection.rtl: - return _Coordinate( - position.dx + cellSize.width > size.width - ? 0 - : (size.width - absolute.dx) ~/ cellSize.width, - position.dy < cellSize.height ? 0 : absolute.dy ~/ cellSize.height, - ); - case TextDirection.ltr: - return _Coordinate( - position.dx < cellSize.width ? 0 : absolute.dx ~/ cellSize.width, - position.dy < cellSize.height ? 0 : absolute.dy ~/ cellSize.height, - ); + if (_painter != null) { + _painter!(context.canvas, offset & size); } - } - - Offset? _coordinateToOffset(_Coordinate coordinate) { - final adjustedScroll = Offset( - coordinate.x == 0 ? 0 : _scrollOffset!.dx, - coordinate.y == 0 ? 0 : _scrollOffset!.dy, - ); - switch (textDirection) { - case TextDirection.rtl: - return Offset( - size.width - - (coordinate.x * cellSize.width) - - cellSize.width + - adjustedScroll.dx, - coordinate.y * cellSize.height - adjustedScroll.dy, - ); - case TextDirection.ltr: - return Offset( - coordinate.x * cellSize.width, - coordinate.y * cellSize.height, - ) - - adjustedScroll; + if (child != null) { + context.paintChild(child!, offset); } } @override - bool hitTestChildren(BoxHitTestResult result, {Offset? position}) { - final coordinate = _offsetToCoordinate(position); - final child = _childrenByCoordinate[coordinate]; - return child != null && - result.addWithPaintOffset( - offset: _coordinateToOffset(coordinate!), - position: position!, - hitTest: (BoxHitTestResult result, Offset transformed) { - return child.hitTest(result, position: transformed); - }, - ); - } - - @override - bool hitTestSelf(Offset position) => true; - - @override - void handleEvent(PointerEvent event, BoxHitTestEntry entry) { - assert(debugHandleEvent(event, entry)); - final coordinate = _offsetToCoordinate(event.localPosition); - if (event is PointerDownEvent && _hasTapHandler(coordinate!)) { - _tap?.addPointer(event); - } - } - - _Coordinate? _lastTapDown; - - void _handleTapDown(TapDownDetails details) { - _lastTapDown = _offsetToCoordinate(details.localPosition); - } - - void _handleTapUp(TapUpDetails details) { - final lastTapUp = _offsetToCoordinate(details.localPosition); - if (_lastTapDown == lastTapUp && _hasTapHandler(lastTapUp!)) { - _getCellFor(_lastTapDown!)!.onTap!(_coordinateToOffset(lastTapUp)); - } + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return child?.hitTest(result, position: position) ?? false; } - - @override - Rect describeSemanticsClip(RenderObject? child) => - (Offset.zero & size).inflate(cellSize.longestSide); -} - -FlutterErrorDetails _debugReportException(FlutterErrorDetails details) { - FlutterError.reportError(details); - return details; -} - -/// A [MaterialScrollBehavior] that supports mouse dragging. -class _MouseDragScrollBehavior extends MaterialScrollBehavior { - static _MouseDragScrollBehavior? _instance; - static _MouseDragScrollBehavior get instance => - _instance ??= _MouseDragScrollBehavior(); - - @override - Set get dragDevices => { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }; } diff --git a/dashboard/lib/widgets/task_grid.dart b/dashboard/lib/widgets/task_grid.dart index c64be8ee93..8a2f01333b 100644 --- a/dashboard/lib/widgets/task_grid.dart +++ b/dashboard/lib/widgets/task_grid.dart @@ -143,40 +143,61 @@ const Map _statusScores = { }; class _TaskGridState extends State { - // TODO(ianh): Cache the lattice cells. Right now we are regenerating the entire - // lattice matrix each time the task grid has to update, regardless of whether - // we've received new data or not. - ScrollController? verticalController; ScrollController? horizontalController; + List>? _cachedCells; + List? _lastCommitStatuses; + TaskGridFilter? _lastFilter; + @override void initState() { super.initState(); verticalController ??= ScrollController(); horizontalController ??= ScrollController(); - widget.filter?.addListener(() { - setState(() {}); + widget.filter?.addListener(_handleFilterChange); + } + + @override + void didUpdateWidget(TaskGrid oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.filter != widget.filter) { + oldWidget.filter?.removeListener(_handleFilterChange); + widget.filter?.addListener(_handleFilterChange); + _cachedCells = null; + } + } + + void _handleFilterChange() { + setState(() { + _cachedCells = null; }); } @override void dispose() { + widget.filter?.removeListener(_handleFilterChange); verticalController?.dispose(); horizontalController?.dispose(); super.dispose(); } + List> _getCells(TaskGrid widget) { + if (_cachedCells != null && + identical(_lastCommitStatuses, widget.commitStatuses) && + identical(_lastFilter, widget.filter)) { + return _cachedCells!; + } + _lastCommitStatuses = widget.commitStatuses; + _lastFilter = widget.filter; + _cachedCells = _processCommitStatuses(widget); + return _cachedCells!; + } + @override Widget build(BuildContext context) { return LatticeScrollView( - // TODO(ianh): Provide some vertical scroll physics that disable - // the clamping in the vertical direction, so that you can keep - // scrolling past the end instead of hitting a wall every time - // we load. - // TODO(ianh): Trigger the loading from the scroll offset, - // rather than the current hack of loading during build. - cells: _processCommitStatuses(widget), + cells: _getCells(widget), verticalController: verticalController, horizontalController: horizontalController, ); diff --git a/dashboard/pubspec.yaml b/dashboard/pubspec.yaml index b4bc7ca355..23cea56353 100644 --- a/dashboard/pubspec.yaml +++ b/dashboard/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: provider: ^6.1.5+1 # Rolled by dependabot sign_in_button: ^5.0.0 truncate: 3.0.1 # Rolled by dependabot + two_dimensional_scrollables: ^0.3.0 url_launcher: 6.3.2 # Rolled by dependabot url_launcher_platform_interface: 2.3.2 # Rolled by dependabot url_launcher_web: ^2.4.3 # Rolled by dependabot diff --git a/dashboard/test/widgets/task_grid_test.dart b/dashboard/test/widgets/task_grid_test.dart index 01f2b68790..1682386fb5 100644 --- a/dashboard/test/widgets/task_grid_test.dart +++ b/dashboard/test/widgets/task_grid_test.dart @@ -79,7 +79,10 @@ void main() { await tester.pump(); final commitCount = tester.elementList(find.byType(CommitBox)).length; - expect(commitCount, 16); // based on screen size this is how many show up + expect( + commitCount, + 23, + ); // based on screen size + cacheExtent this is how many show up final xPosition = tester.getTopLeft(find.byType(CommitBox).first).dx; @@ -146,7 +149,10 @@ void main() { await tester.pump(); final commitCount = tester.elementList(find.byType(CommitBox)).length; - expect(commitCount, 16); // based on screen size this is how many show up + expect( + commitCount, + 23, + ); // based on screen size + cacheExtent this is how many show up final xPosition = tester.getTopLeft(find.byType(CommitBox).first).dx; @@ -236,7 +242,10 @@ void main() { await tester.pump(); final commitCount = tester.elementList(find.byType(CommitBox)).length; - expect(commitCount, 16); // based on screen size this is how many show up + expect( + commitCount, + 23, + ); // based on screen size + cacheExtent this is how many show up final xPosition = tester.getTopLeft(find.byType(CommitBox).first).dx; @@ -336,7 +345,7 @@ void main() { await testGrid( tester, TaskGridFilter()..authorFilter = RegExp('yegor'), - 4, + 5, 101, ); await testGrid( @@ -349,7 +358,7 @@ void main() { tester, TaskGridFilter() ..hashFilter = RegExp('fb75b2b671c7702b549a80a420144097f4fab5a9'), - 2, // codefu: these are magic numbers and this test is bad. + 3, // codefu: these are magic numbers and this test is bad. 101, ); });