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
58 changes: 58 additions & 0 deletions lib/features/workspace/grid_filter_engine.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,67 @@
import 'dart:async';
import 'package:flutter/foundation.dart' as foundation;

/// Default threshold for offloading data grid filtering to a background isolate.
const int kFilterIsolateThreshold = 3000;

class FilterIsolateParams {
const FilterIsolateParams({
required this.filterText,
required this.columns,
required this.rows,
});

final String filterText;
final List<String> columns;
final List<List<String>> rows;
}

List<int> _filterRowIndicesIsolate(FilterIsolateParams params) {
return GridFilterEngine.filterRowIndices(
filterText: params.filterText,
columns: params.columns,
rows: params.rows,
);
}

/// Client-side filter engine for Data Grid.
///
/// Evaluates complex multi-clause expressions with AND / OR / NOT, parentheses,
/// column predicates (`col = val`, `col > 10`, `col LIKE '%test%'`, `col IN ('a', 'b')`,
/// `col IS NULL`, `col BETWEEN x AND y`), and free-text substring search.
abstract final class GridFilterEngine {
static const int defaultIsolateThreshold = kFilterIsolateThreshold;

/// Adaptively evaluates [filterText] against [rows]:
/// synchronously for small datasets (< [threshold]), and offloaded to a
/// background isolate via [foundation.compute] for larger datasets to avoid blocking UI frames.
static Future<List<int>> filterRowIndicesAdaptive({
required String filterText,
required List<String> columns,
required List<List<String>> rows,
int threshold = defaultIsolateThreshold,
}) async {
final trimmed = filterText.trim();
if (trimmed.isEmpty || columns.isEmpty || rows.isEmpty) {
return List<int>.generate(rows.length, (i) => i);
}
if (rows.length < threshold) {
return filterRowIndices(
filterText: filterText,
columns: columns,
rows: rows,
);
}
return foundation.compute(
_filterRowIndicesIsolate,
FilterIsolateParams(
filterText: filterText,
columns: columns,
rows: rows,
),
);
}

/// Evaluates [filterText] against [rows] with respect to [columns].
/// Returns the list of matching row indices.
static List<int> filterRowIndices({
Expand Down
158 changes: 137 additions & 21 deletions lib/features/workspace/results_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,96 @@ class _ResultsTabState extends material.State<ResultsTab> {
List<List<String>>? _memoEffectiveRows;
List<List<String>> _cachedFilteredRows = const [];
List<int>? _cachedFilteredIndices;
int _filterSeq = 0;
Timer? _filterDebounceTimer;

void _onFilterChanged(String text) {
if (_filterText == text) return;
_filterText = text;
final effectiveRows = widget.stagingBuffer != null
? widget.stagingBuffer!.effectiveRows
: widget.rows;
_applyFilter(effectiveRows, widget.columns);
}

void _onFilterClosed() {
_showFilterBar = false;
_filterDebounceTimer?.cancel();
_filterSeq++;
_filterText = '';
final effectiveRows = widget.stagingBuffer != null
? widget.stagingBuffer!.effectiveRows
: widget.rows;
_memoFilterText = '';
_memoEffectiveRows = effectiveRows;
_memoColumns = widget.columns;
_cachedFilteredRows = effectiveRows;
_cachedFilteredIndices = null;
setState(() {});
}

void _applyFilter(
List<List<String>> effectiveRows,
List<String> columns, {
bool forceSyncIfSmall = true,
}) {
final seq = ++_filterSeq;
_filterDebounceTimer?.cancel();

if (_filterText.trim().isEmpty) {
_memoFilterText = _filterText;
_memoEffectiveRows = effectiveRows;
_memoColumns = columns;
_cachedFilteredRows = effectiveRows;
_cachedFilteredIndices = null;
if (mounted) setState(() {});
return;
}

if (forceSyncIfSmall &&
effectiveRows.length < GridFilterEngine.defaultIsolateThreshold) {
final filteredIndices = GridFilterEngine.filterRowIndices(
filterText: _filterText,
columns: columns,
rows: effectiveRows,
);
final isFiltered = filteredIndices.length != effectiveRows.length;
final filteredRows = !isFiltered
? effectiveRows
: filteredIndices.map((i) => effectiveRows[i]).toList();

_memoFilterText = _filterText;
_memoEffectiveRows = effectiveRows;
_memoColumns = columns;
_cachedFilteredRows = filteredRows;
_cachedFilteredIndices = isFiltered ? filteredIndices : null;
if (mounted) setState(() {});
return;
}

_filterDebounceTimer = Timer(const Duration(milliseconds: 150), () {
unawaited(
GridFilterEngine.filterRowIndicesAdaptive(
filterText: _filterText,
columns: columns,
rows: effectiveRows,
).then((filteredIndices) {
if (!mounted || seq != _filterSeq) return;
final isFiltered = filteredIndices.length != effectiveRows.length;
final filteredRows = !isFiltered
? effectiveRows
: filteredIndices.map((i) => effectiveRows[i]).toList();
setState(() {
_memoFilterText = _filterText;
_memoEffectiveRows = effectiveRows;
_memoColumns = columns;
_cachedFilteredRows = filteredRows;
_cachedFilteredIndices = isFiltered ? filteredIndices : null;
});
}),
);
});
}

List<List<String>> _getFilteredRows(
List<List<String>> effectiveRows,
Expand All @@ -120,24 +210,35 @@ class _ResultsTabState extends material.State<ResultsTab> {
return _cachedFilteredRows;
}

final filteredIndices = GridFilterEngine.filterRowIndices(
filterText: _filterText,
columns: columns,
rows: effectiveRows,
);

final isFiltered = filteredIndices.length != effectiveRows.length;
final filteredRows = !isFiltered
? effectiveRows
: filteredIndices.map((i) => effectiveRows[i]).toList();
if (_filterText.trim().isEmpty) {
_memoFilterText = _filterText;
_memoEffectiveRows = effectiveRows;
_memoColumns = columns;
_cachedFilteredRows = effectiveRows;
_cachedFilteredIndices = null;
return effectiveRows;
}

_memoFilterText = _filterText;
_memoEffectiveRows = effectiveRows;
_memoColumns = columns;
_cachedFilteredRows = filteredRows;
_cachedFilteredIndices = isFiltered ? filteredIndices : null;
if (effectiveRows.length < GridFilterEngine.defaultIsolateThreshold) {
final filteredIndices = GridFilterEngine.filterRowIndices(
filterText: _filterText,
columns: columns,
rows: effectiveRows,
);
final isFiltered = filteredIndices.length != effectiveRows.length;
final filteredRows = !isFiltered
? effectiveRows
: filteredIndices.map((i) => effectiveRows[i]).toList();
_memoFilterText = _filterText;
_memoEffectiveRows = effectiveRows;
_memoColumns = columns;
_cachedFilteredRows = filteredRows;
_cachedFilteredIndices = isFiltered ? filteredIndices : null;
return filteredRows;
}

return filteredRows;
_applyFilter(effectiveRows, columns, forceSyncIfSmall: false);
return _cachedFilteredRows.isNotEmpty ? _cachedFilteredRows : effectiveRows;
}

@override
Expand All @@ -150,6 +251,22 @@ class _ResultsTabState extends material.State<ResultsTab> {
void didUpdateWidget(covariant ResultsTab oldWidget) {
super.didUpdateWidget(oldWidget);
_syncGridBridge();
if (!identical(oldWidget.rows, widget.rows) ||
!identical(oldWidget.columns, widget.columns) ||
oldWidget.stagingBuffer != widget.stagingBuffer) {
final effectiveRows = widget.stagingBuffer != null
? widget.stagingBuffer!.effectiveRows
: widget.rows;
if (_filterText.isNotEmpty) {
_applyFilter(effectiveRows, widget.columns);
} else {
_memoFilterText = '';
_memoEffectiveRows = effectiveRows;
_memoColumns = widget.columns;
_cachedFilteredRows = effectiveRows;
_cachedFilteredIndices = null;
}
}
}

void _syncGridBridge() {
Expand Down Expand Up @@ -247,6 +364,8 @@ class _ResultsTabState extends material.State<ResultsTab> {

@override
void dispose() {
_filterDebounceTimer?.cancel();
_filterSeq++;
_statsDebounce?.cancel();
_statsSeq++;
_selectedRowIndex.dispose();
Expand Down Expand Up @@ -542,14 +661,11 @@ class _ResultsTabState extends material.State<ResultsTab> {
expanded: _showFilterBar || _filterText.isNotEmpty,
child: DataGridFilterBar(
filterText: _filterText,
onFilterChanged: (text) => setState(() => _filterText = text),
onFilterChanged: _onFilterChanged,
totalRowCount: effectiveRows.length,
filteredRowCount: filteredRows.length,
columns: widget.columns,
onClose: () => setState(() {
_showFilterBar = false;
_filterText = '';
}),
onClose: _onFilterClosed,
),
),

Expand Down
30 changes: 30 additions & 0 deletions test/features/workspace/data_grid_engines_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,36 @@ void main() {
);
expect(res, equals([0]));
});

test('filterRowIndicesAdaptive executes synchronously below threshold', () async {
final res = await GridFilterEngine.filterRowIndicesAdaptive(
filterText: 'status = ACTIVE',
columns: columns,
rows: rows,
threshold: 10,
);
expect(res, equals([0, 2]));
});

test('filterRowIndicesAdaptive executes via isolate at or above threshold', () async {
final res = await GridFilterEngine.filterRowIndicesAdaptive(
filterText: 'status = ACTIVE',
columns: columns,
rows: rows,
threshold: 2,
);
expect(res, equals([0, 2]));
});

test('filterRowIndicesAdaptive returns all indices immediately on empty filter', () async {
final res = await GridFilterEngine.filterRowIndicesAdaptive(
filterText: ' ',
columns: columns,
rows: rows,
threshold: 2,
);
expect(res, equals([0, 1, 2, 3]));
});
});

group('GridSelectionCalcEngine', () {
Expand Down
Loading