diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index 6e3f923d..2ce785d1 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -11,6 +11,56 @@ abstract final class ResultGridMetrics { static const double maxColumnWidth = 280; static const int columnWidthSampleRows = 40; static const int tooltipMinLength = 48; + + /// Extra columns built beyond the viewport to reduce scroll flicker. + static const int columnOverscan = 2; +} + +/// Inclusive visible column window with spacer widths for off-screen columns. +@immutable +class ResultGridColumnWindow { + const ResultGridColumnWindow({ + required this.first, + required this.last, + required this.leadingWidth, + required this.trailingWidth, + }); + + /// Empty window (no columns). + static const empty = ResultGridColumnWindow( + first: 0, + last: -1, + leadingWidth: 0, + trailingWidth: 0, + ); + + /// Inclusive first visible (or overscanned) column index. + final int first; + + /// Inclusive last visible (or overscanned) column index. + final int last; + + /// Width of columns strictly before [first] (left spacer). + final double leadingWidth; + + /// Width of columns strictly after [last] (right spacer). + final double trailingWidth; + + bool get isEmpty => last < first; + + int get columnCount => isEmpty ? 0 : last - first + 1; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResultGridColumnWindow && + first == other.first && + last == other.last && + leadingWidth == other.leadingWidth && + trailingWidth == other.trailingWidth; + + @override + int get hashCode => Object.hash(first, last, leadingWidth, trailingWidth); } /// Computes fixed column widths from headers and a sample of [rows]. @@ -38,7 +88,68 @@ List computeResultGridColumnWidths({ return widths; } -/// Virtualized read-only grid for SQL query results. +/// Prefix sums: `offsets[i]` = sum of widths `[0, i)`. +@visibleForTesting +List computeResultGridColumnOffsets(List columnWidths) { + final offsets = List.filled(columnWidths.length + 1, 0); + for (var i = 0; i < columnWidths.length; i++) { + offsets[i + 1] = offsets[i] + columnWidths[i]; + } + return offsets; +} + +/// Visible column range for a horizontal viewport (with overscan). +@visibleForTesting +ResultGridColumnWindow computeVisibleColumnWindow({ + required List columnWidths, + required List columnOffsets, + required double scrollOffset, + required double viewportWidth, + int overscanColumns = ResultGridMetrics.columnOverscan, +}) { + final n = columnWidths.length; + if (n == 0) return ResultGridColumnWindow.empty; + assert(columnOffsets.length == n + 1); + + final total = columnOffsets[n]; + if (viewportWidth <= 0) { + return ResultGridColumnWindow( + first: 0, + last: n - 1, + leadingWidth: 0, + trailingWidth: 0, + ); + } + + final start = scrollOffset.clamp(0.0, total); + final end = (scrollOffset + viewportWidth).clamp(0.0, total); + + // First column with any pixel past [start]. + var first = 0; + while (first < n && columnOffsets[first + 1] <= start) { + first++; + } + // Last column with any pixel before [end]. + var last = n - 1; + while (last > 0 && columnOffsets[last] >= end) { + last--; + } + if (first > last) { + first = last.clamp(0, n - 1); + } + + first = (first - overscanColumns).clamp(0, n - 1); + last = (last + overscanColumns).clamp(0, n - 1); + + return ResultGridColumnWindow( + first: first, + last: last, + leadingWidth: columnOffsets[first], + trailingWidth: total - columnOffsets[last + 1], + ); +} + +/// Virtualized read-only grid for SQL query results (rows + columns). class VirtualResultGrid extends material.StatefulWidget { const VirtualResultGrid({ super.key, @@ -58,7 +169,15 @@ class _VirtualResultGridState extends material.State { final _verticalController = material.ScrollController(); List _columnWidths = const []; + List _columnOffsets = const [0]; bool _widthsNeedUpdate = true; + double _scrollOffset = 0; + + @override + void initState() { + super.initState(); + _horizontalController.addListener(_onHorizontalScroll); + } @override void didChangeDependencies() { @@ -76,11 +195,19 @@ class _VirtualResultGridState extends material.State { @override void dispose() { + _horizontalController.removeListener(_onHorizontalScroll); _horizontalController.dispose(); _verticalController.dispose(); super.dispose(); } + void _onHorizontalScroll() { + if (!_horizontalController.hasClients) return; + final offset = _horizontalController.offset; + if ((offset - _scrollOffset).abs() < 0.5) return; + setState(() => _scrollOffset = offset); + } + List _computeColumnWidths() { return computeResultGridColumnWidths( columns: widget.columns, @@ -92,7 +219,7 @@ class _VirtualResultGridState extends material.State { double get _tableWidth { if (_columnWidths.isEmpty) return 0; - return _columnWidths.reduce((a, b) => a + b); + return _columnOffsets[_columnWidths.length]; } double _scaledRowHeight(material.BuildContext context) => @@ -101,14 +228,29 @@ class _VirtualResultGridState extends material.State { double _scaledHeaderHeight(material.BuildContext context) => context.scaled(ResultGridMetrics.headerHeight); + ResultGridColumnWindow _columnWindow( + List displayWidths, + double viewportWidth, + ) { + final offsets = identical(displayWidths, _columnWidths) + ? _columnOffsets + : computeResultGridColumnOffsets(displayWidths); + return computeVisibleColumnWindow( + columnWidths: displayWidths, + columnOffsets: offsets, + scrollOffset: _scrollOffset, + viewportWidth: viewportWidth, + ); + } + @override material.Widget build(material.BuildContext context) { if (_widthsNeedUpdate) { _columnWidths = _computeColumnWidths(); + _columnOffsets = computeResultGridColumnOffsets(_columnWidths); _widthsNeedUpdate = false; } final cs = Theme.of(context).colorScheme; - final colCount = widget.columns.length; final rowHeight = _scaledRowHeight(context); final headerHeight = _scaledHeaderHeight(context); @@ -116,6 +258,7 @@ class _VirtualResultGridState extends material.State { child: material.LayoutBuilder( builder: (context, constraints) { final availableWidth = constraints.maxWidth; + var displayWidths = _columnWidths; var tableWidth = _tableWidth; if (tableWidth < availableWidth && _columnWidths.isNotEmpty) { @@ -129,6 +272,8 @@ class _VirtualResultGridState extends material.State { tableWidth = availableWidth; } + final window = _columnWindow(displayWidths, availableWidth); + return material.Scrollbar( controller: _horizontalController, thumbVisibility: true, @@ -144,6 +289,7 @@ class _VirtualResultGridState extends material.State { _HeaderRow( columns: widget.columns, columnWidths: displayWidths, + window: window, height: headerHeight, colorScheme: cs, ), @@ -162,7 +308,7 @@ class _VirtualResultGridState extends material.State { key: ValueKey('result-row-$rowIndex'), row: row, columnWidths: displayWidths, - columnCount: colCount, + window: window, height: rowHeight, colorScheme: cs, striped: !isEven, @@ -186,12 +332,14 @@ class _HeaderRow extends material.StatelessWidget { const _HeaderRow({ required this.columns, required this.columnWidths, + required this.window, required this.height, required this.colorScheme, }); final List columns; final List columnWidths; + final ResultGridColumnWindow window; final double height; final ColorScheme colorScheme; @@ -209,13 +357,17 @@ class _HeaderRow extends material.StatelessWidget { ), child: material.Row( children: [ - for (var i = 0; i < columns.length; i++) + if (window.leadingWidth > 0) + material.SizedBox(width: window.leadingWidth), + for (var i = window.first; i <= window.last; i++) _GridCell( text: columns[i], width: columnWidths[i], isHeader: true, colorScheme: colorScheme, ), + if (window.trailingWidth > 0) + material.SizedBox(width: window.trailingWidth), ], ), ); @@ -227,7 +379,7 @@ class _DataRow extends material.StatelessWidget { super.key, required this.row, required this.columnWidths, - required this.columnCount, + required this.window, required this.height, required this.colorScheme, required this.striped, @@ -235,7 +387,7 @@ class _DataRow extends material.StatelessWidget { final List row; final List columnWidths; - final int columnCount; + final ResultGridColumnWindow window; final double height; final ColorScheme colorScheme; final bool striped; @@ -257,12 +409,16 @@ class _DataRow extends material.StatelessWidget { ), child: material.Row( children: [ - for (var c = 0; c < columnCount; c++) + if (window.leadingWidth > 0) + material.SizedBox(width: window.leadingWidth), + for (var c = window.first; c <= window.last; c++) _GridCell( text: c < row.length ? row[c] : '', width: columnWidths[c], colorScheme: colorScheme, ), + if (window.trailingWidth > 0) + material.SizedBox(width: window.trailingWidth), ], ), ), diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index a3b6ce9e..e410c655 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -61,6 +61,53 @@ void main() { }); }); + group('computeVisibleColumnWindow', () { + test('returns empty for no columns', () { + expect( + computeVisibleColumnWindow( + columnWidths: const [], + columnOffsets: const [0], + scrollOffset: 0, + viewportWidth: 400, + ), + ResultGridColumnWindow.empty, + ); + }); + + test('keeps far columns out of a narrow viewport', () { + final widths = List.filled(80, 120); + final offsets = computeResultGridColumnOffsets(widths); + final window = computeVisibleColumnWindow( + columnWidths: widths, + columnOffsets: offsets, + scrollOffset: 0, + viewportWidth: 400, + overscanColumns: 1, + ); + // ~4 visible + 1 overscan on the right → last around 4. + expect(window.first, 0); + expect(window.last, lessThan(10)); + expect(window.columnCount, lessThan(12)); + expect(window.leadingWidth, 0); + expect(window.trailingWidth, greaterThan(0)); + }); + + test('shifts window when scrolled horizontally', () { + final widths = List.filled(50, 100); + final offsets = computeResultGridColumnOffsets(widths); + final window = computeVisibleColumnWindow( + columnWidths: widths, + columnOffsets: offsets, + scrollOffset: 2000, + viewportWidth: 300, + overscanColumns: 0, + ); + expect(window.first, greaterThan(15)); + expect(window.last, lessThan(30)); + expect(window.leadingWidth, greaterThan(0)); + }); + }); + group('ResultsTab', () { testWidgets('uses virtualized grid instead of Table', (tester) async { final rows = List.generate( @@ -117,6 +164,38 @@ void main() { expect(dataRowWidgets, lessThan(80)); }); + testWidgets('does not build off-screen columns in a wide grid', + (tester) async { + final columns = List.generate(80, (i) => 'col_$i'); + final rows = List.generate( + 40, + (r) => List.generate(80, (c) => 'r${r}_c$c'), + ); + + await tester.pumpWidget( + resultsShell( + child: material.SizedBox( + height: 400, + width: 360, + child: VirtualResultGrid( + columns: columns, + rows: rows, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('col_0'), findsOneWidget); + expect(find.text('col_79'), findsNothing); + expect(find.text('r0_c0'), findsOneWidget); + expect(find.text('r0_c79'), findsNothing); + + // Far fewer Text widgets than rows×cols (40×80=3200). + final texts = tester.widgetList(find.byType(material.Text)).length; + expect(texts, lessThan(400)); + }); + testWidgets( 'recalculates column widths when updated with different columns without throwing RangeError', (tester) async {