diff --git a/lib/features/workspace/grid_filter_engine.dart b/lib/features/workspace/grid_filter_engine.dart index e07bc1ff..7f2794e7 100644 --- a/lib/features/workspace/grid_filter_engine.dart +++ b/lib/features/workspace/grid_filter_engine.dart @@ -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 columns; + final List> rows; +} + +List _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> filterRowIndicesAdaptive({ + required String filterText, + required List columns, + required List> rows, + int threshold = defaultIsolateThreshold, + }) async { + final trimmed = filterText.trim(); + if (trimmed.isEmpty || columns.isEmpty || rows.isEmpty) { + return List.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 filterRowIndices({ diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 3626fba2..4cd6a7fb 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -109,6 +109,96 @@ class _ResultsTabState extends material.State { List>? _memoEffectiveRows; List> _cachedFilteredRows = const []; List? _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> effectiveRows, + List 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> _getFilteredRows( List> effectiveRows, @@ -120,24 +210,35 @@ class _ResultsTabState extends material.State { 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 @@ -150,6 +251,22 @@ class _ResultsTabState extends material.State { 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() { @@ -247,6 +364,8 @@ class _ResultsTabState extends material.State { @override void dispose() { + _filterDebounceTimer?.cancel(); + _filterSeq++; _statsDebounce?.cancel(); _statsSeq++; _selectedRowIndex.dispose(); @@ -542,14 +661,11 @@ class _ResultsTabState extends material.State { 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, ), ), diff --git a/test/features/workspace/data_grid_engines_test.dart b/test/features/workspace/data_grid_engines_test.dart index ae733bea..dd64ac11 100644 --- a/test/features/workspace/data_grid_engines_test.dart +++ b/test/features/workspace/data_grid_engines_test.dart @@ -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', () {