Skip to content

Commit 7867fea

Browse files
perf(ui): 2D-virtualize VirtualResultGrid columns
Build only the visible column window (plus overscan) with leading/trailing spacers so wide result sets do not mount O(rows×cols) cells. Header stays aligned with the body. Closes #423
1 parent 16d9298 commit 7867fea

2 files changed

Lines changed: 244 additions & 8 deletions

File tree

‎lib/features/main_screen/result_grid_view.dart‎

Lines changed: 165 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:flutter/foundation.dart';
12
import 'package:flutter/material.dart' as material;
23
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
34
import 'package:querya_desktop/core/layout/ui_scale.dart';
@@ -11,6 +12,56 @@ abstract final class ResultGridMetrics {
1112
static const double maxColumnWidth = 280;
1213
static const int columnWidthSampleRows = 40;
1314
static const int tooltipMinLength = 48;
15+
16+
/// Extra columns built beyond the viewport to reduce scroll flicker.
17+
static const int columnOverscan = 2;
18+
}
19+
20+
/// Inclusive visible column window with spacer widths for off-screen columns.
21+
@immutable
22+
class ResultGridColumnWindow {
23+
const ResultGridColumnWindow({
24+
required this.first,
25+
required this.last,
26+
required this.leadingWidth,
27+
required this.trailingWidth,
28+
});
29+
30+
/// Empty window (no columns).
31+
static const empty = ResultGridColumnWindow(
32+
first: 0,
33+
last: -1,
34+
leadingWidth: 0,
35+
trailingWidth: 0,
36+
);
37+
38+
/// Inclusive first visible (or overscanned) column index.
39+
final int first;
40+
41+
/// Inclusive last visible (or overscanned) column index.
42+
final int last;
43+
44+
/// Width of columns strictly before [first] (left spacer).
45+
final double leadingWidth;
46+
47+
/// Width of columns strictly after [last] (right spacer).
48+
final double trailingWidth;
49+
50+
bool get isEmpty => last < first;
51+
52+
int get columnCount => isEmpty ? 0 : last - first + 1;
53+
54+
@override
55+
bool operator ==(Object other) =>
56+
identical(this, other) ||
57+
other is ResultGridColumnWindow &&
58+
first == other.first &&
59+
last == other.last &&
60+
leadingWidth == other.leadingWidth &&
61+
trailingWidth == other.trailingWidth;
62+
63+
@override
64+
int get hashCode => Object.hash(first, last, leadingWidth, trailingWidth);
1465
}
1566

1667
/// Computes fixed column widths from headers and a sample of [rows].
@@ -38,7 +89,68 @@ List<double> computeResultGridColumnWidths({
3889
return widths;
3990
}
4091

41-
/// Virtualized read-only grid for SQL query results.
92+
/// Prefix sums: `offsets[i]` = sum of widths `[0, i)`.
93+
@visibleForTesting
94+
List<double> computeResultGridColumnOffsets(List<double> columnWidths) {
95+
final offsets = List<double>.filled(columnWidths.length + 1, 0);
96+
for (var i = 0; i < columnWidths.length; i++) {
97+
offsets[i + 1] = offsets[i] + columnWidths[i];
98+
}
99+
return offsets;
100+
}
101+
102+
/// Visible column range for a horizontal viewport (with overscan).
103+
@visibleForTesting
104+
ResultGridColumnWindow computeVisibleColumnWindow({
105+
required List<double> columnWidths,
106+
required List<double> columnOffsets,
107+
required double scrollOffset,
108+
required double viewportWidth,
109+
int overscanColumns = ResultGridMetrics.columnOverscan,
110+
}) {
111+
final n = columnWidths.length;
112+
if (n == 0) return ResultGridColumnWindow.empty;
113+
assert(columnOffsets.length == n + 1);
114+
115+
final total = columnOffsets[n];
116+
if (viewportWidth <= 0) {
117+
return ResultGridColumnWindow(
118+
first: 0,
119+
last: n - 1,
120+
leadingWidth: 0,
121+
trailingWidth: 0,
122+
);
123+
}
124+
125+
final start = scrollOffset.clamp(0.0, total);
126+
final end = (scrollOffset + viewportWidth).clamp(0.0, total);
127+
128+
// First column with any pixel past [start].
129+
var first = 0;
130+
while (first < n && columnOffsets[first + 1] <= start) {
131+
first++;
132+
}
133+
// Last column with any pixel before [end].
134+
var last = n - 1;
135+
while (last > 0 && columnOffsets[last] >= end) {
136+
last--;
137+
}
138+
if (first > last) {
139+
first = last.clamp(0, n - 1);
140+
}
141+
142+
first = (first - overscanColumns).clamp(0, n - 1);
143+
last = (last + overscanColumns).clamp(0, n - 1);
144+
145+
return ResultGridColumnWindow(
146+
first: first,
147+
last: last,
148+
leadingWidth: columnOffsets[first],
149+
trailingWidth: total - columnOffsets[last + 1],
150+
);
151+
}
152+
153+
/// Virtualized read-only grid for SQL query results (rows + columns).
42154
class VirtualResultGrid extends material.StatefulWidget {
43155
const VirtualResultGrid({
44156
super.key,
@@ -58,7 +170,15 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
58170
final _verticalController = material.ScrollController();
59171

60172
List<double> _columnWidths = const [];
173+
List<double> _columnOffsets = const [0];
61174
bool _widthsNeedUpdate = true;
175+
double _scrollOffset = 0;
176+
177+
@override
178+
void initState() {
179+
super.initState();
180+
_horizontalController.addListener(_onHorizontalScroll);
181+
}
62182

63183
@override
64184
void didChangeDependencies() {
@@ -76,11 +196,19 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
76196

77197
@override
78198
void dispose() {
199+
_horizontalController.removeListener(_onHorizontalScroll);
79200
_horizontalController.dispose();
80201
_verticalController.dispose();
81202
super.dispose();
82203
}
83204

205+
void _onHorizontalScroll() {
206+
if (!_horizontalController.hasClients) return;
207+
final offset = _horizontalController.offset;
208+
if ((offset - _scrollOffset).abs() < 0.5) return;
209+
setState(() => _scrollOffset = offset);
210+
}
211+
84212
List<double> _computeColumnWidths() {
85213
return computeResultGridColumnWidths(
86214
columns: widget.columns,
@@ -92,7 +220,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
92220

93221
double get _tableWidth {
94222
if (_columnWidths.isEmpty) return 0;
95-
return _columnWidths.reduce((a, b) => a + b);
223+
return _columnOffsets[_columnWidths.length];
96224
}
97225

98226
double _scaledRowHeight(material.BuildContext context) =>
@@ -101,21 +229,37 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
101229
double _scaledHeaderHeight(material.BuildContext context) =>
102230
context.scaled(ResultGridMetrics.headerHeight);
103231

232+
ResultGridColumnWindow _columnWindow(
233+
List<double> displayWidths,
234+
double viewportWidth,
235+
) {
236+
final offsets = identical(displayWidths, _columnWidths)
237+
? _columnOffsets
238+
: computeResultGridColumnOffsets(displayWidths);
239+
return computeVisibleColumnWindow(
240+
columnWidths: displayWidths,
241+
columnOffsets: offsets,
242+
scrollOffset: _scrollOffset,
243+
viewportWidth: viewportWidth,
244+
);
245+
}
246+
104247
@override
105248
material.Widget build(material.BuildContext context) {
106249
if (_widthsNeedUpdate) {
107250
_columnWidths = _computeColumnWidths();
251+
_columnOffsets = computeResultGridColumnOffsets(_columnWidths);
108252
_widthsNeedUpdate = false;
109253
}
110254
final cs = Theme.of(context).colorScheme;
111-
final colCount = widget.columns.length;
112255
final rowHeight = _scaledRowHeight(context);
113256
final headerHeight = _scaledHeaderHeight(context);
114257

115258
return material.RepaintBoundary(
116259
child: material.LayoutBuilder(
117260
builder: (context, constraints) {
118261
final availableWidth = constraints.maxWidth;
262+
119263
var displayWidths = _columnWidths;
120264
var tableWidth = _tableWidth;
121265
if (tableWidth < availableWidth && _columnWidths.isNotEmpty) {
@@ -129,6 +273,8 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
129273
tableWidth = availableWidth;
130274
}
131275

276+
final window = _columnWindow(displayWidths, availableWidth);
277+
132278
return material.Scrollbar(
133279
controller: _horizontalController,
134280
thumbVisibility: true,
@@ -144,6 +290,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
144290
_HeaderRow(
145291
columns: widget.columns,
146292
columnWidths: displayWidths,
293+
window: window,
147294
height: headerHeight,
148295
colorScheme: cs,
149296
),
@@ -162,7 +309,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
162309
key: ValueKey('result-row-$rowIndex'),
163310
row: row,
164311
columnWidths: displayWidths,
165-
columnCount: colCount,
312+
window: window,
166313
height: rowHeight,
167314
colorScheme: cs,
168315
striped: !isEven,
@@ -186,12 +333,14 @@ class _HeaderRow extends material.StatelessWidget {
186333
const _HeaderRow({
187334
required this.columns,
188335
required this.columnWidths,
336+
required this.window,
189337
required this.height,
190338
required this.colorScheme,
191339
});
192340

193341
final List<String> columns;
194342
final List<double> columnWidths;
343+
final ResultGridColumnWindow window;
195344
final double height;
196345
final ColorScheme colorScheme;
197346

@@ -209,13 +358,17 @@ class _HeaderRow extends material.StatelessWidget {
209358
),
210359
child: material.Row(
211360
children: [
212-
for (var i = 0; i < columns.length; i++)
361+
if (window.leadingWidth > 0)
362+
material.SizedBox(width: window.leadingWidth),
363+
for (var i = window.first; i <= window.last; i++)
213364
_GridCell(
214365
text: columns[i],
215366
width: columnWidths[i],
216367
isHeader: true,
217368
colorScheme: colorScheme,
218369
),
370+
if (window.trailingWidth > 0)
371+
material.SizedBox(width: window.trailingWidth),
219372
],
220373
),
221374
);
@@ -227,15 +380,15 @@ class _DataRow extends material.StatelessWidget {
227380
super.key,
228381
required this.row,
229382
required this.columnWidths,
230-
required this.columnCount,
383+
required this.window,
231384
required this.height,
232385
required this.colorScheme,
233386
required this.striped,
234387
});
235388

236389
final List<String> row;
237390
final List<double> columnWidths;
238-
final int columnCount;
391+
final ResultGridColumnWindow window;
239392
final double height;
240393
final ColorScheme colorScheme;
241394
final bool striped;
@@ -257,12 +410,16 @@ class _DataRow extends material.StatelessWidget {
257410
),
258411
child: material.Row(
259412
children: [
260-
for (var c = 0; c < columnCount; c++)
413+
if (window.leadingWidth > 0)
414+
material.SizedBox(width: window.leadingWidth),
415+
for (var c = window.first; c <= window.last; c++)
261416
_GridCell(
262417
text: c < row.length ? row[c] : '',
263418
width: columnWidths[c],
264419
colorScheme: colorScheme,
265420
),
421+
if (window.trailingWidth > 0)
422+
material.SizedBox(width: window.trailingWidth),
266423
],
267424
),
268425
),

0 commit comments

Comments
 (0)