diff --git a/benchmark/grid_perf_bench.dart b/benchmark/grid_perf_bench.dart index bfb261c..87c493d 100644 --- a/benchmark/grid_perf_bench.dart +++ b/benchmark/grid_perf_bench.dart @@ -3,6 +3,7 @@ // Frame-timing benchmark for VirtualResultGrid. Run in profile mode: // flutter run --profile -d linux -t benchmark/grid_perf_bench.dart --dart-define=MODE=scroll // MODE: scroll (horizontal pan) | edit (staged cell edits on a sorted grid) +// | select (mouse-drag selection through ResultsTab, incl. stats) import 'dart:async'; import 'dart:developer' as developer; import 'dart:io'; @@ -15,6 +16,7 @@ import 'package:flutter/scheduler.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; +import 'package:querya_desktop/features/workspace/results_tab.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:vm_service/vm_service.dart' as vms; import 'package:vm_service/vm_service_io.dart' as vms_io; @@ -55,14 +57,23 @@ void main() { runApp(ShadcnApp( theme: td, home: material.Scaffold( - body: ListenableBuilder( - listenable: buffer, - builder: (_, __) => VirtualResultGrid( - columns: columns, - rows: buffer.effectiveRows, - stagingBuffer: buffer, - ), - ), + body: mode == 'select' + ? ListenableBuilder( + listenable: buffer, + builder: (_, __) => ResultsTab( + columns: columns, + rows: rows, + stagingBuffer: buffer, + ), + ) + : ListenableBuilder( + listenable: buffer, + builder: (_, __) => VirtualResultGrid( + columns: columns, + rows: buffer.effectiveRows, + stagingBuffer: buffer, + ), + ), ), )); WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -97,6 +108,18 @@ Future _tapText(String text) async { Future _run(DataGridStagingBuffer buffer) async { if (mode == 'edit') await _tapText('col_0'); // sort ascending + Offset? dragStart; + if (mode == 'select') { + final e = _find((e) => e.widget is Text && (e.widget as Text).data == 'v2_1'); + final box = e!.renderObject! as RenderBox; + dragStart = box.localToGlobal(box.size.center(Offset.zero)); + GestureBinding.instance.handlePointerEvent(PointerDownEvent( + position: dragStart, + pointer: 7, + kind: PointerDeviceKind.mouse, + buttons: kPrimaryButton, + )); + } if (profile) { await _connectVm(); await _vm?.clearCpuSamples(_isolateId!); @@ -121,11 +144,20 @@ Future _run(DataGridStagingBuffer buffer) async { final next = p.pixels + dir * 14; if (next > p.maxScrollExtent || next < 0) dir = -dir; p.jumpTo((p.pixels + dir * 14).clamp(0.0, p.maxScrollExtent)); + } else if (mode == 'select') { + _dragStep(dragStart!, i++); } else { buffer.setCell((i * 37) % rowCount, 1 + i % 5, 'edit$i'); i++; } } + if (mode == 'select') { + GestureBinding.instance.handlePointerEvent(PointerUpEvent( + position: dragStart!, + pointer: 7, + kind: PointerDeviceKind.mouse, + )); + } await Future.delayed(const Duration(seconds: 1)); _report(); if (profile) await _reportCpu(startMicros); @@ -198,3 +230,21 @@ Future _reportCpu(int startMicros) async { top('self time', self); top('inclusive time', incl); } + +/// Moves the held mouse pointer to a new cell each frame, zig-zagging down and +/// across, so the drag selection (and its statistics) changes on every frame. +Offset? _lastDrag; + +void _dragStep(Offset start, int i) { + final rowsDown = 1 + (i % 40); // sweep 1..40 rows + final colsAcross = 1 + ((i ~/ 40) % 6); + final pos = start + Offset(colsAcross * 110.0, rowsDown * 28.0); + GestureBinding.instance.handlePointerEvent(PointerMoveEvent( + position: pos, + delta: pos - (_lastDrag ?? start), + pointer: 7, + kind: PointerDeviceKind.mouse, + buttons: kPrimaryButton, + )); + _lastDrag = pos; +} diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 998c5f3..31c1b12 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -1,4 +1,4 @@ -import 'dart:async' show unawaited; +import 'dart:async' show Timer, unawaited; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; @@ -22,6 +22,23 @@ enum ResultViewMode { } /// Query output: grid, loading, error, or placeholder. +/// Pause after the last selection change before aggregates are recomputed. +const kSelectionStatsDebounce = Duration(milliseconds: 60); + +/// The cell that has keyboard focus, shown in the value inspector panel. +@immutable +class _FocusedCell { + const _FocusedCell({ + required this.columnName, + required this.value, + required this.rowIndex, + }); + + final String columnName; + final String value; + final int? rowIndex; +} + class ResultsTab extends material.StatefulWidget { const ResultsTab({ super.key, @@ -59,18 +76,25 @@ class ResultsTab extends material.StatefulWidget { } class _ResultsTabState extends material.State { - int? _selectedRowIndex; + /// Selection / focus state lives in notifiers so a navigation step rebuilds + /// only the widgets that show it (toolbar, value panel, calc bar), not the + /// whole tab and its grid. + final _selectedRowIndex = material.ValueNotifier(null); + final _focusedCell = material.ValueNotifier<_FocusedCell?>(null); + final _selectionStats = + material.ValueNotifier(GridCalcStats.empty); + Timer? _statsDebounce; + + /// Bumped for every selection change; a stats result is applied only if it + /// still matches, so a slow older aggregation cannot overwrite a newer one. + int _statsSeq = 0; + ResultViewMode _viewMode = ResultViewMode.grid; bool _showFilterBar = false; String _filterText = ''; bool _showValuePanel = false; - String? _focusedColumnName; - String? _focusedCellValue; - int? _focusedRowIndex; - - GridCalcStats _selectionStats = GridCalcStats.empty; String? _memoFilterText; List? _memoColumns; @@ -191,8 +215,35 @@ class _ResultsTabState extends material.State { } } + /// Selection aggregates are recomputed after a short pause, so holding an + /// arrow key or dragging a selection does not run the maths on every step. + void _onSelectionValues(List values) { + final seq = ++_statsSeq; + _statsDebounce?.cancel(); + if (values.isEmpty) { + _selectionStats.value = GridCalcStats.empty; + return; + } + _statsDebounce = Timer(kSelectionStatsDebounce, () { + if (values.length < GridSelectionCalcEngine.computeThreshold) { + _selectionStats.value = GridSelectionCalcEngine.compute(values); + return; + } + unawaited( + GridSelectionCalcEngine.computeAdaptive(values).then((stats) { + if (mounted && seq == _statsSeq) _selectionStats.value = stats; + }), + ); + }); + } + @override void dispose() { + _statsDebounce?.cancel(); + _statsSeq++; + _selectedRowIndex.dispose(); + _focusedCell.dispose(); + _selectionStats.dispose(); DataGridCommandBridge.instance.unregister(); _memoColumns = null; _memoEffectiveRows = null; @@ -319,11 +370,14 @@ class _ResultsTabState extends material.State { QueryaAnimatedExpand( expanded: widget.stagingBuffer != null, child: widget.stagingBuffer != null - ? DataGridStagingToolbar( - stagingBuffer: widget.stagingBuffer!, - selectedRowIndex: _selectedRowIndex, - onApplyChanges: widget.onApplyChanges, - isSaving: widget.isSaving, + ? material.ValueListenableBuilder( + valueListenable: _selectedRowIndex, + builder: (_, selectedRow, __) => DataGridStagingToolbar( + stagingBuffer: widget.stagingBuffer!, + selectedRowIndex: selectedRow, + onApplyChanges: widget.onApplyChanges, + isSaving: widget.isSaving, + ), ) : const material.SizedBox.shrink(), ), @@ -497,32 +551,14 @@ class _ResultsTabState extends material.State { stagingBuffer: widget.stagingBuffer, columnDataTypes: widget.columnDataTypes, rowIndicesMapping: _cachedFilteredIndices, - onRowSelected: (row) => setState(() => _selectedRowIndex = row), - onSelectionValuesChanged: (values) { - if (values.isEmpty) { - setState(() => _selectionStats = GridCalcStats.empty); - return; - } - if (values.length < GridSelectionCalcEngine.computeThreshold) { - setState(() { - _selectionStats = GridSelectionCalcEngine.compute(values); - }); - } else { - unawaited( - GridSelectionCalcEngine.computeAdaptive(values).then((stats) { - if (mounted) { - setState(() => _selectionStats = stats); - } - }), - ); - } - }, + onRowSelected: (row) => _selectedRowIndex.value = row, + onSelectionValuesChanged: _onSelectionValues, onCellFocused: (colName, cellVal, rowIdx) { - setState(() { - _focusedColumnName = colName; - _focusedCellValue = cellVal; - _focusedRowIndex = rowIdx; - }); + _focusedCell.value = _FocusedCell( + columnName: colName, + value: cellVal, + rowIndex: rowIdx, + ); }, onFilterRequested: (filterExpr) { setState(() { @@ -540,34 +576,40 @@ class _ResultsTabState extends material.State { ), // Value Inspector Panel - material.AnimatedSize( - duration: context.motionDuration(QueryaMotion.standard), - curve: context.motionCurve(QueryaMotion.enter), - alignment: material.Alignment.centerRight, - child: (_showValuePanel && - _focusedColumnName != null && - _focusedCellValue != null) - ? DataGridValuePanel( - columnName: _focusedColumnName!, - cellValue: _focusedCellValue!, - rowIndex: _focusedRowIndex, - onClose: () => setState(() => _showValuePanel = false), - onUpdateValue: widget.stagingBuffer != null && - _focusedRowIndex != null && - _focusedColumnName != null - ? (newVal) { - final colIdx = widget.columns.indexOf(_focusedColumnName!); - if (colIdx != -1) { - widget.stagingBuffer!.setCell( - _focusedRowIndex!, - colIdx, - newVal, - ); + material.ValueListenableBuilder<_FocusedCell?>( + valueListenable: _focusedCell, + builder: (context, focused, _) => material.AnimatedSize( + duration: + context.motionDuration(QueryaMotion.standard), + curve: context.motionCurve(QueryaMotion.enter), + alignment: material.Alignment.centerRight, + child: (_showValuePanel && focused != null) + ? DataGridValuePanel( + columnName: focused.columnName, + cellValue: focused.value, + rowIndex: focused.rowIndex, + onClose: () => + setState(() => _showValuePanel = false), + onUpdateValue: widget.stagingBuffer != null && + focused.rowIndex != null + ? (newVal) { + final colIdx = widget.columns + .indexOf(focused.columnName); + if (colIdx != -1) { + widget.stagingBuffer!.setCell( + focused.rowIndex!, + colIdx, + newVal, + ); + } } - } - : null, - ) - : const material.SizedBox(width: 0, height: double.infinity), + : null, + ) + : const material.SizedBox( + width: 0, + height: double.infinity, + ), + ), ), ], ), @@ -576,7 +618,10 @@ class _ResultsTabState extends material.State { // Calc Bar Footer if (_viewMode == ResultViewMode.grid) - DataGridCalcBar(stats: _selectionStats), + material.ValueListenableBuilder( + valueListenable: _selectionStats, + builder: (_, stats, __) => DataGridCalcBar(stats: stats), + ), ], ), ); diff --git a/test/features/workspace/results_tab_test.dart b/test/features/workspace/results_tab_test.dart index f7e868e..2248393 100644 --- a/test/features/workspace/results_tab_test.dart +++ b/test/features/workspace/results_tab_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; @@ -1212,5 +1213,89 @@ void main() { buffer.dispose(); }); }); + + group('ResultsTab selection updates (#885)', () { + final rows = [ + for (var r = 0; r < 6; r++) ['${r + 1}', '${(r + 1) * 10}', 'name$r'], + ]; + + Future pumpTab(WidgetTester tester) async { + await tester.pumpWidget( + resultsShell( + child: material.Scaffold( + body: ResultsTab( + columns: const ['id', 'amount', 'name'], + rows: rows, + stagingBuffer: DataGridStagingBuffer( + columns: const ['id', 'amount', 'name'], + rows: rows, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + // onTap resolves only after the double-tap timeout. + Future tapCell(WidgetTester tester, String text) async { + await tester.tap(find.text(text)); + await tester.pump(const Duration(milliseconds: 350)); + } + + testWidgets('a selection step does not rebuild the tab or its grid', + (tester) async { + await pumpTab(tester); + final gridBefore = tester.widget(find.byType(VirtualResultGrid)); + + await tapCell(tester, '20'); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(kSelectionStatsDebounce * 2); + await tester.pumpAndSettle(); + + // ResultsTab.build creates a new grid widget; the same instance means + // the tab did not rebuild for any selection / focus / stats change. + expect(identical(tester.widget(find.byType(VirtualResultGrid)), gridBefore), + isTrue); + }); + + testWidgets('selection stats appear after the debounce, once', (tester) async { + await pumpTab(tester); + await tapCell(tester, '10'); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + // Shift+Down extends the range over 10, 20, 30, 40. + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + + // Not computed yet inside the debounce window. + expect(find.text('Sum: '), findsNothing); + + await tester.pump(kSelectionStatsDebounce + const Duration(milliseconds: 10)); + await tester.pumpAndSettle(); + expect(find.text('Sum: '), findsOneWidget); + }); + + testWidgets('clearing the selection resets stats immediately', + (tester) async { + await pumpTab(tester); + await tapCell(tester, '10'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(kSelectionStatsDebounce * 2); + await tester.pumpAndSettle(); + expect(find.text('Sum: '), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + // Pending timers must be gone after dispose. + await tester.pumpWidget(const material.SizedBox()); + await tester.pump(kSelectionStatsDebounce * 2); + expect(tester.takeException(), isNull); + }); + }); }