From 3e1aaf962c2665f35b224cde33dd06bb5cd40cf5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 25 Sep 2026 11:06:10 +0300 Subject: [PATCH] fix(grid): support mouse drag range selection across cells in ResultGridView (Closes #904) --- lib/features/workspace/result_grid_view.dart | 265 +++++++++++++++++- .../result_grid_drag_selection_test.dart | 165 +++++++++++ 2 files changed, 421 insertions(+), 9 deletions(-) create mode 100644 test/features/workspace/result_grid_drag_selection_test.dart diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index ce57c8b..19d3363 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -1,8 +1,10 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/foundation.dart' as foundation; -import 'package:flutter/gestures.dart' show kSecondaryMouseButton; +import 'package:flutter/gestures.dart' + show kPrimaryMouseButton, kSecondaryMouseButton; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show Clipboard, ClipboardData, HardwareKeyboard, LogicalKeyboardKey; @@ -705,6 +707,17 @@ class _VirtualResultGridState extends material.State { int _lastKnownStagedRowCount = -1; + bool _isPointerDown = false; + bool _isDragSelecting = false; + bool _hasJustDragSelected = false; + Offset _pointerDownGlobalPos = Offset.zero; + Offset _pointerDownLocalPos = Offset.zero; + Offset? _lastPointerLocalPos; + Timer? _autoScrollTimer; + List _currentDisplayOffsets = const [0]; + double _currentAvailableWidth = 0.0; + double _currentRowsViewportHeight = 0.0; + @override void initState() { super.initState(); @@ -765,6 +778,7 @@ class _VirtualResultGridState extends material.State { @override void dispose() { + _stopAutoScroll(); widget.stagingBuffer?.removeListener(_onStagingBufferChanged); _horizontalController.removeListener(_onHorizontalScroll); _horizontalController.dispose(); @@ -1098,7 +1112,223 @@ class _VirtualResultGridState extends material.State { } } + ResultGridCellCoordinate? _cellAtOffset({ + required Offset localPosition, + required double rowHeight, + }) { + if (_sortedRows.isEmpty || widget.columns.isEmpty) return null; + final rowCount = _sortedRows.length; + final colCount = widget.columns.length; + + final verticalOffset = + _verticalController.hasClients ? _verticalController.offset : 0.0; + final tableY = localPosition.dy + verticalOffset; + final rowIndex = (tableY / rowHeight).floor().clamp(0, rowCount - 1); + + final tableX = localPosition.dx; + final offsets = _currentDisplayOffsets.length >= colCount + 1 + ? _currentDisplayOffsets + : _columnOffsets; + int colIndex = colCount - 1; + for (var i = 0; i < colCount; i++) { + if (i + 1 < offsets.length && tableX < offsets[i + 1]) { + colIndex = i; + break; + } + } + colIndex = colIndex.clamp(0, colCount - 1); + + return ResultGridCellCoordinate(rowIndex, colIndex); + } + + void _onGridPointerDown( + PointerDownEvent event, { + required double rowHeight, + }) { + if (event.buttons != kPrimaryMouseButton) return; + if (_sortedRows.isEmpty || widget.columns.isEmpty) return; + + if (_editingCell != null) { + _cancelEdit(); + } + + _isPointerDown = true; + _isDragSelecting = false; + _hasJustDragSelected = false; + _pointerDownGlobalPos = event.position; + _pointerDownLocalPos = event.localPosition; + _lastPointerLocalPos = event.localPosition; + } + + void _onGridPointerMove( + PointerMoveEvent event, { + required double rowHeight, + }) { + if (!_isPointerDown) return; + _lastPointerLocalPos = event.localPosition; + + if (!_isDragSelecting) { + final delta = (event.position - _pointerDownGlobalPos).distance; + if (delta > 4.0) { + _isDragSelecting = true; + _hasJustDragSelected = false; + final startCell = _cellAtOffset( + localPosition: _pointerDownLocalPos, + rowHeight: rowHeight, + ); + if (startCell != null) { + final isShift = HardwareKeyboard.instance.isShiftPressed; + if (!isShift || _selectionAnchor == null) { + _selectionAnchor = startCell; + } + _selectionFocus = startCell; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor!, + focus: startCell, + ); + _focusNode.requestFocus(); + final modelRow = _toModelRowIndex(startCell.row); + widget.onRowSelected?.call(modelRow); + _notifySelectionAndFocus(); + setState(() {}); + } + _startAutoScroll(rowHeight); + } + } + + if (_isDragSelecting) { + final currentCell = _cellAtOffset( + localPosition: event.localPosition, + rowHeight: rowHeight, + ); + if (currentCell != null && currentCell != _selectionFocus) { + setState(() { + _selectionFocus = currentCell; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor ?? currentCell, + focus: currentCell, + ); + }); + final modelRow = _toModelRowIndex(currentCell.row); + widget.onRowSelected?.call(modelRow); + _notifySelectionAndFocus(); + } + } + } + + void _onGridPointerUp(PointerUpEvent event) { + if (_isDragSelecting) { + _hasJustDragSelected = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _hasJustDragSelected = false; + }); + } + _isPointerDown = false; + _isDragSelecting = false; + _stopAutoScroll(); + } + + void _onGridPointerCancel(PointerCancelEvent event) { + _isPointerDown = false; + _isDragSelecting = false; + _stopAutoScroll(); + } + + void _startAutoScroll(double rowHeight) { + _autoScrollTimer?.cancel(); + _autoScrollTimer = Timer.periodic(const Duration(milliseconds: 20), (_) { + if (!_isDragSelecting || _lastPointerLocalPos == null || !mounted) { + _stopAutoScroll(); + return; + } + _performAutoScrollAndSelection(rowHeight); + }); + } + + void _stopAutoScroll() { + _autoScrollTimer?.cancel(); + _autoScrollTimer = null; + } + + void _performAutoScrollAndSelection(double rowHeight) { + if (!mounted) { + _stopAutoScroll(); + return; + } + final pos = _lastPointerLocalPos; + if (pos == null) return; + + const edgeMargin = 32.0; + const maxVelocity = 15.0; + + double dyScroll = 0.0; + if (pos.dy < edgeMargin) { + final factor = ((edgeMargin - pos.dy) / edgeMargin).clamp(0.0, 3.0); + dyScroll = -maxVelocity * factor; + } else if (pos.dy > _currentRowsViewportHeight - edgeMargin) { + final factor = + ((pos.dy - (_currentRowsViewportHeight - edgeMargin)) / edgeMargin) + .clamp(0.0, 3.0); + dyScroll = maxVelocity * factor; + } + + double dxScroll = 0.0; + final hOffset = + _horizontalController.hasClients ? _horizontalController.offset : 0.0; + final screenX = pos.dx - hOffset; + if (screenX < edgeMargin) { + final factor = ((edgeMargin - screenX) / edgeMargin).clamp(0.0, 3.0); + dxScroll = -maxVelocity * factor; + } else if (screenX > _currentAvailableWidth - edgeMargin) { + final factor = + ((screenX - (_currentAvailableWidth - edgeMargin)) / edgeMargin) + .clamp(0.0, 3.0); + dxScroll = maxVelocity * factor; + } + + if (dyScroll != 0.0 && _verticalController.hasClients) { + final targetY = (_verticalController.offset + dyScroll).clamp( + 0.0, + _verticalController.position.maxScrollExtent, + ); + if (targetY != _verticalController.offset) { + _verticalController.jumpTo(targetY); + } + } + + if (dxScroll != 0.0 && _horizontalController.hasClients) { + final targetX = (_horizontalController.offset + dxScroll).clamp( + 0.0, + _horizontalController.position.maxScrollExtent, + ); + if (targetX != _horizontalController.offset) { + _horizontalController.jumpTo(targetX); + } + } + + final currentCell = _cellAtOffset( + localPosition: pos, + rowHeight: rowHeight, + ); + if (currentCell != null && currentCell != _selectionFocus) { + setState(() { + _selectionFocus = currentCell; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor ?? currentCell, + focus: currentCell, + ); + }); + final modelRow = _toModelRowIndex(currentCell.row); + widget.onRowSelected?.call(modelRow); + _notifySelectionAndFocus(); + } + } + void _onCellTap(int row, int column, {bool isShift = false}) { + if (_isDragSelecting || _hasJustDragSelected) { + _hasJustDragSelected = false; + return; + } _focusNode.requestFocus(); final modelRow = _toModelRowIndex(row); widget.onRowSelected?.call(modelRow); @@ -1498,8 +1728,6 @@ class _VirtualResultGridState extends material.State { _widthsNeedUpdate = false; } final cs = Theme.of(context).colorScheme; - final rowHeight = _scaledRowHeight(context); - final headerHeight = _scaledHeaderHeight(context); return material.CallbackShortcuts( bindings: { @@ -1780,6 +2008,12 @@ class _VirtualResultGridState extends material.State { child: material.LayoutBuilder( builder: (context, constraints) { final availableWidth = constraints.maxWidth; + final headerHeight = _scaledHeaderHeight(context); + final rowHeight = _scaledRowHeight(context); + final rowsViewportHeight = + math.max(0.0, constraints.maxHeight - headerHeight); + _currentAvailableWidth = availableWidth; + _currentRowsViewportHeight = rowsViewportHeight; var displayWidths = _columnWidths; var tableWidth = _tableWidth; @@ -1801,6 +2035,10 @@ class _VirtualResultGridState extends material.State { tableWidth = availableWidth; } + _currentDisplayOffsets = identical(displayWidths, _columnWidths) + ? _columnOffsets + : computeResultGridColumnOffsets(displayWidths); + final window = _columnWindow(displayWidths, availableWidth); return material.Scrollbar( @@ -1831,11 +2069,19 @@ class _VirtualResultGridState extends material.State { child: material.Scrollbar( controller: _verticalController, thumbVisibility: true, - child: material.ListView.builder( - controller: _verticalController, - itemCount: _sortedRows.length, - itemExtent: rowHeight, - itemBuilder: (context, rowIndex) { + child: material.Listener( + behavior: material.HitTestBehavior.translucent, + onPointerDown: (e) => + _onGridPointerDown(e, rowHeight: rowHeight), + onPointerMove: (e) => + _onGridPointerMove(e, rowHeight: rowHeight), + onPointerUp: _onGridPointerUp, + onPointerCancel: _onGridPointerCancel, + child: material.ListView.builder( + controller: _verticalController, + itemCount: _sortedRows.length, + itemExtent: rowHeight, + itemBuilder: (context, rowIndex) { final row = _sortedRows[rowIndex]; final isEven = rowIndex.isEven; return _DataRow( @@ -1874,7 +2120,8 @@ class _VirtualResultGridState extends material.State { ), ), ), - ], + ), + ], ), ), ), diff --git a/test/features/workspace/result_grid_drag_selection_test.dart b/test/features/workspace/result_grid_drag_selection_test.dart new file mode 100644 index 0000000..486798b --- /dev/null +++ b/test/features/workspace/result_grid_drag_selection_test.dart @@ -0,0 +1,165 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/result_grid_view.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +material.Widget _testShell({required material.Widget child}) { + return queryaThemeTestShell( + child: material.Scaffold( + body: child, + ), + ); +} + +void main() { + group('VirtualResultGrid Mouse Drag Range Selection', () { + testWidgets('selects rectangular range of cells via mouse drag', (tester) async { + List? selectedValues; + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name', 'role'], + rows: const [ + ['1', 'Alice', 'Admin'], + ['2', 'Bob', 'User'], + ['3', 'Charlie', 'Manager'], + ], + onSelectionValuesChanged: (vals) => selectedValues = vals, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Start drag from cell '1' (row 0, col 0) to 'Bob' (row 1, col 1) + final startPos = tester.getCenter(find.text('1')); + final endPos = tester.getCenter(find.text('Bob')); + + final gesture = await tester.startGesture(startPos, kind: PointerDeviceKind.mouse); + await tester.pump(); + + await gesture.moveTo(endPos); + await tester.pump(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Should select 2x2 box: (row 0..1, col 0..1) -> ['1', 'Alice', '2', 'Bob'] + expect(selectedValues, ['1', 'Alice', '2', 'Bob']); + }); + + testWidgets('supports reverse drag selection (bottom-right to top-left)', (tester) async { + List? selectedValues; + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name', 'role'], + rows: const [ + ['1', 'Alice', 'Admin'], + ['2', 'Bob', 'User'], + ['3', 'Charlie', 'Manager'], + ], + onSelectionValuesChanged: (vals) => selectedValues = vals, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Start drag from 'Manager' (row 2, col 2) up-left to 'Alice' (row 0, col 1) + final startPos = tester.getCenter(find.text('Manager')); + final endPos = tester.getCenter(find.text('Alice')); + + final gesture = await tester.startGesture(startPos, kind: PointerDeviceKind.mouse); + await tester.pump(); + + await gesture.moveTo(endPos); + await tester.pump(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Should select rectangle rows 0..2, cols 1..2 + expect(selectedValues, ['Alice', 'Admin', 'Bob', 'User', 'Charlie', 'Manager']); + }); + + testWidgets('single click without drag selects only one cell', (tester) async { + List? selectedValues; + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name', 'role'], + rows: const [ + ['1', 'Alice', 'Admin'], + ['2', 'Bob', 'User'], + ], + onSelectionValuesChanged: (vals) => selectedValues = vals, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alice')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + + expect(selectedValues, ['Alice']); + }); + + testWidgets('drag selection auto-scrolls vertically when dragged past viewport edge', (tester) async { + List? selectedValues; + + // 50 rows in a 200px high grid so vertical scrolling is required + final rows = List.generate(50, (i) => ['$i', 'Name $i', 'Role $i']); + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 200, + child: VirtualResultGrid( + columns: const ['id', 'name', 'role'], + rows: rows, + onSelectionValuesChanged: (vals) => selectedValues = vals, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final startPos = tester.getCenter(find.text('Name 0')); + final gesture = await tester.startGesture(startPos, kind: PointerDeviceKind.mouse); + await tester.pump(); + + // Move downwards past the bottom edge of the grid viewport (e.g. y = 195) + await gesture.moveTo(material.Offset(startPos.dx, 195)); + await tester.pump(); + + // Wait for auto-scroll timer ticks + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Selection should span more than the initial visible rows (e.g. from row 0 to at least row 5) + expect(selectedValues, isNotNull); + expect(selectedValues!.length, greaterThan(6)); + }); + }); +}