Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 164 additions & 8 deletions lib/features/main_screen/result_grid_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -38,7 +88,68 @@ List<double> computeResultGridColumnWidths({
return widths;
}

/// Virtualized read-only grid for SQL query results.
/// Prefix sums: `offsets[i]` = sum of widths `[0, i)`.
@visibleForTesting
List<double> computeResultGridColumnOffsets(List<double> columnWidths) {
final offsets = List<double>.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<double> columnWidths,
required List<double> 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,
Expand All @@ -58,7 +169,15 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
final _verticalController = material.ScrollController();

List<double> _columnWidths = const [];
List<double> _columnOffsets = const [0];
bool _widthsNeedUpdate = true;
double _scrollOffset = 0;

@override
void initState() {
super.initState();
_horizontalController.addListener(_onHorizontalScroll);
}

@override
void didChangeDependencies() {
Expand All @@ -76,11 +195,19 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {

@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<double> _computeColumnWidths() {
return computeResultGridColumnWidths(
columns: widget.columns,
Expand All @@ -92,7 +219,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {

double get _tableWidth {
if (_columnWidths.isEmpty) return 0;
return _columnWidths.reduce((a, b) => a + b);
return _columnOffsets[_columnWidths.length];
}

double _scaledRowHeight(material.BuildContext context) =>
Expand All @@ -101,21 +228,37 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
double _scaledHeaderHeight(material.BuildContext context) =>
context.scaled(ResultGridMetrics.headerHeight);

ResultGridColumnWindow _columnWindow(
List<double> 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);

return material.RepaintBoundary(
child: material.LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;

var displayWidths = _columnWidths;
var tableWidth = _tableWidth;
if (tableWidth < availableWidth && _columnWidths.isNotEmpty) {
Expand All @@ -129,6 +272,8 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
tableWidth = availableWidth;
}

final window = _columnWindow(displayWidths, availableWidth);

return material.Scrollbar(
controller: _horizontalController,
thumbVisibility: true,
Expand All @@ -144,6 +289,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
_HeaderRow(
columns: widget.columns,
columnWidths: displayWidths,
window: window,
height: headerHeight,
colorScheme: cs,
),
Expand All @@ -162,7 +308,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
key: ValueKey('result-row-$rowIndex'),
row: row,
columnWidths: displayWidths,
columnCount: colCount,
window: window,
height: rowHeight,
colorScheme: cs,
striped: !isEven,
Expand All @@ -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<String> columns;
final List<double> columnWidths;
final ResultGridColumnWindow window;
final double height;
final ColorScheme colorScheme;

Expand All @@ -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),
],
),
);
Expand All @@ -227,15 +379,15 @@ 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,
});

final List<String> row;
final List<double> columnWidths;
final int columnCount;
final ResultGridColumnWindow window;
final double height;
final ColorScheme colorScheme;
final bool striped;
Expand All @@ -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),
],
),
),
Expand Down
79 changes: 79 additions & 0 deletions test/features/main_screen/results_tab_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<double>.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<double>.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(
Expand Down Expand Up @@ -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 {
Expand Down
Loading