From acc44754eb36279d23f111ca581b31edb3676a60 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 18:11:23 +0300 Subject: [PATCH] feat(workspace): implement comprehensive keyboard navigation and multi-cell range selection in DataGrid - Add _navigateCell, _jumpToCell, and _selectAll in ResultGridView - Bind Arrow keys (Up, Down, Left, Right) and Shift+Arrows for multi-cell range selection - Bind Home, End, Ctrl+Home, Ctrl+End, PageUp, PageDown, and Ctrl+A / Meta+A - Implement _scrollToCell for auto-scrolling active cells into viewport view - Add widget tests covering cell keyboard navigation, range selection, and shortcuts Closes #662 --- lib/features/workspace/result_grid_view.dart | 311 +++++++++++++++++- .../grid_keyboard_navigation_test.dart | 253 ++++++++++++++ 2 files changed, 561 insertions(+), 3 deletions(-) create mode 100644 test/features/workspace/grid_keyboard_navigation_test.dart diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index c10b043..b28004e 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -412,6 +412,7 @@ class _VirtualResultGridState extends material.State { List> _sortedRows = const []; ResultGridCellCoordinate? _selectionAnchor; + ResultGridCellCoordinate? _selectionFocus; ResultGridSelection? _selection; ResultGridCellCoordinate? _editingCell; @@ -453,6 +454,7 @@ class _VirtualResultGridState extends material.State { _sortColumnIndex = null; _sortOrder = null; _selectionAnchor = null; + _selectionFocus = null; _selection = null; _editingCell = null; widget.onRowSelected?.call(null); @@ -473,10 +475,16 @@ class _VirtualResultGridState extends material.State { void _startEditing(int row, int column) { if (widget.stagingBuffer == null) return; - if (row < 0 || row >= _sortedRows.length || column < 0 || column >= widget.columns.length) return; + if (row < 0 || + row >= _sortedRows.length || + column < 0 || + column >= widget.columns.length) { + return; + } setState(() { _editingCell = ResultGridCellCoordinate(row, column); _selectionAnchor = _editingCell; + _selectionFocus = _editingCell; _selection = ResultGridSelection( startRow: row, startColumn: column, @@ -529,7 +537,8 @@ class _VirtualResultGridState extends material.State { endColumn: column - 1, ); } else if (row > 0) { - _editingCell = ResultGridCellCoordinate(row - 1, widget.columns.length - 1); + _editingCell = + ResultGridCellCoordinate(row - 1, widget.columns.length - 1); _selection = ResultGridSelection( startRow: row - 1, startColumn: widget.columns.length - 1, @@ -566,7 +575,14 @@ class _VirtualResultGridState extends material.State { } else { _editingCell = null; } + if (_editingCell != null) { + _selectionAnchor = _editingCell; + _selectionFocus = _editingCell; + widget.onRowSelected?.call(_editingCell!.row); + _scrollToCell(_editingCell!.row, _editingCell!.column); + } }); + _notifySelectionAndFocus(); } void _cancelEdit() { @@ -683,12 +699,14 @@ class _VirtualResultGridState extends material.State { setState(() { final coord = ResultGridCellCoordinate(row, column); if (isShift && _selectionAnchor != null) { + _selectionFocus = coord; _selection = ResultGridSelection.fromPoints( anchor: _selectionAnchor!, focus: coord, ); } else { _selectionAnchor = coord; + _selectionFocus = coord; _selection = ResultGridSelection( startRow: row, startColumn: column, @@ -707,7 +725,9 @@ class _VirtualResultGridState extends material.State { _copySelection(); } else { setState(() { - _selectionAnchor = ResultGridCellCoordinate(row, column); + final coord = ResultGridCellCoordinate(row, column); + _selectionAnchor = coord; + _selectionFocus = coord; _selection = ResultGridSelection( startRow: row, startColumn: column, @@ -765,6 +785,168 @@ class _VirtualResultGridState extends material.State { ); } + void _scrollToCell(int row, int col) { + if (!mounted) return; + final rowHeight = _scaledRowHeight(context); + final headerHeight = _scaledHeaderHeight(context); + + // Vertical scroll + if (_verticalController.hasClients) { + final targetTop = row * rowHeight; + final targetBottom = targetTop + rowHeight; + final currentOffset = _verticalController.offset; + final viewportHeight = + _verticalController.position.viewportDimension - headerHeight; + + if (targetTop < currentOffset) { + _verticalController.jumpTo(targetTop.clamp( + 0.0, + _verticalController.position.maxScrollExtent, + )); + } else if (targetBottom > currentOffset + viewportHeight && + viewportHeight > 0) { + final newOffset = (targetBottom - viewportHeight).clamp( + 0.0, + _verticalController.position.maxScrollExtent, + ); + _verticalController.jumpTo(newOffset); + } + } + + // Horizontal scroll + if (_horizontalController.hasClients && + col >= 0 && + col < _columnWidths.length) { + final colLeft = _columnOffsets[col]; + final colRight = colLeft + _columnWidths[col]; + final currentOffset = _horizontalController.offset; + final viewportWidth = _horizontalController.position.viewportDimension; + + if (colLeft < currentOffset) { + _horizontalController.jumpTo(colLeft.clamp( + 0.0, + _horizontalController.position.maxScrollExtent, + )); + } else if (colRight > currentOffset + viewportWidth && + viewportWidth > 0) { + final newOffset = (colRight - viewportWidth).clamp( + 0.0, + _horizontalController.position.maxScrollExtent, + ); + _horizontalController.jumpTo(newOffset); + } + } + } + + void _navigateCell(int dRow, int dCol, {bool extendSelection = false}) { + if (_sortedRows.isEmpty || widget.columns.isEmpty) return; + if (_editingCell != null) return; + + if (_selectionAnchor == null) { + setState(() { + _selectionAnchor = const ResultGridCellCoordinate(0, 0); + _selectionFocus = const ResultGridCellCoordinate(0, 0); + _selection = const ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 0, + endColumn: 0, + ); + }); + widget.onRowSelected?.call(0); + _scrollToCell(0, 0); + _notifySelectionAndFocus(); + return; + } + + final currentFocus = _selectionFocus ?? _selectionAnchor!; + final newRow = (currentFocus.row + dRow).clamp(0, _sortedRows.length - 1); + final newCol = + (currentFocus.column + dCol).clamp(0, widget.columns.length - 1); + final newFocus = ResultGridCellCoordinate(newRow, newCol); + + setState(() { + if (extendSelection) { + _selectionFocus = newFocus; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor!, + focus: newFocus, + ); + } else { + _selectionAnchor = newFocus; + _selectionFocus = newFocus; + _selection = ResultGridSelection( + startRow: newRow, + startColumn: newCol, + endRow: newRow, + endColumn: newCol, + ); + widget.onRowSelected?.call(newRow); + } + }); + + _scrollToCell(newRow, newCol); + _notifySelectionAndFocus(); + } + + void _jumpToCell({int? row, int? column, bool extendSelection = false}) { + if (_sortedRows.isEmpty || widget.columns.isEmpty) return; + if (_editingCell != null) return; + + final currentFocus = _selectionFocus ?? + _selectionAnchor ?? + const ResultGridCellCoordinate(0, 0); + final newRow = (row ?? currentFocus.row).clamp(0, _sortedRows.length - 1); + final newCol = + (column ?? currentFocus.column).clamp(0, widget.columns.length - 1); + final newFocus = ResultGridCellCoordinate(newRow, newCol); + + setState(() { + if (extendSelection) { + _selectionAnchor ??= currentFocus; + _selectionFocus = newFocus; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor!, + focus: newFocus, + ); + } else { + _selectionAnchor = newFocus; + _selectionFocus = newFocus; + _selection = ResultGridSelection( + startRow: newRow, + startColumn: newCol, + endRow: newRow, + endColumn: newCol, + ); + widget.onRowSelected?.call(newRow); + } + }); + + _scrollToCell(newRow, newCol); + _notifySelectionAndFocus(); + } + + void _selectAll() { + if (_sortedRows.isEmpty || widget.columns.isEmpty) return; + if (_editingCell != null) return; + + setState(() { + _selectionAnchor = const ResultGridCellCoordinate(0, 0); + _selectionFocus = ResultGridCellCoordinate( + _sortedRows.length - 1, + widget.columns.length - 1, + ); + _selection = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: _sortedRows.length - 1, + endColumn: widget.columns.length - 1, + ); + }); + + _notifySelectionAndFocus(); + } + @override material.Widget build(material.BuildContext context) { if (_widthsNeedUpdate && !_userHasResized) { @@ -840,7 +1022,9 @@ class _VirtualResultGridState extends material.State { setState(() { _selection = null; _selectionAnchor = null; + _selectionFocus = null; }); + _notifySelectionAndFocus(); } }, const material.SingleActivator( @@ -883,6 +1067,127 @@ class _VirtualResultGridState extends material.State { ); } }, + + // Navigation: Arrows + const material.SingleActivator(LogicalKeyboardKey.arrowDown): () => + _navigateCell(1, 0), + const material.SingleActivator(LogicalKeyboardKey.arrowUp): () => + _navigateCell(-1, 0), + const material.SingleActivator(LogicalKeyboardKey.arrowRight): () => + _navigateCell(0, 1), + const material.SingleActivator(LogicalKeyboardKey.arrowLeft): () => + _navigateCell(0, -1), + + // Navigation: Shift + Arrows (range selection) + const material.SingleActivator( + LogicalKeyboardKey.arrowDown, + shift: true, + ): () => _navigateCell(1, 0, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.arrowUp, + shift: true, + ): () => _navigateCell(-1, 0, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.arrowRight, + shift: true, + ): () => _navigateCell(0, 1, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.arrowLeft, + shift: true, + ): () => _navigateCell(0, -1, extendSelection: true), + + // Navigation: Home / End (column jump) + const material.SingleActivator(LogicalKeyboardKey.home): () => + _jumpToCell(column: 0), + const material.SingleActivator( + LogicalKeyboardKey.home, + shift: true, + ): () => _jumpToCell(column: 0, extendSelection: true), + const material.SingleActivator(LogicalKeyboardKey.end): () => + _jumpToCell(column: widget.columns.length - 1), + const material.SingleActivator( + LogicalKeyboardKey.end, + shift: true, + ): () => _jumpToCell( + column: widget.columns.length - 1, + extendSelection: true, + ), + + // Navigation: Ctrl+Home / Ctrl+End (table start / end) + const material.SingleActivator( + LogicalKeyboardKey.home, + control: true, + ): () => _jumpToCell(row: 0, column: 0), + const material.SingleActivator( + LogicalKeyboardKey.home, + meta: true, + ): () => _jumpToCell(row: 0, column: 0), + const material.SingleActivator( + LogicalKeyboardKey.home, + control: true, + shift: true, + ): () => _jumpToCell(row: 0, column: 0, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.home, + meta: true, + shift: true, + ): () => _jumpToCell(row: 0, column: 0, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.end, + control: true, + ): () => _jumpToCell( + row: _sortedRows.length - 1, + column: widget.columns.length - 1, + ), + const material.SingleActivator( + LogicalKeyboardKey.end, + meta: true, + ): () => _jumpToCell( + row: _sortedRows.length - 1, + column: widget.columns.length - 1, + ), + const material.SingleActivator( + LogicalKeyboardKey.end, + control: true, + shift: true, + ): () => _jumpToCell( + row: _sortedRows.length - 1, + column: widget.columns.length - 1, + extendSelection: true, + ), + const material.SingleActivator( + LogicalKeyboardKey.end, + meta: true, + shift: true, + ): () => _jumpToCell( + row: _sortedRows.length - 1, + column: widget.columns.length - 1, + extendSelection: true, + ), + + // Navigation: PageUp / PageDown + const material.SingleActivator(LogicalKeyboardKey.pageDown): () => + _navigateCell(20, 0), + const material.SingleActivator( + LogicalKeyboardKey.pageDown, + shift: true, + ): () => _navigateCell(20, 0, extendSelection: true), + const material.SingleActivator(LogicalKeyboardKey.pageUp): () => + _navigateCell(-20, 0), + const material.SingleActivator( + LogicalKeyboardKey.pageUp, + shift: true, + ): () => _navigateCell(-20, 0, extendSelection: true), + + // Select All: Ctrl+A / Meta+A + const material.SingleActivator( + LogicalKeyboardKey.keyA, + control: true, + ): () => _selectAll(), + const material.SingleActivator( + LogicalKeyboardKey.keyA, + meta: true, + ): () => _selectAll(), }, child: material.Focus( focusNode: _focusNode, diff --git a/test/features/workspace/grid_keyboard_navigation_test.dart b/test/features/workspace/grid_keyboard_navigation_test.dart new file mode 100644 index 0000000..211ef84 --- /dev/null +++ b/test/features/workspace/grid_keyboard_navigation_test.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.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 Keyboard Navigation & Selection', () { + testWidgets('navigates cells with arrow keys', (tester) async { + List? selectedValues; + String? focusedVal; + + 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, + onCellFocused: (col, val, row) => focusedVal = val, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap on 'Alice' (row 0, col 1) + await tester.tap(find.text('Alice')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + + expect(selectedValues, ['Alice']); + expect(focusedVal, 'Alice'); + + // Press ArrowDown -> should move to 'Bob' (row 1, col 1) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + + expect(selectedValues, ['Bob']); + expect(focusedVal, 'Bob'); + + // Press ArrowRight -> should move to 'User' (row 1, col 2) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + + expect(selectedValues, ['User']); + expect(focusedVal, 'User'); + + // Press ArrowUp -> should move to 'Admin' (row 0, col 2) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + + expect(selectedValues, ['Admin']); + expect(focusedVal, 'Admin'); + + // Press ArrowLeft -> should move back to 'Alice' (row 0, col 1) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + expect(selectedValues, ['Alice']); + expect(focusedVal, 'Alice'); + }); + + testWidgets('extends rectangular range selection with Shift+Arrow keys', (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(); + + // Tap on top-left '1' (row 0, col 0) + await tester.tap(find.text('1')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + + expect(selectedValues, ['1']); + + // Shift + ArrowDown -> extends selection to rows 0..1, col 0 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + + expect(selectedValues, ['1', '2']); + + // Shift + ArrowRight -> extends selection to 2x2 box: (row 0..1, col 0..1) + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + + expect(selectedValues, ['1', 'Alice', '2', 'Bob']); + }); + + testWidgets('selects all cells with Ctrl+A / Meta+A', (tester) async { + List? selectedValues; + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name'], + rows: const [ + ['1', 'Alice'], + ['2', 'Bob'], + ], + onSelectionValuesChanged: (vals) => selectedValues = vals, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap on Alice + await tester.tap(find.text('Alice')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + + expect(selectedValues, ['Alice']); + + // Press Ctrl+A + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(selectedValues, ['1', 'Alice', '2', 'Bob']); + }); + + testWidgets('jumps to start and end with Home / End and Ctrl+Home / Ctrl+End', (tester) async { + List? selectedValues; + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['c1', 'c2', 'c3'], + rows: const [ + ['r0c0', 'r0c1', 'r0c2'], + ['r1c0', 'r1c1', 'r1c2'], + ['r2c0', 'r2c1', 'r2c2'], + ], + onSelectionValuesChanged: (vals) => selectedValues = vals, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap on middle cell 'r1c1' + await tester.tap(find.text('r1c1')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + expect(selectedValues, ['r1c1']); + + // Press Home -> moves to 'r1c0' + await tester.sendKeyEvent(LogicalKeyboardKey.home); + await tester.pumpAndSettle(); + expect(selectedValues, ['r1c0']); + + // Press End -> moves to 'r1c2' + await tester.sendKeyEvent(LogicalKeyboardKey.end); + await tester.pumpAndSettle(); + expect(selectedValues, ['r1c2']); + + // Press Ctrl+Home -> moves to top-left 'r0c0' + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.home); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(selectedValues, ['r0c0']); + + // Press Ctrl+End -> moves to bottom-right 'r2c2' + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.end); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(selectedValues, ['r2c2']); + }); + + testWidgets('presses F2 to start editing on selected cell', (tester) async { + final staging = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ], + ); + + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name'], + rows: const [ + ['1', 'Alice'], + ], + stagingBuffer: staging, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap once to select Alice + await tester.tap(find.text('Alice')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + + // Press F2 -> opens inline editor + await tester.sendKeyEvent(LogicalKeyboardKey.f2); + await tester.pumpAndSettle(); + + expect(find.byType(material.TextField), findsOneWidget); + }); + }); +}