From df600810a65f0d7a94bcfa681114b06b2c72a550 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 25 Sep 2026 14:22:43 +0300 Subject: [PATCH] perf(grid): rebuild only the rows and cells whose inputs changed (Closes #972) A staged cell edit rebuilt every visible row, and each row rebuilt all of its cells; a function-level profile showed ~67% of frame time in that rebuild and ~25% in per-cell inherited lookups. Panning also rebuilt the whole window on every column boundary. - VirtualResultGrid keeps the last _DataRow per visual row and hands the framework the same instance when a rebuild would produce an identical row; _DataRow keeps the same per column for _GridCell (keyed by column). Widgets cannot override ==, so reuse is by instance with a sameAs comparison. - Selection and editing cell are clipped to the row they touch, so a selection change no longer invalidates every row. - DataGridStagingBuffer.rowRenderSignature fingerprints the staged state that affects drawing a row (status and modified cells). - The column window is kept until the viewport reaches its edge, with a wider overscan (6), so it is rebuilt every few columns instead of on each one. - Add benchmark/grid_perf_bench.dart for repeatable frame-timing and CPU profile runs in profile mode. --- benchmark/grid_perf_bench.dart | 200 ++++++++++++++++++ .../workspace/data_grid_staging_buffer.dart | 11 + lib/features/workspace/result_grid_view.dart | 175 +++++++++++++-- .../data_grid_staging_buffer_test.dart | 22 ++ .../result_grid_sorting_model_test.dart | 129 +++++++++++ 5 files changed, 525 insertions(+), 12 deletions(-) create mode 100644 benchmark/grid_perf_bench.dart diff --git a/benchmark/grid_perf_bench.dart b/benchmark/grid_perf_bench.dart new file mode 100644 index 0000000..bfb261c --- /dev/null +++ b/benchmark/grid_perf_bench.dart @@ -0,0 +1,200 @@ +// ignore_for_file: depend_on_referenced_packages + +// 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) +import 'dart:async'; +import 'dart:developer' as developer; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' as material; +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: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; + +const mode = String.fromEnvironment('MODE', defaultValue: 'scroll'); +const rowCount = int.fromEnvironment('ROWS', defaultValue: 5000); +const colCount = int.fromEnvironment('COLS', defaultValue: 120); +const seconds = int.fromEnvironment('SECS', defaultValue: 6); +const profile = bool.fromEnvironment('PROFILE', defaultValue: false); + +vms.VmService? _vm; +String? _isolateId; + +Future _connectVm() async { + final info = await developer.Service.getInfo(); + final uri = info.serverUri; + if (uri == null) return; + final ws = uri.replace(scheme: 'ws', path: '${uri.path}ws').toString(); + _vm = await vms_io.vmServiceConnectUri(ws); + _isolateId = developer.Service.getIsolateId(Isolate.current); +} + + +final timings = []; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + SchedulerBinding.instance.addTimingsCallback(timings.addAll); + final columns = [for (var c = 0; c < colCount; c++) 'col_$c']; + final rows = [ + for (var r = 0; r < rowCount; r++) + [for (var c = 0; c < colCount; c++) c == 0 ? '${(r * 7919) % rowCount}' : 'v${r}_$c'], + ]; + final buffer = DataGridStagingBuffer(columns: columns, rows: rows); + final td = QueryaTheme.darkDefault + .toShadcnThemeData() + .copyWith(platform: () => TargetPlatform.linux); + runApp(ShadcnApp( + theme: td, + home: material.Scaffold( + body: ListenableBuilder( + listenable: buffer, + builder: (_, __) => VirtualResultGrid( + columns: columns, + rows: buffer.effectiveRows, + stagingBuffer: buffer, + ), + ), + ), + )); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await Future.delayed(const Duration(seconds: 2)); + await _run(buffer); + }); +} + +Element? _find(bool Function(Element) test) { + Element? found; + void visit(Element e) { + if (found != null) return; + if (test(e)) { + found = e; + return; + } + e.visitChildren(visit); + } + WidgetsBinding.instance.rootElement!.visitChildren(visit); + return found; +} + +Future _tapText(String text) async { + final e = _find((e) => e.widget is Text && (e.widget as Text).data == text); + final box = e!.renderObject! as RenderBox; + final pos = box.localToGlobal(box.size.center(Offset.zero)); + final b = GestureBinding.instance; + b.handlePointerEvent(PointerDownEvent(position: pos, pointer: 99)); + b.handlePointerEvent(PointerUpEvent(position: pos, pointer: 99)); + await Future.delayed(const Duration(milliseconds: 500)); +} + +Future _run(DataGridStagingBuffer buffer) async { + if (mode == 'edit') await _tapText('col_0'); // sort ascending + if (profile) { + await _connectVm(); + await _vm?.clearCpuSamples(_isolateId!); + } + timings.clear(); + final startMicros = DateTime.now().microsecondsSinceEpoch; + final end = DateTime.now().add(const Duration(seconds: seconds)); + var i = 0; + ScrollPosition? pos; + if (mode == 'scroll') { + final e = _find((e) => + e.widget is Scrollable && (e.widget as Scrollable).axis == Axis.horizontal); + pos = (e as StatefulElement).state is ScrollableState + ? ((e.state) as ScrollableState).position + : null; + } + var dir = 1.0; + while (DateTime.now().isBefore(end)) { + await SchedulerBinding.instance.endOfFrame; + if (mode == 'scroll') { + final p = pos!; + 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 { + buffer.setCell((i * 37) % rowCount, 1 + i % 5, 'edit$i'); + i++; + } + } + await Future.delayed(const Duration(seconds: 1)); + _report(); + if (profile) await _reportCpu(startMicros); + exit(0); +} + +void _report() { + double ms(Duration d) => d.inMicroseconds / 1000.0; + double pct(List v, double p) { + final s = [...v]..sort(); + return s[((s.length - 1) * p).round()]; + } + final build = [for (final t in timings) ms(t.buildDuration)]; + final raster = [for (final t in timings) ms(t.rasterDuration)]; + final total = [for (final t in timings) ms(t.totalSpan)]; + const budget = 1000 / 120; + int over(List v) => v.where((x) => x > budget).length; + String row(String n, List v) => + '$n p50=${pct(v, .5).toStringAsFixed(2)} p90=${pct(v, .9).toStringAsFixed(2)} ' + 'p99=${pct(v, .99).toStringAsFixed(2)} max=${pct(v, 1).toStringAsFixed(2)} ' + 'over8.33ms=${over(v)}/${v.length}'; + final view = PlatformDispatcher.instance.views.first; + stdout.writeln('BENCH window=${view.physicalSize.width.toInt()}x${view.physicalSize.height.toInt()} dpr=${view.devicePixelRatio}'); + stdout.writeln('BENCH mode=$mode rows=$rowCount cols=$colCount frames=${timings.length}'); + stdout.writeln('BENCH ${row('build ', build)}'); + stdout.writeln('BENCH ${row('raster', raster)}'); + stdout.writeln('BENCH ${row('total ', total)}'); +} + +Future _reportCpu(int startMicros) async { + final vm = _vm; + if (vm == null) { + stdout.writeln('PROFILE no VM service'); + return; + } + final samples = await vm.getCpuSamples(_isolateId!, 0, 0x7fffffffffff); + final self = {}; + final incl = {}; + for (final s in samples.samples ?? const []) { + final stack = s.stack ?? const []; + if (stack.isEmpty) continue; + self.update(stack.first, (v) => v + 1, ifAbsent: () => 1); + for (final f in stack.toSet()) { + incl.update(f, (v) => v + 1, ifAbsent: () => 1); + } + } + final funcs = samples.functions ?? const []; + String name(int i) { + final f = funcs[i].function; + if (f is vms.FuncRef) { + final owner = f.owner; + final o = owner is vms.ClassRef + ? '${owner.name}.' + : owner is vms.LibraryRef + ? '${owner.name?.split('.').last}::' + : ''; + return '$o${f.name}'; + } + return '${funcs[i].function}'; + } + final total = samples.sampleCount ?? 0; + void top(String title, Map m) { + final e = m.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + stdout.writeln('PROFILE == $title (samples=$total) =='); + for (final x in e.take(30)) { + stdout.writeln( + 'PROFILE ${(100 * x.value / total).toStringAsFixed(1).padLeft(5)}% ${name(x.key)}'); + } + } + top('self time', self); + top('inclusive time', incl); +} diff --git a/lib/features/workspace/data_grid_staging_buffer.dart b/lib/features/workspace/data_grid_staging_buffer.dart index c0f07a3..f564d75 100644 --- a/lib/features/workspace/data_grid_staging_buffer.dart +++ b/lib/features/workspace/data_grid_staging_buffer.dart @@ -169,6 +169,17 @@ class DataGridStagingBuffer extends ChangeNotifier { return StagedCellStatus.clean; } + /// Fingerprint of the staged state that changes how [row] is drawn (row + /// status and which cells are modified). Lets a grid skip rebuilding rows + /// whose staged state is unchanged. + int rowRenderSignature(int row) { + final mods = row >= 0 ? _modifiedCells[row] : null; + return Object.hash( + getRowStatus(row), + mods == null || mods.isEmpty ? 0 : Object.hashAllUnordered(mods.keys), + ); + } + /// Explicitly sets the cell to SQL NULL. void setCellNull(int row, int col) { setCell(row, col, TableMutationEngine.kNullSentinel); diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 9d5ac08..ced77f4 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -27,7 +27,9 @@ abstract final class ResultGridMetrics { static const int tooltipMinLength = 48; /// Extra columns built beyond the viewport to reduce scroll flicker. - static const int columnOverscan = 2; + /// Columns built beyond each viewport edge. A wider margin means the window + /// is rebuilt only every few columns of horizontal scroll, not on each one. + static const int columnOverscan = 6; /// Hover tooltip: column SQL type when known, plus the cell value when long. static String? cellTooltipMessage({ @@ -697,6 +699,9 @@ class _VirtualResultGridState extends material.State { int? _sortColumnIndex; ResultGridSortOrder? _sortOrder; int _sortVersion = 0; + /// Last widget built per visual row, reused when a rebuild would produce the + /// same row (see [_DataRow.sameAs]). + final Map _rowWidgets = {}; List> _sortedRows = const []; List _sortedToModelIndices = const []; @@ -854,6 +859,16 @@ class _VirtualResultGridState extends material.State { }); } + /// The selection only when it touches [rowIndex]. Rows outside the range do + /// not depend on it, so a selection change does not rebuild them. + ResultGridSelection? _selectionForRow(int rowIndex) { + final sel = _selection; + if (sel == null || rowIndex < sel.startRow || rowIndex > sel.endRow) { + return null; + } + return sel; + } + int _toModelRowIndex(int visualRow) { if (visualRow >= 0 && visualRow < _sortedToModelIndices.length) { return _sortedToModelIndices[visualRow]; @@ -983,7 +998,7 @@ class _VirtualResultGridState extends material.State { /// The scroll view already translates header and body together; the offset /// only decides which columns are built (virtualized window). So rebuild only - /// when that window changes, not on every pan frame. + /// when the viewport runs past the built window, not on every pan frame. void _onHorizontalScroll() { if (!_horizontalController.hasClients) return; final offset = _horizontalController.offset; @@ -992,13 +1007,20 @@ class _VirtualResultGridState extends material.State { final current = _currentWindow; if (current != null && _currentDisplayWidths.isNotEmpty) { - final next = computeVisibleColumnWindow( + final visible = computeVisibleColumnWindow( columnWidths: _currentDisplayWidths, columnOffsets: _currentDisplayOffsets, scrollOffset: offset, viewportWidth: _currentAvailableWidth, + overscanColumns: 0, ); - if (next == current) return; + // Every visible column is already built (the rest is overscan): keep the + // window until the viewport reaches its edge, then recentre it. + if (!visible.isEmpty && + current.first <= visible.first && + visible.last <= current.last) { + return; + } } setState(() {}); } @@ -2142,7 +2164,7 @@ class _VirtualResultGridState extends material.State { itemBuilder: (context, rowIndex) { final row = _sortedRows[rowIndex]; final isEven = rowIndex.isEven; - return _DataRow( + final candidate = _DataRow( key: ValueKey('result-row-$rowIndex'), rowIndex: rowIndex, modelRowIndex: _toModelRowIndex(rowIndex), @@ -2153,9 +2175,15 @@ class _VirtualResultGridState extends material.State { height: rowHeight, colorScheme: cs, striped: !isEven, - selection: _selection, + selection: _selectionForRow(rowIndex), stagingBuffer: widget.stagingBuffer, - editingCell: _editingCell, + stagedSignature: widget.stagingBuffer + ?.rowRenderSignature( + _toModelRowIndex(rowIndex)) ?? + 0, + editingCell: _editingCell?.row == rowIndex + ? _editingCell + : null, canFilter: widget.onFilterRequested != null, onCellTap: _onCellTap, onCellDoubleTap: _startEditing, @@ -2174,6 +2202,13 @@ class _VirtualResultGridState extends material.State { onRevertRow: _handleRevertRow, columnDataTypes: widget.columnDataTypes, ); + final previous = _rowWidgets[rowIndex]; + if (previous != null && + previous.sameAs(candidate)) { + return previous; + } + _rowWidgets[rowIndex] = candidate; + return candidate; }, ), ), @@ -2363,7 +2398,7 @@ class _HeaderCell extends material.StatelessWidget { } } -class _DataRow extends material.StatelessWidget { +class _DataRow extends material.StatefulWidget { const _DataRow({ super.key, required this.rowIndex, @@ -2377,6 +2412,7 @@ class _DataRow extends material.StatelessWidget { required this.striped, this.selection, this.stagingBuffer, + this.stagedSignature = 0, this.editingCell, this.canFilter = false, this.onCellTap, @@ -2408,6 +2444,10 @@ class _DataRow extends material.StatelessWidget { final bool striped; final ResultGridSelection? selection; final DataGridStagingBuffer? stagingBuffer; + + /// [DataGridStagingBuffer.rowRenderSignature] of this row, so equality can + /// see staged-state changes the (shared, mutable) buffer reference hides. + final int stagedSignature; final ResultGridCellCoordinate? editingCell; final bool canFilter; final void Function(int row, int col, {bool isShift})? onCellTap; @@ -2442,8 +2482,48 @@ class _DataRow extends material.StatelessWidget { final void Function(int row)? onRevertRow; final Map? columnDataTypes; + /// True when [other] would draw exactly the same row. The grid hands the + /// framework the *previous* widget instance in that case, so + /// [Element.updateChild] skips it and an edit, a selection change or a scroll + /// only rebuilds the rows whose inputs actually changed. + bool sameAs(_DataRow other) => + identical(this, other) || + rowIndex == other.rowIndex && + modelRowIndex == other.modelRowIndex && + identical(row, other.row) && + identical(columns, other.columns) && + (identical(columnWidths, other.columnWidths) || + foundation.listEquals(columnWidths, other.columnWidths)) && + window == other.window && + height == other.height && + colorScheme == other.colorScheme && + striped == other.striped && + selection == other.selection && + identical(stagingBuffer, other.stagingBuffer) && + stagedSignature == other.stagedSignature && + editingCell == other.editingCell && + canFilter == other.canFilter && + onCellTap == other.onCellTap && + onCellDoubleTap == other.onCellDoubleTap && + onCellSecondaryTap == other.onCellSecondaryTap && + onCommitEdit == other.onCommitEdit && + onCancelEdit == other.onCancelEdit && + onOpenInspector == other.onOpenInspector && + onCopyCell == other.onCopyCell && + onFilterByValue == other.onFilterByValue && + onFilterComparison == other.onFilterComparison && + onSetNull == other.onSetNull && + onSetEmpty == other.onSetEmpty && + onRevertCell == other.onRevertCell && + onDuplicateRow == other.onDuplicateRow && + onToggleDeleteRow == other.onToggleDeleteRow && + onRevertRow == other.onRevertRow && + identical(columnDataTypes, other.columnDataTypes); + @override - material.Widget build(material.BuildContext context) { + material.State<_DataRow> createState() => _DataRowState(); + + material.Widget _buildRow(Map cache) { final rowStatus = stagingBuffer?.getRowStatus(modelRowIndex) ?? StagedRowStatus.unchanged; @@ -2453,9 +2533,16 @@ class _DataRow extends material.StatelessWidget { child: material.Row( children: [ if (window.leadingWidth > 0) - material.SizedBox(width: window.leadingWidth), + material.SizedBox( + key: const material.ValueKey('lead'), + width: window.leadingWidth, + ), for (var c = window.first; c <= window.last; c++) - _GridCell( + _reuseCell( + cache, + c, + _GridCell( + key: material.ValueKey(c), row: rowIndex, column: c, columnName: c < columns.length ? columns[c] : '', @@ -2500,9 +2587,13 @@ class _DataRow extends material.StatelessWidget { onDuplicateRow: onDuplicateRow, onToggleDeleteRow: onToggleDeleteRow, onRevertRow: onRevertRow, + ), ), if (window.trailingWidth > 0) - material.SizedBox(width: window.trailingWidth), + material.SizedBox( + key: const material.ValueKey('trail'), + width: window.trailingWidth, + ), ], ), ), @@ -2510,8 +2601,30 @@ class _DataRow extends material.StatelessWidget { } } +/// Returns the cell built last time for [column] when it would look the same, +/// so the framework skips it; otherwise remembers and returns [candidate]. +_GridCell _reuseCell(Map cache, int column, _GridCell candidate) { + final previous = cache[column]; + if (previous != null && previous.sameAs(candidate)) return previous; + cache[column] = candidate; + return candidate; +} + +class _DataRowState extends material.State<_DataRow> { + final Map _cells = {}; + + @override + material.Widget build(material.BuildContext context) { + final w = widget.window; + // Cells that scrolled out of the window are dropped, not kept alive. + _cells.removeWhere((c, _) => c < w.first || c > w.last); + return widget._buildRow(_cells); + } +} + class _GridCell extends material.StatelessWidget { const _GridCell({ + super.key, required this.row, required this.column, this.columnName = '', @@ -2596,6 +2709,44 @@ class _GridCell extends material.StatelessWidget { final void Function(int row)? onToggleDeleteRow; final void Function(int row)? onRevertRow; + /// True when [other] would draw exactly the same cell (see [_DataRow.sameAs]): + /// scrolling the column window keeps the cells that stay visible untouched. + bool sameAs(_GridCell other) => + identical(this, other) || + row == other.row && + column == other.column && + columnName == other.columnName && + text == other.text && + width == other.width && + colorScheme == other.colorScheme && + striped == other.striped && + rowStatus == other.rowStatus && + cellStatus == other.cellStatus && + isSelected == other.isSelected && + isEditing == other.isEditing && + isSelectionTop == other.isSelectionTop && + isSelectionBottom == other.isSelectionBottom && + isSelectionLeft == other.isSelectionLeft && + isSelectionRight == other.isSelectionRight && + canFilter == other.canFilter && + hasStagingBuffer == other.hasStagingBuffer && + dataTypeName == other.dataTypeName && + onTap == other.onTap && + onDoubleTap == other.onDoubleTap && + onSecondaryTap == other.onSecondaryTap && + onCommitEdit == other.onCommitEdit && + onCancelEdit == other.onCancelEdit && + onOpenInspector == other.onOpenInspector && + onCopyCell == other.onCopyCell && + onFilterByValue == other.onFilterByValue && + onFilterComparison == other.onFilterComparison && + onSetNull == other.onSetNull && + onSetEmpty == other.onSetEmpty && + onRevertCell == other.onRevertCell && + onDuplicateRow == other.onDuplicateRow && + onToggleDeleteRow == other.onToggleDeleteRow && + onRevertRow == other.onRevertRow; + List _buildContextMenuItems(material.BuildContext context) { final preview = text.length > 24 ? '${text.substring(0, 22)}…' : text; final isNull = text == 'NULL'; diff --git a/test/features/workspace/data_grid_staging_buffer_test.dart b/test/features/workspace/data_grid_staging_buffer_test.dart index 1d9aaf7..78bd2a5 100644 --- a/test/features/workspace/data_grid_staging_buffer_test.dart +++ b/test/features/workspace/data_grid_staging_buffer_test.dart @@ -209,6 +209,28 @@ void main() { expect(buffer.insertedRowCount, 1); }); + test('rowRenderSignature changes only with the staged state of that row', + () { + final clean0 = buffer.rowRenderSignature(0); + final clean1 = buffer.rowRenderSignature(1); + expect(buffer.rowRenderSignature(0), clean0); + + buffer.setCell(0, 1, 'Alice Edited'); + expect(buffer.rowRenderSignature(0), isNot(clean0)); + expect(buffer.rowRenderSignature(1), clean1, reason: 'other rows unchanged'); + + final oneCell = buffer.rowRenderSignature(0); + buffer.setCell(0, 2, 'x@y.z'); + expect(buffer.rowRenderSignature(0), isNot(oneCell)); + + buffer.revertRow(0); + expect(buffer.rowRenderSignature(0), clean0); + + buffer.toggleDeleteRow(1); + expect(buffer.rowRenderSignature(1), isNot(clean1)); + expect(buffer.rowRenderSignature(-1), isA()); + }); + test('committedRows equals baseline when clean', () { expect(buffer.committedRows, buffer.originalRows); }); diff --git a/test/features/workspace/result_grid_sorting_model_test.dart b/test/features/workspace/result_grid_sorting_model_test.dart index 8dfaa1f..e57b1c1 100644 --- a/test/features/workspace/result_grid_sorting_model_test.dart +++ b/test/features/workspace/result_grid_sorting_model_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/gestures.dart'; 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/theme/querya_theme.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; @@ -389,4 +390,132 @@ void main() { expect(find.text('column_39'), findsNothing); }); }); + + group('VirtualResultGrid reuses unchanged rows and cells (#972)', () { + // Wider than the viewport, so content edits do not re-distribute spare + // column width (which legitimately rebuilds every row). + final columns = [for (var c = 0; c < 30; c++) 'col_$c']; + Future pump(WidgetTester tester) async { + final buffer = DataGridStagingBuffer( + columns: columns, + rows: [ + for (var r = 0; r < 6; r++) [for (var c = 0; c < 30; c++) 'r${r}c$c'], + ], + ); + addTearDown(buffer.dispose); + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 800, + height: 600, + child: material.ListenableBuilder( + listenable: buffer, + builder: (_, __) => VirtualResultGrid( + columns: columns, + rows: buffer.effectiveRows, + stagingBuffer: buffer, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return buffer; + } + + Widget rowWidget(WidgetTester tester, int i) => + tester.widget(find.byKey(material.ValueKey('result-row-$i'))); + + testWidgets('editing one cell rebuilds only that row', (tester) async { + final buffer = await pump(tester); + final before = [for (var i = 0; i < 6; i++) rowWidget(tester, i)]; + + buffer.setCell(2, 1, 'edited'); + await tester.pumpAndSettle(); + + for (var i = 0; i < 6; i++) { + expect(identical(rowWidget(tester, i), before[i]), i != 2, + reason: 'row $i'); + } + expect(find.text('edited'), findsOneWidget); + }); + + testWidgets('a selection change only rebuilds rows in the old or new range', + (tester) async { + await pump(tester); + await tester.tap(find.text('r1c0')); + // onTap resolves only after the double-tap timeout. + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + final before = [for (var i = 0; i < 6; i++) rowWidget(tester, i)]; + + // Arrow Down moves the selection from row 1 to row 2. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + + for (var i = 0; i < 6; i++) { + final touched = i == 1 || i == 2; + expect(identical(rowWidget(tester, i), before[i]), !touched, + reason: 'row $i'); + } + }); + + testWidgets('staged status changes still repaint the row', (tester) async { + final buffer = await pump(tester); + final before = rowWidget(tester, 3); + + buffer.toggleDeleteRow(3); + await tester.pumpAndSettle(); + + expect(identical(rowWidget(tester, 3), before), isFalse); + expect(buffer.getRowStatus(3), StagedRowStatus.deleted); + }); + + testWidgets('cells that stay in the column window are reused on scroll', + (tester) async { + final wide = [for (var c = 0; c < 40; c++) 'column_$c']; + final rows = [ + for (var r = 0; r < 3; r++) [for (var c = 0; c < 40; c++) 'v${r}_$c'], + ]; + await tester.pumpWidget( + _testShell( + child: material.SizedBox( + width: 600, + height: 400, + child: VirtualResultGrid(columns: wide, rows: rows), + ), + ), + ); + await tester.pumpAndSettle(); + + Map cells() { + final out = {}; + final rowFinder = find.byKey(const material.ValueKey('result-row-0')); + for (var c = 0; c < 40; c++) { + final f = find.descendant( + of: rowFinder, + matching: find.byKey(material.ValueKey(c)), + ); + if (f.evaluate().isNotEmpty) out[c] = tester.widget(f); + } + return out; + } + + final before = cells(); + final position = tester + .state(find.byWidgetPredicate( + (w) => w is Scrollable && w.axis == Axis.horizontal)) + .position; + position.jumpTo(1200); // far enough to shift the window + await tester.pumpAndSettle(); + final after = cells(); + + expect(after.keys.first, greaterThan(before.keys.first)); + final shared = before.keys.toSet().intersection(after.keys.toSet()); + expect(shared, isNotEmpty); + for (final c in shared) { + expect(identical(before[c], after[c]), isTrue, reason: 'column $c'); + } + }); + }); }