From 02aeb48d75c570cf9c5e1ee52dab0e0aeb2046a6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 17:24:13 +0300 Subject: [PATCH 1/3] refactor(grid): decompose result_grid_view.dart into grid/ modules (#1035) Move column sizing, selection model and sorting into libraries and the state logic (editing, layout, pointer selection, clipboard, cell actions, keyboard navigation, build) plus row/cell renderers into part files. result_grid_view.dart keeps the widget, state fields and lifecycle. --- .../workspace/grid/grid_cell_actions.dart | 94 + .../workspace/grid/grid_cell_editing.dart | 165 + .../workspace/grid/grid_cell_renderer.dart | 435 +++ .../grid/grid_clipboard_service.dart | 67 + .../workspace/grid/grid_column_sizer.dart | 283 ++ .../workspace/grid/grid_header_renderer.dart | 172 + .../grid/grid_keyboard_navigation.dart | 168 + .../grid/grid_pointer_selection.dart | 346 ++ .../workspace/grid/grid_row_renderer.dart | 243 ++ .../grid/grid_selection_manager.dart | 184 + lib/features/workspace/grid/grid_sorting.dart | 180 + .../workspace/grid/grid_view_builder.dart | 459 +++ .../workspace/grid/grid_view_layout.dart | 205 ++ lib/features/workspace/result_grid_view.dart | 2963 +---------------- 14 files changed, 3020 insertions(+), 2944 deletions(-) create mode 100644 lib/features/workspace/grid/grid_cell_actions.dart create mode 100644 lib/features/workspace/grid/grid_cell_editing.dart create mode 100644 lib/features/workspace/grid/grid_cell_renderer.dart create mode 100644 lib/features/workspace/grid/grid_clipboard_service.dart create mode 100644 lib/features/workspace/grid/grid_column_sizer.dart create mode 100644 lib/features/workspace/grid/grid_header_renderer.dart create mode 100644 lib/features/workspace/grid/grid_keyboard_navigation.dart create mode 100644 lib/features/workspace/grid/grid_pointer_selection.dart create mode 100644 lib/features/workspace/grid/grid_row_renderer.dart create mode 100644 lib/features/workspace/grid/grid_selection_manager.dart create mode 100644 lib/features/workspace/grid/grid_sorting.dart create mode 100644 lib/features/workspace/grid/grid_view_builder.dart create mode 100644 lib/features/workspace/grid/grid_view_layout.dart diff --git a/lib/features/workspace/grid/grid_cell_actions.dart b/lib/features/workspace/grid/grid_cell_actions.dart new file mode 100644 index 00000000..5c4da3c0 --- /dev/null +++ b/lib/features/workspace/grid/grid_cell_actions.dart @@ -0,0 +1,94 @@ +// ignore_for_file: invalid_use_of_protected_member + +part of '../result_grid_view.dart'; + +/// Context-menu cell and row actions for [VirtualResultGrid]. +extension _GridCellActions on _VirtualResultGridState { + void _handleFilterByValue(int row, int col, {required bool invert}) { + if (widget.onFilterRequested == null) return; + if (col >= widget.columns.length || row >= _sortedRows.length) return; + final colName = widget.columns[col]; + final rowData = _sortedRows[row]; + final cellVal = col < rowData.length ? rowData[col] : ''; + + String expr; + if (cellVal == 'NULL') { + expr = invert ? '$colName IS NOT NULL' : '$colName IS NULL'; + } else if (num.tryParse(cellVal) != null) { + expr = invert ? '$colName != $cellVal' : '$colName = $cellVal'; + } else { + final escaped = cellVal.replaceAll("'", "''"); + expr = invert ? "$colName != '$escaped'" : "$colName = '$escaped'"; + } + widget.onFilterRequested!(expr); + } + + void _handleFilterComparison(int row, int col, String operator) { + if (widget.onFilterRequested == null) return; + if (col >= widget.columns.length || row >= _sortedRows.length) return; + final colName = widget.columns[col]; + final rowData = _sortedRows[row]; + final cellVal = col < rowData.length ? rowData[col] : ''; + if (num.tryParse(cellVal) == null) return; + + widget.onFilterRequested!('$colName $operator $cellVal'); + } + + void _handleSetNull(int row, int col) { + if (widget.stagingBuffer == null) return; + final sel = (_selection != null && _selection!.contains(row, col)) + ? _selection! + : ResultGridSelection( + startRow: row, startColumn: col, endRow: row, endColumn: col); + for (var r = sel.startRow; r <= sel.endRow; r++) { + final modelRow = _toModelRowIndex(r); + for (var c = sel.startColumn; c <= sel.endColumn; c++) { + widget.stagingBuffer!.setCellNull(modelRow, c); + } + } + } + + void _handleSetEmpty(int row, int col) { + if (widget.stagingBuffer == null) return; + final sel = (_selection != null && _selection!.contains(row, col)) + ? _selection! + : ResultGridSelection( + startRow: row, startColumn: col, endRow: row, endColumn: col); + for (var r = sel.startRow; r <= sel.endRow; r++) { + final modelRow = _toModelRowIndex(r); + for (var c = sel.startColumn; c <= sel.endColumn; c++) { + widget.stagingBuffer!.setCell(modelRow, c, ''); + } + } + } + + void _handleRevertCell(int row, int col) { + if (widget.stagingBuffer == null) return; + final sel = (_selection != null && _selection!.contains(row, col)) + ? _selection! + : ResultGridSelection( + startRow: row, startColumn: col, endRow: row, endColumn: col); + for (var r = sel.startRow; r <= sel.endRow; r++) { + final modelRow = _toModelRowIndex(r); + for (var c = sel.startColumn; c <= sel.endColumn; c++) { + widget.stagingBuffer!.revertCell(modelRow, c); + } + } + } + + void _handleDuplicateRow(int row) { + if (widget.stagingBuffer == null || row >= _sortedRows.length) return; + widget.stagingBuffer!.duplicateRow(_toModelRowIndex(row)); + } + + void _handleToggleDeleteRow(int row) { + if (widget.stagingBuffer == null || row >= _sortedRows.length) return; + widget.stagingBuffer!.toggleDeleteRow(_toModelRowIndex(row)); + } + + void _handleRevertRow(int row) { + if (widget.stagingBuffer == null || row >= _sortedRows.length) return; + widget.stagingBuffer!.revertRow(_toModelRowIndex(row)); + } + +} diff --git a/lib/features/workspace/grid/grid_cell_editing.dart b/lib/features/workspace/grid/grid_cell_editing.dart new file mode 100644 index 00000000..317838a3 --- /dev/null +++ b/lib/features/workspace/grid/grid_cell_editing.dart @@ -0,0 +1,165 @@ +// ignore_for_file: invalid_use_of_protected_member + +part of '../result_grid_view.dart'; + +/// Inline editing and inspector handling for [VirtualResultGrid]. +extension _GridCellEditing on _VirtualResultGridState { + void _startEditing(int row, int column) { + if (widget.stagingBuffer == null) return; + if (row < 0 || + row >= _sortedRows.length || + column < 0 || + column >= widget.columns.length) { + return; + } + setState(() { + _editingCell = ResultGridCellCoordinate(row, column); + _selectionAnchor = _editingCell; + _selectionFocus = _editingCell; + _selection = ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + }); + } + + /// The selection only when it touches [rowIndex]. Rows outside the range do + /// not depend on it, so a selection change does not rebuild them. + ResultGridSelection? _selectionForRow(int rowIndex) { + final sel = _selection; + if (sel == null || rowIndex < sel.startRow || rowIndex > sel.endRow) { + return null; + } + return sel; + } + + int _toModelRowIndex(int visualRow) { + if (visualRow >= 0 && visualRow < _sortedToModelIndices.length) { + return _sortedToModelIndices[visualRow]; + } + return visualRow; + } + + void _commitEdit( + int row, + int column, + String value, { + bool moveNextCol = false, + bool movePrevCol = false, + bool moveNextRow = false, + bool movePrevRow = false, + }) { + if (widget.stagingBuffer != null) { + final modelRow = _toModelRowIndex(row); + widget.stagingBuffer!.setCell(modelRow, column, value); + } + setState(() { + if (moveNextCol) { + if (column + 1 < widget.columns.length) { + _editingCell = ResultGridCellCoordinate(row, column + 1); + _selection = ResultGridSelection( + startRow: row, + startColumn: column + 1, + endRow: row, + endColumn: column + 1, + ); + } else if (row + 1 < _sortedRows.length) { + _editingCell = ResultGridCellCoordinate(row + 1, 0); + _selection = ResultGridSelection( + startRow: row + 1, + startColumn: 0, + endRow: row + 1, + endColumn: 0, + ); + } else { + _editingCell = null; + } + } else if (movePrevCol) { + if (column > 0) { + _editingCell = ResultGridCellCoordinate(row, column - 1); + _selection = ResultGridSelection( + startRow: row, + startColumn: column - 1, + endRow: row, + endColumn: column - 1, + ); + } else if (row > 0) { + _editingCell = + ResultGridCellCoordinate(row - 1, widget.columns.length - 1); + _selection = ResultGridSelection( + startRow: row - 1, + startColumn: widget.columns.length - 1, + endRow: row - 1, + endColumn: widget.columns.length - 1, + ); + } else { + _editingCell = null; + } + } else if (moveNextRow) { + if (row + 1 < _sortedRows.length) { + _editingCell = ResultGridCellCoordinate(row + 1, column); + _selection = ResultGridSelection( + startRow: row + 1, + startColumn: column, + endRow: row + 1, + endColumn: column, + ); + } else { + _editingCell = null; + } + } else if (movePrevRow) { + if (row > 0) { + _editingCell = ResultGridCellCoordinate(row - 1, column); + _selection = ResultGridSelection( + startRow: row - 1, + startColumn: column, + endRow: row - 1, + endColumn: column, + ); + } else { + _editingCell = null; + } + } else { + _editingCell = null; + } + if (_editingCell != null) { + _selectionAnchor = _editingCell; + _selectionFocus = _editingCell; + widget.onRowSelected?.call(_editingCell!.row); + _scrollToCell(_editingCell!.row, _editingCell!.column); + } + }); + _notifySelectionAndFocus(); + } + + void _cancelEdit() { + setState(() { + _editingCell = null; + }); + } + + Future _openInspector(int row, int column) async { + if (row < 0 || + row >= _sortedRows.length || + column < 0 || + column >= widget.columns.length) { + return; + } + final colName = widget.columns[column]; + final currentVal = + column < _sortedRows[row].length ? _sortedRows[row][column] : ''; + final result = await showGridCellInspectorDialog( + context: context, + columnName: colName, + initialValue: currentVal, + rowIndex: row, + dataTypeName: widget.columnDataTypes?[colName], + ); + if (result != null && widget.stagingBuffer != null) { + widget.stagingBuffer!.setCell(_toModelRowIndex(row), column, result); + } + } + +} diff --git a/lib/features/workspace/grid/grid_cell_renderer.dart b/lib/features/workspace/grid/grid_cell_renderer.dart new file mode 100644 index 00000000..6997a797 --- /dev/null +++ b/lib/features/workspace/grid/grid_cell_renderer.dart @@ -0,0 +1,435 @@ +part of '../result_grid_view.dart'; + +class _GridCell extends material.StatelessWidget { + const _GridCell({ + super.key, + required this.row, + required this.column, + this.columnName = '', + required this.text, + required this.width, + required this.colorScheme, + required this.striped, + this.rowStatus = StagedRowStatus.unchanged, + this.cellStatus = StagedCellStatus.clean, + this.isSelected = false, + this.isEditing = false, + this.isSelectionTop = false, + this.isSelectionBottom = false, + this.isSelectionLeft = false, + this.isSelectionRight = false, + this.canFilter = false, + this.hasStagingBuffer = false, + this.dataTypeName, + this.onSecondaryTap, + this.onCommitEdit, + this.onCancelEdit, + this.onOpenInspector, + this.onCopyCell, + this.onFilterByValue, + this.onFilterComparison, + this.onSetNull, + this.onSetEmpty, + this.onRevertCell, + this.onDuplicateRow, + this.onToggleDeleteRow, + this.onRevertRow, + }); + + final int row; + final int column; + final String columnName; + final String text; + final double width; + final ColorScheme colorScheme; + final bool striped; + final StagedRowStatus rowStatus; + final StagedCellStatus cellStatus; + final bool isSelected; + final bool isEditing; + final bool isSelectionTop; + final bool isSelectionBottom; + final bool isSelectionLeft; + final bool isSelectionRight; + final bool canFilter; + final bool hasStagingBuffer; + final String? dataTypeName; + final void Function(int row, int col)? onSecondaryTap; + final void Function( + int row, + int col, + String val, { + bool moveNextCol, + bool movePrevCol, + bool moveNextRow, + bool movePrevRow, + })? onCommitEdit; + final material.VoidCallback? onCancelEdit; + final void Function(int row, int col)? onOpenInspector; + final void Function( + int row, + int col, { + bool withHeaders, + bool asJson, + bool asCsv, + })? onCopyCell; + final void Function(int row, int col, {required bool invert})? + onFilterByValue; + final void Function(int row, int col, String operator)? onFilterComparison; + final void Function(int row, int col)? onSetNull; + final void Function(int row, int col)? onSetEmpty; + final void Function(int row, int col)? onRevertCell; + final void Function(int row)? onDuplicateRow; + final void Function(int row)? onToggleDeleteRow; + final void Function(int row)? onRevertRow; + + /// True when [other] would draw exactly the same cell (see [_DataRow.sameAs]): + /// scrolling the column window keeps the cells that stay visible untouched. + bool sameAs(_GridCell other) => + identical(this, other) || + row == other.row && + column == other.column && + columnName == other.columnName && + text == other.text && + width == other.width && + colorScheme == other.colorScheme && + striped == other.striped && + rowStatus == other.rowStatus && + cellStatus == other.cellStatus && + isSelected == other.isSelected && + isEditing == other.isEditing && + isSelectionTop == other.isSelectionTop && + isSelectionBottom == other.isSelectionBottom && + isSelectionLeft == other.isSelectionLeft && + isSelectionRight == other.isSelectionRight && + canFilter == other.canFilter && + hasStagingBuffer == other.hasStagingBuffer && + dataTypeName == other.dataTypeName && + onSecondaryTap == other.onSecondaryTap && + onCommitEdit == other.onCommitEdit && + onCancelEdit == other.onCancelEdit && + onOpenInspector == other.onOpenInspector && + onCopyCell == other.onCopyCell && + onFilterByValue == other.onFilterByValue && + onFilterComparison == other.onFilterComparison && + onSetNull == other.onSetNull && + onSetEmpty == other.onSetEmpty && + onRevertCell == other.onRevertCell && + onDuplicateRow == other.onDuplicateRow && + onToggleDeleteRow == other.onToggleDeleteRow && + onRevertRow == other.onRevertRow; + + List _buildContextMenuItems(material.BuildContext context) { + gridCellContextMenuItemsBuiltCount++; + final preview = text.length > 24 ? '${text.substring(0, 22)}…' : text; + final isNull = text == 'NULL'; + final numVal = num.tryParse(text); + + return [ + MenuButton( + leading: + const material.Icon(material.Icons.content_copy_rounded, size: 16), + trailing: const material.Text('Ctrl+C', + style: material.TextStyle(fontSize: 11)), + onPressed: (_) => onCopyCell?.call(row, column), + child: const material.Text('Copy Value'), + ), + MenuButton( + leading: + const material.Icon(material.Icons.table_chart_outlined, size: 16), + trailing: const material.Text('Ctrl+Shift+C', + style: material.TextStyle(fontSize: 11)), + onPressed: (_) => onCopyCell?.call(row, column, withHeaders: true), + child: const material.Text('Copy with Headers'), + ), + MenuButton( + leading: + const material.Icon(material.Icons.data_object_rounded, size: 16), + onPressed: (_) => onCopyCell?.call(row, column, asJson: true), + child: const material.Text('Copy as JSON'), + ), + MenuButton( + leading: const material.Icon(material.Icons.grid_on_rounded, size: 16), + onPressed: (_) => onCopyCell?.call(row, column, asCsv: true), + child: const material.Text('Copy as CSV'), + ), + if (canFilter && columnName.isNotEmpty) ...[ + const MenuDivider(), + MenuButton( + leading: + const material.Icon(material.Icons.filter_alt_outlined, size: 16), + onPressed: (_) => onFilterByValue?.call(row, column, invert: false), + child: material.Text( + isNull + ? 'Filter by NULL ($columnName IS NULL)' + : 'Filter by Value ($columnName = $preview)', + ), + ), + MenuButton( + leading: const material.Icon(material.Icons.filter_alt_off_outlined, + size: 16), + onPressed: (_) => onFilterByValue?.call(row, column, invert: true), + child: material.Text( + isNull + ? 'Filter Out NULL ($columnName IS NOT NULL)' + : 'Filter Out ($columnName != $preview)', + ), + ), + if (numVal != null) ...[ + MenuButton( + leading: const material.Icon(material.Icons.chevron_right_rounded, + size: 16), + onPressed: (_) => onFilterComparison?.call(row, column, '>'), + child: material.Text('Filter > $text ($columnName > $text)'), + ), + MenuButton( + leading: const material.Icon(material.Icons.chevron_left_rounded, + size: 16), + onPressed: (_) => onFilterComparison?.call(row, column, '<'), + child: material.Text('Filter < $text ($columnName < $text)'), + ), + ], + ], + const MenuDivider(), + MenuButton( + leading: + const material.Icon(material.Icons.visibility_outlined, size: 16), + trailing: const material.Text('Ctrl+I', + style: material.TextStyle(fontSize: 11)), + onPressed: (_) => onOpenInspector?.call(row, column), + child: const material.Text('Inspect Cell Value…'), + ), + if (hasStagingBuffer) ...[ + MenuButton( + leading: const material.Icon( + material.Icons.remove_circle_outline_rounded, + size: 16), + trailing: const material.Text('Alt+N', + style: material.TextStyle(fontSize: 11)), + onPressed: (_) => onSetNull?.call(row, column), + child: const material.Text('Set NULL'), + ), + MenuButton( + leading: const material.Icon(material.Icons.format_clear_rounded, + size: 16), + onPressed: (_) => onSetEmpty?.call(row, column), + child: const material.Text('Set to Empty String'), + ), + if (cellStatus == StagedCellStatus.modified) + MenuButton( + leading: const material.Icon(material.Icons.undo_rounded, size: 16), + onPressed: (_) => onRevertCell?.call(row, column), + child: const material.Text('Revert Cell Changes'), + ), + const MenuDivider(), + MenuButton( + leading: const material.Icon(material.Icons.content_paste_go_rounded, + size: 16), + trailing: const material.Text('Ctrl+D', + style: material.TextStyle(fontSize: 11)), + onPressed: (_) => onDuplicateRow?.call(row), + child: const material.Text('Duplicate Row'), + ), + MenuButton( + leading: material.Icon( + rowStatus == StagedRowStatus.deleted + ? material.Icons.restore_from_trash_rounded + : material.Icons.delete_outline_rounded, + size: 16, + color: rowStatus == StagedRowStatus.deleted + ? null + : colorScheme.destructive, + ), + onPressed: (_) => onToggleDeleteRow?.call(row), + child: material.Text( + rowStatus == StagedRowStatus.deleted + ? 'Restore Deleted Row' + : 'Delete Row', + style: material.TextStyle( + color: rowStatus == StagedRowStatus.deleted + ? null + : colorScheme.destructive, + ), + ), + ), + if (rowStatus == StagedRowStatus.modified || + rowStatus == StagedRowStatus.deleted) + MenuButton( + leading: + const material.Icon(material.Icons.restore_rounded, size: 16), + onPressed: (_) => onRevertRow?.call(row), + child: const material.Text('Revert Row Changes'), + ), + ], + ]; + } + + @override + material.Widget build(material.BuildContext context) { + if (isEditing) { + return GridCellEditor( + initialValue: text, + width: width, + height: double.infinity, + dataTypeName: dataTypeName, + onCommit: (val, + {moveNextCol = false, + movePrevCol = false, + moveNextRow = false, + movePrevRow = false}) { + onCommitEdit?.call( + row, + column, + val, + moveNextCol: moveNextCol, + movePrevCol: movePrevCol, + moveNextRow: moveNextRow, + movePrevRow: movePrevRow, + ); + }, + onCancel: () => onCancelEdit?.call(), + onOpenInspector: () => onOpenInspector?.call(row, column), + ); + } + + final isNull = text == 'NULL'; + final isDeleted = rowStatus == StagedRowStatus.deleted; + final isInserted = rowStatus == StagedRowStatus.inserted; + final isModified = cellStatus == StagedCellStatus.modified; + + var style = material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.normal, + fontFamily: 'monospace', + color: isDeleted + ? colorScheme.destructive.withValues(alpha: 0.7) + : (isNull + ? colorScheme.mutedForeground.withValues(alpha: 0.5) + : colorScheme.foreground), + decoration: isDeleted ? material.TextDecoration.lineThrough : null, + fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, + ); + + var bg = isSelected + ? colorScheme.primary.withValues(alpha: 0.18) + : (isDeleted + ? colorScheme.destructive.withValues(alpha: 0.08) + : (isModified + ? colorScheme.primary.withValues(alpha: 0.14) + : (isInserted + ? colorScheme.primary.withValues(alpha: 0.08) + : (striped + ? colorScheme.muted.withValues(alpha: 0.12) + : material.Colors.transparent)))); + + final cell = material.Container( + width: width, + height: double.infinity, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + decoration: material.BoxDecoration( + color: bg, + border: material.Border( + right: material.BorderSide( + color: isSelectionRight + ? colorScheme.primary + : colorScheme.border.withValues(alpha: 0.3), + width: isSelectionRight ? 1.5 : 1.0, + ), + left: isSelectionLeft + ? material.BorderSide(color: colorScheme.primary, width: 1.5) + : material.BorderSide.none, + top: isSelectionTop + ? material.BorderSide(color: colorScheme.primary, width: 1.5) + : material.BorderSide.none, + bottom: isSelectionBottom + ? material.BorderSide(color: colorScheme.primary, width: 1.5) + : material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.15), + ), + ), + ), + child: material.Stack( + clipBehavior: material.Clip.none, + alignment: material.Alignment.centerLeft, + children: [ + material.Text( + text, + style: style, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + if (isModified) + material.Positioned( + top: -8, + right: -8, + child: material.CustomPaint( + size: const material.Size(6, 6), + painter: _TriangleCornerPainter(color: colorScheme.primary), + ), + ), + ], + ), + ); + + // Tap / double-tap / drag-select are handled by one Listener at the grid + // level (_onGridPointerDown/Move/Up + _cellAtOffset hit-testing) instead + // of a GestureDetector per cell, and the cursor by one grid-level + // MouseRegion instead of one per cell (#983) — both were previously + // among the ~10 widgets/render objects built for every visible cell. + material.Widget content = cell; + final tooltip = ResultGridMetrics.cellTooltipMessage( + text: text, + dataTypeName: dataTypeName, + columnName: columnName, + ); + if (tooltip != null) { + content = material.Tooltip( + message: tooltip, + waitDuration: kQueryaTooltipWait, + child: content, + ); + } + + // The row this cell belongs to carries one semantics node with a label + // for all its visible cells (see _DataRow._semanticsLabel, #981); a + // GestureDetector/Tooltip/Text node per cell on top of that would be + // redundant and is one of the grid's largest per-frame costs. + return material.ExcludeSemantics( + child: material.Listener( + onPointerDown: (event) { + if (event.buttons == kSecondaryMouseButton) { + onSecondaryTap?.call(row, column); + } + }, + child: ContextMenu( + // Lazy: right-clicking a cell is rare relative to how often cells + // get rebuilt (scroll, selection, ...); building the item list + // eagerly on every build was pure waste (#983). + itemsBuilder: _buildContextMenuItems, + child: content, + ), + ), + ); + } +} + +class _TriangleCornerPainter extends material.CustomPainter { + const _TriangleCornerPainter({required this.color}); + final material.Color color; + + @override + void paint(material.Canvas canvas, material.Size size) { + final paint = material.Paint()..color = color; + final path = material.Path() + ..moveTo(0, 0) + ..lineTo(size.width, 0) + ..lineTo(size.width, size.height) + ..close(); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _TriangleCornerPainter oldDelegate) => + oldDelegate.color != color; +} diff --git a/lib/features/workspace/grid/grid_clipboard_service.dart b/lib/features/workspace/grid/grid_clipboard_service.dart new file mode 100644 index 00000000..d66557a7 --- /dev/null +++ b/lib/features/workspace/grid/grid_clipboard_service.dart @@ -0,0 +1,67 @@ +// ignore_for_file: invalid_use_of_protected_member + +part of '../result_grid_view.dart'; + +/// Clipboard copy (TSV/CSV/JSON) for [VirtualResultGrid]. +extension _GridClipboard on _VirtualResultGridState { + void _copySelection( + {bool withHeaders = false, bool asCsv = false, bool asJson = false}) { + if (_selection == null) return; + _copySelectionData( + _selection!, + withHeaders: withHeaders, + asCsv: asCsv, + asJson: asJson, + ); + } + + void _handleCopyCell( + int row, + int column, { + bool withHeaders = false, + bool asCsv = false, + bool asJson = false, + }) { + final sel = (_selection != null && _selection!.contains(row, column)) + ? _selection! + : ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + _copySelectionData( + sel, + withHeaders: withHeaders, + asCsv: asCsv, + asJson: asJson, + ); + } + + void _copySelectionData( + ResultGridSelection sel, { + bool withHeaders = false, + bool asCsv = false, + bool asJson = false, + }) { + if (_sortedRows.isEmpty) return; + String text; + if (asJson) { + text = sel.toJson(widget.columns, _sortedRows); + } else if (asCsv) { + text = sel.toCsv( + _sortedRows, + columns: withHeaders ? widget.columns : null, + ); + } else { + text = sel.toTsv( + _sortedRows, + columns: withHeaders ? widget.columns : null, + ); + } + if (text.isNotEmpty) { + Clipboard.setData(ClipboardData(text: text)); + } + } + +} diff --git a/lib/features/workspace/grid/grid_column_sizer.dart b/lib/features/workspace/grid/grid_column_sizer.dart new file mode 100644 index 00000000..ea56fe3c --- /dev/null +++ b/lib/features/workspace/grid/grid_column_sizer.dart @@ -0,0 +1,283 @@ +import 'dart:math' as math; + +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Layout metrics for [VirtualResultGrid]. +abstract final class ResultGridMetrics { + static const double rowHeight = 36; + static const double headerHeight = 36; + static const double minColumnWidth = 56; + 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. + /// Columns built beyond each viewport edge. A wider margin means the window + /// is rebuilt only every few columns of horizontal scroll, not on each one. + static const int columnOverscan = 6; + + /// Hover tooltip: column SQL type when known, plus the cell value when long. + static String? cellTooltipMessage({ + required String text, + String? dataTypeName, + String columnName = '', + }) { + final type = dataTypeName?.trim() ?? ''; + final typePart = type.isEmpty + ? null + : (columnName.isNotEmpty ? '$columnName · $type' : type); + final showValue = text.length >= tooltipMinLength; + if (typePart == null && !showValue) return null; + if (typePart != null && showValue) return '$typePart\n$text'; + return typePart ?? text; + } +} + +/// 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]. +List computeResultGridColumnWidths({ + required List columns, + required List> rows, + double minWidth = ResultGridMetrics.minColumnWidth, + double maxWidth = ResultGridMetrics.maxColumnWidth, + int sampleRowCount = ResultGridMetrics.columnWidthSampleRows, +}) { + if (columns.isEmpty) return const []; + + final widths = List.filled(columns.length, minWidth); + final sample = rows.length < sampleRowCount ? rows.length : sampleRowCount; + + for (var c = 0; c < columns.length; c++) { + final headerWidth = columns[c].length * 7.5 + 38.0; + var maxRowChars = 0; + for (var r = 0; r < sample; r++) { + if (c < rows[r].length && rows[r][c].length > maxRowChars) { + maxRowChars = rows[r][c].length; + } + } + final contentWidth = maxRowChars * 7.5 + 24.0; + final naturalWidth = math.max(headerWidth, contentWidth); + widths[c] = naturalWidth.clamp(minWidth, maxWidth); + } + return widths; +} + +/// Counts calls to `_GridCell._buildContextMenuItems` — should only fire when +/// a cell's context menu is actually opened (#983), not on every cell build. +int gridCellContextMenuItemsBuiltCount = 0; + +/// Distributes spare viewport width adaptively to columns that benefit from expansion, +/// avoiding artificial stretching of compact columns. +List distributeResultGridSpareWidth({ + required List columnWidths, + required List columns, + required List> rows, + required double availableWidth, + double maxColumnWidth = ResultGridMetrics.maxColumnWidth, + int sampleRowCount = ResultGridMetrics.columnWidthSampleRows, +}) { + if (columnWidths.isEmpty || columns.isEmpty) return columnWidths; + + final totalInitial = columnWidths.fold(0.0, (sum, w) => sum + w); + final spare = availableWidth - totalInitial; + if (spare <= 0.5) return columnWidths; + + final sample = rows.length < sampleRowCount ? rows.length : sampleRowCount; + final desiredWidths = List.filled(columns.length, 0); + + for (var c = 0; c < columns.length; c++) { + final headerWidth = columns[c].length * 7.5 + 38.0; + var maxRowChars = 0; + for (var r = 0; r < sample; r++) { + if (c < rows[r].length && rows[r][c].length > maxRowChars) { + maxRowChars = rows[r][c].length; + } + } + final contentWidth = maxRowChars * 7.5 + 24.0; + desiredWidths[c] = math.max(headerWidth, contentWidth); + } + + // Deficit columns: columns whose desired width exceeds the clamped initial width + final deficits = List.filled(columns.length, 0); + var totalDeficit = 0.0; + for (var c = 0; c < columns.length; c++) { + if (desiredWidths[c] > columnWidths[c]) { + final def = desiredWidths[c] - columnWidths[c]; + deficits[c] = def; + totalDeficit += def; + } + } + + final result = List.from(columnWidths); + + if (totalDeficit > 0) { + if (spare <= totalDeficit) { + final ratio = spare / totalDeficit; + for (var c = 0; c < columns.length; c++) { + if (deficits[c] > 0) { + result[c] += deficits[c] * ratio; + } + } + return result; + } else { + for (var c = 0; c < columns.length; c++) { + if (deficits[c] > 0) { + result[c] += deficits[c]; + } + } + final remainingSpare = spare - totalDeficit; + _distributeRemainingSpare(result, remainingSpare); + return result; + } + } else { + _distributeRemainingSpare(result, spare); + return result; + } +} + +void _distributeRemainingSpare(List widths, double spare) { + final weights = List.filled(widths.length, 0); + var totalWeight = 0.0; + for (var i = 0; i < widths.length; i++) { + if (widths[i] > 110) { + final w = widths[i] - 100; + weights[i] = w; + totalWeight += w; + } + } + + if (totalWeight <= 0) { + return; + } + + final ratio = spare / totalWeight; + for (var i = 0; i < widths.length; i++) { + if (weights[i] > 0) { + final extra = math.min(weights[i] * ratio, 120.0); + widths[i] += extra; + } + } +} + +/// Prefix sums: `offsets[i]` = sum of widths `[0, i)`. +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). +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]: smallest index where columnOffsets[first + 1] > start + var first = 0; + var low = 0; + var high = n - 1; + while (low <= high) { + final mid = (low + high) ~/ 2; + if (columnOffsets[mid + 1] > start) { + first = mid; + high = mid - 1; + } else { + low = mid + 1; + } + } + + // Last column with any pixel before [end]: largest index where columnOffsets[last] < end + var last = n - 1; + low = 0; + high = n - 1; + while (low <= high) { + final mid = (low + high) ~/ 2; + if (columnOffsets[mid] < end) { + last = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + 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], + ); +} diff --git a/lib/features/workspace/grid/grid_header_renderer.dart b/lib/features/workspace/grid/grid_header_renderer.dart new file mode 100644 index 00000000..ebb92cb4 --- /dev/null +++ b/lib/features/workspace/grid/grid_header_renderer.dart @@ -0,0 +1,172 @@ +part of '../result_grid_view.dart'; + +class _HeaderRow extends material.StatelessWidget { + const _HeaderRow({ + required this.columns, + required this.columnWidths, + required this.window, + required this.height, + required this.colorScheme, + this.sortColumnIndex, + this.sortOrder, + this.onSortColumn, + this.onResizeColumn, + this.onAutoFitColumn, + }); + + final List columns; + final List columnWidths; + final ResultGridColumnWindow window; + final double height; + final ColorScheme colorScheme; + final int? sortColumnIndex; + final ResultGridSortOrder? sortOrder; + final material.ValueChanged? onSortColumn; + final void Function(int index, double delta)? onResizeColumn; + final void Function(int index)? onAutoFitColumn; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + height: height, + decoration: material.BoxDecoration( + color: colorScheme.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.5), + ), + ), + ), + child: material.Row( + children: [ + if (window.leadingWidth > 0) + material.SizedBox(width: window.leadingWidth), + for (var i = window.first; i <= window.last; i++) + _HeaderCell( + text: columns[i], + width: columnWidths[i], + colorScheme: colorScheme, + sortOrder: sortColumnIndex == i ? sortOrder : null, + onSort: onSortColumn != null ? () => onSortColumn!(i) : null, + onResize: onResizeColumn != null + ? (delta) => onResizeColumn!(i, delta) + : null, + onAutoFit: + onAutoFitColumn != null ? () => onAutoFitColumn!(i) : null, + ), + if (window.trailingWidth > 0) + material.SizedBox(width: window.trailingWidth), + ], + ), + ); + } +} + +class _HeaderCell extends material.StatelessWidget { + const _HeaderCell({ + required this.text, + required this.width, + required this.colorScheme, + this.sortOrder, + this.onSort, + this.onResize, + this.onAutoFit, + }); + + final String text; + final double width; + final ColorScheme colorScheme; + final ResultGridSortOrder? sortOrder; + final material.VoidCallback? onSort; + final material.ValueChanged? onResize; + final material.VoidCallback? onAutoFit; + + @override + material.Widget build(material.BuildContext context) { + final isSorted = sortOrder != null; + final style = material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: isSorted ? colorScheme.primary : colorScheme.foreground, + ); + + return material.Container( + width: width, + height: double.infinity, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Stack( + clipBehavior: material.Clip.none, + children: [ + material.Positioned.fill( + child: material.MouseRegion( + cursor: onSort != null + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: onSort, + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + child: material.Row( + children: [ + material.Expanded( + child: material.Tooltip( + message: text, + waitDuration: kQueryaTooltipWait, + child: material.Text( + text, + style: style, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + if (sortOrder != null) ...[ + const Gap(4), + material.AnimatedRotation( + turns: sortOrder == ResultGridSortOrder.ascending + ? 0.0 + : 0.5, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), + child: material.Icon( + material.Icons.arrow_upward_rounded, + size: 14, + color: colorScheme.primary, + ), + ), + ], + ], + ), + ), + ), + ), + ), + if (onResize != null || onAutoFit != null) + material.Positioned( + right: -5, + top: 0, + bottom: 0, + width: 12, + child: material.MouseRegion( + cursor: material.SystemMouseCursors.resizeColumn, + child: material.GestureDetector( + behavior: material.HitTestBehavior.translucent, + onDoubleTap: onAutoFit, + onHorizontalDragUpdate: onResize != null + ? (details) => onResize!(details.delta.dx) + : null, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/workspace/grid/grid_keyboard_navigation.dart b/lib/features/workspace/grid/grid_keyboard_navigation.dart new file mode 100644 index 00000000..a298b565 --- /dev/null +++ b/lib/features/workspace/grid/grid_keyboard_navigation.dart @@ -0,0 +1,168 @@ +// ignore_for_file: invalid_use_of_protected_member + +part of '../result_grid_view.dart'; + +/// Keyboard navigation and scroll-to-cell for [VirtualResultGrid]. +extension _GridKeyboardNavigation on _VirtualResultGridState { + void _scrollToCell(int row, int col) { + if (!mounted) return; + final rowHeight = _scaledRowHeight(context); + final headerHeight = _scaledHeaderHeight(context); + + // Vertical scroll + if (_verticalController.hasClients) { + final targetTop = row * rowHeight; + final targetBottom = targetTop + rowHeight; + final currentOffset = _verticalController.offset; + final viewportHeight = + _verticalController.position.viewportDimension - headerHeight; + + if (targetTop < currentOffset) { + _verticalController.jumpTo(targetTop.clamp( + 0.0, + _verticalController.position.maxScrollExtent, + )); + } else if (targetBottom > currentOffset + viewportHeight && + viewportHeight > 0) { + final newOffset = (targetBottom - viewportHeight).clamp( + 0.0, + _verticalController.position.maxScrollExtent, + ); + _verticalController.jumpTo(newOffset); + } + } + + // Horizontal scroll + if (_horizontalController.hasClients && + col >= 0 && + col < _columnWidths.length) { + final colLeft = _columnOffsets[col]; + final colRight = colLeft + _columnWidths[col]; + final currentOffset = _horizontalController.offset; + final viewportWidth = _horizontalController.position.viewportDimension; + + if (colLeft < currentOffset) { + _horizontalController.jumpTo(colLeft.clamp( + 0.0, + _horizontalController.position.maxScrollExtent, + )); + } else if (colRight > currentOffset + viewportWidth && + viewportWidth > 0) { + final newOffset = (colRight - viewportWidth).clamp( + 0.0, + _horizontalController.position.maxScrollExtent, + ); + _horizontalController.jumpTo(newOffset); + } + } + } + + void _navigateCell(int dRow, int dCol, {bool extendSelection = false}) { + if (_sortedRows.isEmpty || widget.columns.isEmpty) return; + if (_editingCell != null) return; + + if (_selectionAnchor == null) { + setState(() { + _selectionAnchor = const ResultGridCellCoordinate(0, 0); + _selectionFocus = const ResultGridCellCoordinate(0, 0); + _selection = const ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 0, + endColumn: 0, + ); + }); + widget.onRowSelected?.call(0); + _scrollToCell(0, 0); + _notifySelectionAndFocus(); + return; + } + + final currentFocus = _selectionFocus ?? _selectionAnchor!; + final newRow = (currentFocus.row + dRow).clamp(0, _sortedRows.length - 1); + final newCol = + (currentFocus.column + dCol).clamp(0, widget.columns.length - 1); + final newFocus = ResultGridCellCoordinate(newRow, newCol); + + setState(() { + if (extendSelection) { + _selectionFocus = newFocus; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor!, + focus: newFocus, + ); + } else { + _selectionAnchor = newFocus; + _selectionFocus = newFocus; + _selection = ResultGridSelection( + startRow: newRow, + startColumn: newCol, + endRow: newRow, + endColumn: newCol, + ); + widget.onRowSelected?.call(newRow); + } + }); + + _scrollToCell(newRow, newCol); + _notifySelectionAndFocus(); + } + + void _jumpToCell({int? row, int? column, bool extendSelection = false}) { + if (_sortedRows.isEmpty || widget.columns.isEmpty) return; + if (_editingCell != null) return; + + final currentFocus = _selectionFocus ?? + _selectionAnchor ?? + const ResultGridCellCoordinate(0, 0); + final newRow = (row ?? currentFocus.row).clamp(0, _sortedRows.length - 1); + final newCol = + (column ?? currentFocus.column).clamp(0, widget.columns.length - 1); + final newFocus = ResultGridCellCoordinate(newRow, newCol); + + setState(() { + if (extendSelection) { + _selectionAnchor ??= currentFocus; + _selectionFocus = newFocus; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor!, + focus: newFocus, + ); + } else { + _selectionAnchor = newFocus; + _selectionFocus = newFocus; + _selection = ResultGridSelection( + startRow: newRow, + startColumn: newCol, + endRow: newRow, + endColumn: newCol, + ); + widget.onRowSelected?.call(newRow); + } + }); + + _scrollToCell(newRow, newCol); + _notifySelectionAndFocus(); + } + + void _selectAll() { + if (_sortedRows.isEmpty || widget.columns.isEmpty) return; + if (_editingCell != null) return; + + setState(() { + _selectionAnchor = const ResultGridCellCoordinate(0, 0); + _selectionFocus = ResultGridCellCoordinate( + _sortedRows.length - 1, + widget.columns.length - 1, + ); + _selection = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: _sortedRows.length - 1, + endColumn: widget.columns.length - 1, + ); + }); + + _notifySelectionAndFocus(); + } +} diff --git a/lib/features/workspace/grid/grid_pointer_selection.dart b/lib/features/workspace/grid/grid_pointer_selection.dart new file mode 100644 index 00000000..67554d91 --- /dev/null +++ b/lib/features/workspace/grid/grid_pointer_selection.dart @@ -0,0 +1,346 @@ +// ignore_for_file: invalid_use_of_protected_member + +part of '../result_grid_view.dart'; + +/// Pointer, drag and tap selection for [VirtualResultGrid]. +extension _GridPointerSelection on _VirtualResultGridState { + void _notifySelectionAndFocus() { + if (widget.onSelectionValuesChanged != null) { + if (_selection == null) { + widget.onSelectionValuesChanged!(const []); + } else { + final rows = _sortedRows; + final values = []; + for (var r = _selection!.startRow; r <= _selection!.endRow; r++) { + if (r >= 0 && r < rows.length) { + for (var c = _selection!.startColumn; + c <= _selection!.endColumn; + c++) { + if (c >= 0 && c < rows[r].length) { + values.add(rows[r][c]); + } + } + } + } + widget.onSelectionValuesChanged!(values); + } + } + + if (widget.onCellFocused != null && _selectionAnchor != null) { + final r = _selectionAnchor!.row; + final c = _selectionAnchor!.column; + final rows = _sortedRows; + if (r >= 0 && r < rows.length && c >= 0 && c < widget.columns.length) { + final colName = widget.columns[c]; + final val = c < rows[r].length ? rows[r][c] : ''; + final modelRow = _toModelRowIndex(r); + widget.onCellFocused!(colName, val, modelRow); + } + } + } + + ResultGridCellCoordinate? _cellAtOffset({ + required Offset localPosition, + required double rowHeight, + }) { + if (_sortedRows.isEmpty || widget.columns.isEmpty) return null; + final rowCount = _sortedRows.length; + final colCount = widget.columns.length; + + final verticalOffset = + _verticalController.hasClients ? _verticalController.offset : 0.0; + final tableY = localPosition.dy + verticalOffset; + final rowIndex = (tableY / rowHeight).floor().clamp(0, rowCount - 1); + + final tableX = localPosition.dx; + final offsets = _currentDisplayOffsets.length >= colCount + 1 + ? _currentDisplayOffsets + : _columnOffsets; + int colIndex = colCount - 1; + for (var i = 0; i < colCount; i++) { + if (i + 1 < offsets.length && tableX < offsets[i + 1]) { + colIndex = i; + break; + } + } + colIndex = colIndex.clamp(0, colCount - 1); + + return ResultGridCellCoordinate(rowIndex, colIndex); + } + + void _onGridPointerDown( + PointerDownEvent event, { + required double rowHeight, + }) { + if (event.buttons != kPrimaryMouseButton) return; + if (_sortedRows.isEmpty || widget.columns.isEmpty) return; + + if (_editingCell != null) { + final downCell = _cellAtOffset( + localPosition: event.localPosition, + rowHeight: rowHeight, + ); + if (downCell == _editingCell) { + // The press landed on the cell currently being edited (e.g. to move + // the caret or select text inside the open GridCellEditor) — let the + // editor's own gesture handling deal with it instead of cancelling + // the in-progress edit out from under the user (#1006). + return; + } + _cancelEdit(); + } + + _isPointerDown = true; + _isDragSelecting = false; + _hasJustDragSelected = false; + _pointerDownGlobalPos = event.position; + _pointerDownLocalPos = event.localPosition; + _lastPointerLocalPos = event.localPosition; + } + + void _onGridPointerMove( + PointerMoveEvent event, { + required double rowHeight, + }) { + if (!_isPointerDown) return; + _lastPointerLocalPos = event.localPosition; + + if (!_isDragSelecting) { + final delta = (event.position - _pointerDownGlobalPos).distance; + if (delta > 4.0) { + _isDragSelecting = true; + _hasJustDragSelected = false; + final startCell = _cellAtOffset( + localPosition: _pointerDownLocalPos, + rowHeight: rowHeight, + ); + if (startCell != null) { + final isShift = HardwareKeyboard.instance.isShiftPressed; + if (!isShift || _selectionAnchor == null) { + _selectionAnchor = startCell; + } + _selectionFocus = startCell; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor!, + focus: startCell, + ); + _focusNode.requestFocus(); + final modelRow = _toModelRowIndex(startCell.row); + widget.onRowSelected?.call(modelRow); + _notifySelectionAndFocus(); + setState(() {}); + } + _startAutoScroll(rowHeight); + } + } + + if (_isDragSelecting) { + final currentCell = _cellAtOffset( + localPosition: event.localPosition, + rowHeight: rowHeight, + ); + if (currentCell != null && currentCell != _selectionFocus) { + setState(() { + _selectionFocus = currentCell; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor ?? currentCell, + focus: currentCell, + ); + }); + final modelRow = _toModelRowIndex(currentCell.row); + widget.onRowSelected?.call(modelRow); + _notifySelectionAndFocus(); + } + } + } + + void _onGridPointerUp( + PointerUpEvent event, { + required double rowHeight, + }) { + if (_isDragSelecting) { + _hasJustDragSelected = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _hasJustDragSelected = false; + }); + } else if (_isPointerDown) { + // A plain click (never crossed the drag threshold): resolve which + // cell was pressed — the down position is the more accurate "which + // cell did the user click" signal than wherever the pointer lifted — + // and treat it as a tap, or as a double-tap if it lands on the same + // cell as the last qualifying tap within the platform's double-tap + // window (#983: replaces a per-cell GestureDetector). + final cell = _cellAtOffset( + localPosition: _pointerDownLocalPos, + rowHeight: rowHeight, + ); + if (cell != null) { + final now = DateTime.now(); + final isDoubleTap = _lastTapCell == cell && + _lastTapUpTime != null && + now.difference(_lastTapUpTime!) <= kDoubleTapTimeout; + final isShift = HardwareKeyboard.instance.isShiftPressed; + _onCellTap(cell.row, cell.column, isShift: isShift); + if (isDoubleTap) { + _startEditing(cell.row, cell.column); + _lastTapUpTime = null; + _lastTapCell = null; + } else { + _lastTapUpTime = now; + _lastTapCell = cell; + } + } + } + _isPointerDown = false; + _isDragSelecting = false; + _stopAutoScroll(); + } + + void _onGridPointerCancel(PointerCancelEvent event) { + _isPointerDown = false; + _isDragSelecting = false; + _stopAutoScroll(); + } + + void _startAutoScroll(double rowHeight) { + _autoScrollTimer?.cancel(); + _autoScrollTimer = Timer.periodic(const Duration(milliseconds: 20), (_) { + if (!_isDragSelecting || _lastPointerLocalPos == null || !mounted) { + _stopAutoScroll(); + return; + } + _performAutoScrollAndSelection(rowHeight); + }); + } + + void _stopAutoScroll() { + _autoScrollTimer?.cancel(); + _autoScrollTimer = null; + } + + void _performAutoScrollAndSelection(double rowHeight) { + if (!mounted) { + _stopAutoScroll(); + return; + } + final pos = _lastPointerLocalPos; + if (pos == null) return; + + const edgeMargin = 32.0; + const maxVelocity = 15.0; + + double dyScroll = 0.0; + if (pos.dy < edgeMargin) { + final factor = ((edgeMargin - pos.dy) / edgeMargin).clamp(0.0, 3.0); + dyScroll = -maxVelocity * factor; + } else if (pos.dy > _currentRowsViewportHeight - edgeMargin) { + final factor = + ((pos.dy - (_currentRowsViewportHeight - edgeMargin)) / edgeMargin) + .clamp(0.0, 3.0); + dyScroll = maxVelocity * factor; + } + + double dxScroll = 0.0; + final hOffset = + _horizontalController.hasClients ? _horizontalController.offset : 0.0; + final screenX = pos.dx - hOffset; + if (screenX < edgeMargin) { + final factor = ((edgeMargin - screenX) / edgeMargin).clamp(0.0, 3.0); + dxScroll = -maxVelocity * factor; + } else if (screenX > _currentAvailableWidth - edgeMargin) { + final factor = + ((screenX - (_currentAvailableWidth - edgeMargin)) / edgeMargin) + .clamp(0.0, 3.0); + dxScroll = maxVelocity * factor; + } + + if (dyScroll != 0.0 && _verticalController.hasClients) { + final targetY = (_verticalController.offset + dyScroll).clamp( + 0.0, + _verticalController.position.maxScrollExtent, + ); + if (targetY != _verticalController.offset) { + _verticalController.jumpTo(targetY); + } + } + + if (dxScroll != 0.0 && _horizontalController.hasClients) { + final targetX = (_horizontalController.offset + dxScroll).clamp( + 0.0, + _horizontalController.position.maxScrollExtent, + ); + if (targetX != _horizontalController.offset) { + _horizontalController.jumpTo(targetX); + } + } + + final currentCell = _cellAtOffset( + localPosition: pos, + rowHeight: rowHeight, + ); + if (currentCell != null && currentCell != _selectionFocus) { + setState(() { + _selectionFocus = currentCell; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor ?? currentCell, + focus: currentCell, + ); + }); + final modelRow = _toModelRowIndex(currentCell.row); + widget.onRowSelected?.call(modelRow); + _notifySelectionAndFocus(); + } + } + + void _onCellTap(int row, int column, {bool isShift = false}) { + if (_isDragSelecting || _hasJustDragSelected) { + _hasJustDragSelected = false; + return; + } + _focusNode.requestFocus(); + final modelRow = _toModelRowIndex(row); + widget.onRowSelected?.call(modelRow); + setState(() { + final coord = ResultGridCellCoordinate(row, column); + if (isShift && _selectionAnchor != null) { + _selectionFocus = coord; + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor!, + focus: coord, + ); + } else { + _selectionAnchor = coord; + _selectionFocus = coord; + _selection = ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + } + }); + _notifySelectionAndFocus(); + } + + void _onCellSecondaryTap(int row, int column) { + _focusNode.requestFocus(); + widget.onRowSelected?.call(row); + if (_selection != null && _selection!.contains(row, column)) { + // Keep existing selection intact so context menu operates on whole selection if clicked inside + } else { + setState(() { + final coord = ResultGridCellCoordinate(row, column); + _selectionAnchor = coord; + _selectionFocus = coord; + _selection = ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + }); + _notifySelectionAndFocus(); + } + } + +} diff --git a/lib/features/workspace/grid/grid_row_renderer.dart b/lib/features/workspace/grid/grid_row_renderer.dart new file mode 100644 index 00000000..4d5b58b2 --- /dev/null +++ b/lib/features/workspace/grid/grid_row_renderer.dart @@ -0,0 +1,243 @@ +part of '../result_grid_view.dart'; + +class _DataRow extends material.StatefulWidget { + const _DataRow({ + super.key, + required this.rowIndex, + required this.modelRowIndex, + required this.row, + required this.columns, + required this.columnWidths, + required this.window, + required this.height, + required this.colorScheme, + required this.striped, + this.selection, + this.stagingBuffer, + this.stagedSignature = 0, + this.editingCell, + this.canFilter = false, + this.onCellSecondaryTap, + this.onCommitEdit, + this.onCancelEdit, + this.onOpenInspector, + this.onCopyCell, + this.onFilterByValue, + this.onFilterComparison, + this.onSetNull, + this.onSetEmpty, + this.onRevertCell, + this.onDuplicateRow, + this.onToggleDeleteRow, + this.onRevertRow, + this.columnDataTypes, + }); + + final int rowIndex; + final int modelRowIndex; + final List row; + final List columns; + final List columnWidths; + final ResultGridColumnWindow window; + final double height; + final ColorScheme colorScheme; + final bool striped; + final ResultGridSelection? selection; + final DataGridStagingBuffer? stagingBuffer; + + /// [DataGridStagingBuffer.rowRenderSignature] of this row, so equality can + /// see staged-state changes the (shared, mutable) buffer reference hides. + final int stagedSignature; + final ResultGridCellCoordinate? editingCell; + final bool canFilter; + final void Function(int row, int col)? onCellSecondaryTap; + final void Function( + int row, + int col, + String val, { + bool moveNextCol, + bool movePrevCol, + bool moveNextRow, + bool movePrevRow, + })? onCommitEdit; + final material.VoidCallback? onCancelEdit; + final void Function(int row, int col)? onOpenInspector; + final void Function( + int row, + int col, { + bool withHeaders, + bool asJson, + bool asCsv, + })? onCopyCell; + final void Function(int row, int col, {required bool invert})? + onFilterByValue; + final void Function(int row, int col, String operator)? onFilterComparison; + final void Function(int row, int col)? onSetNull; + final void Function(int row, int col)? onSetEmpty; + final void Function(int row, int col)? onRevertCell; + final void Function(int row)? onDuplicateRow; + final void Function(int row)? onToggleDeleteRow; + final void Function(int row)? onRevertRow; + final Map? columnDataTypes; + + /// True when [other] would draw exactly the same row. The grid hands the + /// framework the *previous* widget instance in that case, so + /// [Element.updateChild] skips it and an edit, a selection change or a scroll + /// only rebuilds the rows whose inputs actually changed. + bool sameAs(_DataRow other) => + identical(this, other) || + rowIndex == other.rowIndex && + modelRowIndex == other.modelRowIndex && + identical(row, other.row) && + identical(columns, other.columns) && + (identical(columnWidths, other.columnWidths) || + foundation.listEquals(columnWidths, other.columnWidths)) && + window == other.window && + height == other.height && + colorScheme == other.colorScheme && + striped == other.striped && + selection == other.selection && + identical(stagingBuffer, other.stagingBuffer) && + stagedSignature == other.stagedSignature && + editingCell == other.editingCell && + canFilter == other.canFilter && + onCellSecondaryTap == other.onCellSecondaryTap && + onCommitEdit == other.onCommitEdit && + onCancelEdit == other.onCancelEdit && + onOpenInspector == other.onOpenInspector && + onCopyCell == other.onCopyCell && + onFilterByValue == other.onFilterByValue && + onFilterComparison == other.onFilterComparison && + onSetNull == other.onSetNull && + onSetEmpty == other.onSetEmpty && + onRevertCell == other.onRevertCell && + onDuplicateRow == other.onDuplicateRow && + onToggleDeleteRow == other.onToggleDeleteRow && + onRevertRow == other.onRevertRow && + identical(columnDataTypes, other.columnDataTypes); + + @override + material.State<_DataRow> createState() => _DataRowState(); + + /// One label for the whole row (visible columns only), read by screen + /// readers instead of a separate semantics node per cell — see #981. + String _semanticsLabel(StagedRowStatus rowStatus) { + final buffer = StringBuffer('Row ${modelRowIndex + 1}'); + switch (rowStatus) { + case StagedRowStatus.deleted: + buffer.write(' (deleted)'); + case StagedRowStatus.inserted: + buffer.write(' (new)'); + case StagedRowStatus.modified: + buffer.write(' (modified)'); + case StagedRowStatus.unchanged: + break; + } + for (var c = window.first; c <= window.last && c < columns.length; c++) { + buffer.write(', ${columns[c]}: ${c < row.length ? row[c] : ''}'); + } + return buffer.toString(); + } + + material.Widget _buildRow(Map cache) { + final rowStatus = + stagingBuffer?.getRowStatus(modelRowIndex) ?? StagedRowStatus.unchanged; + + return material.Semantics( + container: true, + label: _semanticsLabel(rowStatus), + child: material.RepaintBoundary( + child: material.SizedBox( + height: height, + child: material.Row( + children: [ + if (window.leadingWidth > 0) + material.SizedBox( + key: const material.ValueKey('lead'), + width: window.leadingWidth, + ), + for (var c = window.first; c <= window.last; c++) + _reuseCell( + cache, + c, + _GridCell( + key: material.ValueKey(c), + row: rowIndex, + column: c, + columnName: c < columns.length ? columns[c] : '', + text: c < row.length ? row[c] : '', + width: columnWidths[c], + colorScheme: colorScheme, + striped: striped, + rowStatus: rowStatus, + cellStatus: + stagingBuffer?.getCellStatus(modelRowIndex, c) ?? + StagedCellStatus.clean, + isSelected: selection?.contains(rowIndex, c) ?? false, + isEditing: editingCell?.row == rowIndex && + editingCell?.column == c, + isSelectionTop: selection != null && + selection!.contains(rowIndex, c) && + rowIndex == selection!.startRow, + isSelectionBottom: selection != null && + selection!.contains(rowIndex, c) && + rowIndex == selection!.endRow, + isSelectionLeft: selection != null && + selection!.contains(rowIndex, c) && + c == selection!.startColumn, + isSelectionRight: selection != null && + selection!.contains(rowIndex, c) && + c == selection!.endColumn, + canFilter: canFilter, + hasStagingBuffer: stagingBuffer != null, + dataTypeName: + columnDataTypes?[c < columns.length ? columns[c] : ''], + onSecondaryTap: onCellSecondaryTap, + onCommitEdit: onCommitEdit, + onCancelEdit: onCancelEdit, + onOpenInspector: onOpenInspector, + onCopyCell: onCopyCell, + onFilterByValue: onFilterByValue, + onFilterComparison: onFilterComparison, + onSetNull: onSetNull, + onSetEmpty: onSetEmpty, + onRevertCell: onRevertCell, + onDuplicateRow: onDuplicateRow, + onToggleDeleteRow: onToggleDeleteRow, + onRevertRow: onRevertRow, + ), + ), + if (window.trailingWidth > 0) + material.SizedBox( + key: const material.ValueKey('trail'), + width: window.trailingWidth, + ), + ], + ), + ), + ), + ); + } +} + +/// Returns the cell built last time for [column] when it would look the same, +/// so the framework skips it; otherwise remembers and returns [candidate]. +_GridCell _reuseCell( + Map cache, int column, _GridCell candidate) { + final previous = cache[column]; + if (previous != null && previous.sameAs(candidate)) return previous; + cache[column] = candidate; + return candidate; +} + +class _DataRowState extends material.State<_DataRow> { + final Map _cells = {}; + + @override + material.Widget build(material.BuildContext context) { + final w = widget.window; + // Cells that scrolled out of the window are dropped, not kept alive. + _cells.removeWhere((c, _) => c < w.first || c > w.last); + return widget._buildRow(_cells); + } +} diff --git a/lib/features/workspace/grid/grid_selection_manager.dart b/lib/features/workspace/grid/grid_selection_manager.dart new file mode 100644 index 00000000..b808b22b --- /dev/null +++ b/lib/features/workspace/grid/grid_selection_manager.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; + +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Coordinate of a cell in [VirtualResultGrid]. +@immutable +class ResultGridCellCoordinate { + const ResultGridCellCoordinate(this.row, this.column); + + final int row; + final int column; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResultGridCellCoordinate && + row == other.row && + column == other.column; + + @override + int get hashCode => Object.hash(row, column); +} + +/// Rectangular cell selection range in [VirtualResultGrid]. +@immutable +class ResultGridSelection { + const ResultGridSelection({ + required this.startRow, + required this.startColumn, + required this.endRow, + required this.endColumn, + }); + + factory ResultGridSelection.fromPoints({ + required ResultGridCellCoordinate anchor, + required ResultGridCellCoordinate focus, + }) { + final minR = anchor.row < focus.row ? anchor.row : focus.row; + final maxR = anchor.row > focus.row ? anchor.row : focus.row; + final minC = anchor.column < focus.column ? anchor.column : focus.column; + final maxC = anchor.column > focus.column ? anchor.column : focus.column; + return ResultGridSelection( + startRow: minR, + startColumn: minC, + endRow: maxR, + endColumn: maxC, + ); + } + + final int startRow; + final int startColumn; + final int endRow; + final int endColumn; + + bool contains(int row, int column) => + row >= startRow && + row <= endRow && + column >= startColumn && + column <= endColumn; + + int get rowCount => endRow - startRow + 1; + int get columnCount => endColumn - startColumn + 1; + + /// Formats selected cell values as a Tab-Separated Values (TSV) string. + /// If [columns] is provided, prepends a header row for the selected column range. + String toTsv(List> rows, {List? columns}) { + if (rows.isEmpty) return ''; + final buffer = StringBuffer(); + if (columns != null && columns.isNotEmpty) { + final headerCells = []; + for (var c = startColumn; c <= endColumn; c++) { + final col = c < columns.length ? columns[c] : ''; + headerCells.add(_escapeTsv(col)); + } + buffer.writeln(headerCells.join('\t')); + } + for (var r = startRow; r <= endRow; r++) { + if (r < 0 || r >= rows.length) continue; + final rowData = rows[r]; + final cells = []; + for (var c = startColumn; c <= endColumn; c++) { + final val = c < rowData.length ? rowData[c] : ''; + cells.add(_escapeTsv(val)); + } + buffer.writeln(cells.join('\t')); + } + return buffer.toString().trimRight(); + } + + /// Formats selected cell values as a CSV string. + /// If [columns] is provided, prepends a header row for the selected column range. + String toCsv(List> rows, {List? columns}) { + if (rows.isEmpty) return ''; + final buffer = StringBuffer(); + if (columns != null && columns.isNotEmpty) { + final headerCells = []; + for (var c = startColumn; c <= endColumn; c++) { + final col = c < columns.length ? columns[c] : ''; + headerCells.add(_escapeCsv(col)); + } + buffer.writeln(headerCells.join(',')); + } + for (var r = startRow; r <= endRow; r++) { + if (r < 0 || r >= rows.length) continue; + final rowData = rows[r]; + final cells = []; + for (var c = startColumn; c <= endColumn; c++) { + final val = c < rowData.length ? rowData[c] : ''; + cells.add(_escapeCsv(val)); + } + buffer.writeln(cells.join(',')); + } + return buffer.toString().trimRight(); + } + + /// Formats selected cell values as a formatted JSON string. + /// Returns a JSON object for single-row selection, or a JSON array for multi-row selection. + String toJson(List columns, List> rows) { + if (rows.isEmpty || columns.isEmpty) return '[]'; + final result = >[]; + for (var r = startRow; r <= endRow; r++) { + if (r < 0 || r >= rows.length) continue; + final rowData = rows[r]; + final map = {}; + for (var c = startColumn; c <= endColumn; c++) { + final colName = c < columns.length ? columns[c] : 'col_$c'; + final val = c < rowData.length ? rowData[c] : ''; + if (val == 'NULL') { + map[colName] = null; + } else if (RegExp(r'^0\d+$').hasMatch(val.trim())) { + // Preserve numeric strings with leading zeros (e.g. '01234', '007') + map[colName] = val; + } else if (int.tryParse(val) != null) { + map[colName] = int.parse(val); + } else if (double.tryParse(val) != null) { + map[colName] = double.parse(val); + } else if (val.toLowerCase() == 'true') { + map[colName] = true; + } else if (val.toLowerCase() == 'false') { + map[colName] = false; + } else { + map[colName] = val; + } + } + result.add(map); + } + if (result.length == 1) { + return const JsonEncoder.withIndent(' ').convert(result.first); + } + return const JsonEncoder.withIndent(' ').convert(result); + } + + static String _escapeTsv(String val) { + if (val.contains('\t') || + val.contains('\n') || + val.contains('\r') || + val.contains('"')) { + return '"${val.replaceAll('"', '""')}"'; + } + return val; + } + + static String _escapeCsv(String val) { + if (val.contains(',') || + val.contains('"') || + val.contains('\n') || + val.contains('\r')) { + return '"${val.replaceAll('"', '""')}"'; + } + return val; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResultGridSelection && + startRow == other.startRow && + startColumn == other.startColumn && + endRow == other.endRow && + endColumn == other.endColumn; + + @override + int get hashCode => Object.hash(startRow, startColumn, endRow, endColumn); +} diff --git a/lib/features/workspace/grid/grid_sorting.dart b/lib/features/workspace/grid/grid_sorting.dart new file mode 100644 index 00000000..e2ea3ea8 --- /dev/null +++ b/lib/features/workspace/grid/grid_sorting.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' as foundation; + +/// Sorting direction for [VirtualResultGrid]. +enum ResultGridSortOrder { + ascending, + descending, +} + +enum _SortKeyType { nullOrEmpty, numeric, dateTime, string } + +class _SortKey implements Comparable<_SortKey> { + final _SortKeyType type; + final num? numVal; + final DateTime? dtVal; + final String strLower; + final String strRaw; + + _SortKey._({ + required this.type, + this.numVal, + this.dtVal, + this.strLower = '', + this.strRaw = '', + }); + + factory _SortKey.parse(String val) { + if (val == 'NULL' || val.isEmpty) { + return _SortKey._(type: _SortKeyType.nullOrEmpty); + } + final n = num.tryParse(val); + if (n != null) { + return _SortKey._(type: _SortKeyType.numeric, numVal: n, strRaw: val); + } + final dt = DateTime.tryParse(val); + if (dt != null) { + return _SortKey._(type: _SortKeyType.dateTime, dtVal: dt, strRaw: val); + } + return _SortKey._( + type: _SortKeyType.string, + strLower: val.toLowerCase(), + strRaw: val, + ); + } + + @override + int compareTo(_SortKey other) { + if (type == _SortKeyType.nullOrEmpty && + other.type == _SortKeyType.nullOrEmpty) { + return 0; + } + if (type == _SortKeyType.nullOrEmpty) return 1; + if (other.type == _SortKeyType.nullOrEmpty) return -1; + + if (type == _SortKeyType.numeric && other.type == _SortKeyType.numeric) { + return numVal!.compareTo(other.numVal!); + } + if (type == _SortKeyType.dateTime && other.type == _SortKeyType.dateTime) { + return dtVal!.compareTo(other.dtVal!); + } + + final aLower = + type == _SortKeyType.string ? strLower : strRaw.toLowerCase(); + final bLower = other.type == _SortKeyType.string + ? other.strLower + : other.strRaw.toLowerCase(); + final cmp = aLower.compareTo(bLower); + if (cmp != 0) return cmp; + + return strRaw.compareTo(other.strRaw); + } +} + +/// Result of sorting rows in [VirtualResultGrid], preserving model indices. +class SortedResultGridData { + final List> rows; + final List sortedToModelIndices; + + const SortedResultGridData({ + required this.rows, + required this.sortedToModelIndices, + }); +} + +/// Sorts rows by the specified column index with natural numeric / temporal / lexicographic comparison. +/// Uses Schwartzian transform (Decorate-Sort-Undecorate) to precompute sort keys in O(N) time. +/// Returns [SortedResultGridData] containing sorted rows and their corresponding model indices. +SortedResultGridData sortResultGridRowsWithIndices({ + required List> rows, + required int columnIndex, + required ResultGridSortOrder order, +}) { + if (rows.isEmpty || columnIndex < 0) { + return SortedResultGridData( + rows: rows, + sortedToModelIndices: + List.generate(rows.length, (i) => i, growable: false), + ); + } + final n = rows.length; + + final keys = List<_SortKey>.generate(n, (i) { + final row = rows[i]; + final val = columnIndex < row.length ? row[columnIndex] : ''; + return _SortKey.parse(val); + }, growable: false); + + final indices = List.generate(n, (i) => i, growable: false); + + indices.sort((a, b) { + final cmp = keys[a].compareTo(keys[b]); + return order == ResultGridSortOrder.ascending ? cmp : -cmp; + }); + + final sortedRows = + List>.generate(n, (i) => rows[indices[i]], growable: false); + return SortedResultGridData( + rows: sortedRows, + sortedToModelIndices: indices, + ); +} + +/// Sorts rows by the specified column index with natural numeric / temporal / lexicographic comparison. +List> sortResultGridRows({ + required List> rows, + required int columnIndex, + required ResultGridSortOrder order, +}) { + return sortResultGridRowsWithIndices( + rows: rows, + columnIndex: columnIndex, + order: order, + ).rows; +} + +/// Default threshold for offloading data grid sorting to a background isolate. +const int kSortIsolateThreshold = 3000; + +class SortIsolateParams { + final List> rows; + final int columnIndex; + final ResultGridSortOrder order; + + const SortIsolateParams({ + required this.rows, + required this.columnIndex, + required this.order, + }); +} + +SortedResultGridData sortResultGridRowsWithIndicesIsolate( + SortIsolateParams params) { + return sortResultGridRowsWithIndices( + rows: params.rows, + columnIndex: params.columnIndex, + order: params.order, + ); +} + +/// Adaptive sort function that executes synchronously for small lists (< [kSortIsolateThreshold]), +/// and offloads to a background isolate for large datasets to keep UI fluid. +Future sortResultGridRowsWithIndicesAdaptive({ + required List> rows, + required int columnIndex, + required ResultGridSortOrder order, + int threshold = kSortIsolateThreshold, +}) async { + if (rows.length < threshold) { + return sortResultGridRowsWithIndices( + rows: rows, + columnIndex: columnIndex, + order: order, + ); + } + return foundation.compute( + sortResultGridRowsWithIndicesIsolate, + SortIsolateParams(rows: rows, columnIndex: columnIndex, order: order), + ); +} diff --git a/lib/features/workspace/grid/grid_view_builder.dart b/lib/features/workspace/grid/grid_view_builder.dart new file mode 100644 index 00000000..08662637 --- /dev/null +++ b/lib/features/workspace/grid/grid_view_builder.dart @@ -0,0 +1,459 @@ +// ignore_for_file: invalid_use_of_protected_member + +part of '../result_grid_view.dart'; + +/// Widget tree for [VirtualResultGrid]. +extension _GridViewBuilder on _VirtualResultGridState { + material.Widget _buildGrid(material.BuildContext context) { + if (_widthsNeedUpdate && !_userHasResized) { + _columnWidths = _computeColumnWidths(); + _columnOffsets = computeResultGridColumnOffsets(_columnWidths); + _widthsNeedUpdate = false; + } + final cs = Theme.of(context).colorScheme; + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator( + LogicalKeyboardKey.keyC, + meta: true, + ): () => _copySelection(), + const material.SingleActivator( + LogicalKeyboardKey.keyC, + control: true, + ): () => _copySelection(), + const material.SingleActivator( + LogicalKeyboardKey.keyC, + meta: true, + shift: true, + ): () => _copySelection(withHeaders: true), + const material.SingleActivator( + LogicalKeyboardKey.keyC, + control: true, + shift: true, + ): () => _copySelection(withHeaders: true), + const material.SingleActivator( + LogicalKeyboardKey.keyD, + control: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + _handleDuplicateRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyD, + meta: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + _handleDuplicateRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.insert, + control: true, + ): () => widget.stagingBuffer?.addRow(), + const material.SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + ): () { + if (widget.stagingBuffer != null) { + widget.stagingBuffer!.addRow(); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.delete, + control: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer! + .toggleDeleteRow(_toModelRowIndex(_selection!.startRow)); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.backspace, + meta: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer! + .toggleDeleteRow(_toModelRowIndex(_selection!.startRow)); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyZ, + control: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer! + .revertRow(_toModelRowIndex(_selection!.startRow)); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyZ, + meta: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer! + .revertRow(_toModelRowIndex(_selection!.startRow)); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.escape, + ): () { + if (_editingCell != null) { + _cancelEdit(); + } else { + widget.onRowSelected?.call(null); + setState(() { + _selection = null; + _selectionAnchor = null; + _selectionFocus = null; + }); + _notifySelectionAndFocus(); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.f2, + ): () { + if (_selection != null && _editingCell == null) { + _startEditing(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + ): () { + if (_selection != null && _editingCell == null) { + _startEditing(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + ): () { + if (_selection != null && _editingCell == null) { + _startEditing(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.space, + ): () { + if (_selection != null && _editingCell == null) { + _openInspector(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyI, + control: true, + ): () { + if (_selection != null && _editingCell == null) { + _openInspector(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyI, + meta: true, + ): () { + if (_selection != null && _editingCell == null) { + _openInspector(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyN, + alt: true, + ): () { + if (_selection != null && widget.stagingBuffer != null) { + _handleSetNull(_selection!.startRow, _selection!.startColumn); + } + }, + + // Navigation: Arrows + const material.SingleActivator(LogicalKeyboardKey.arrowDown): () => + _navigateCell(1, 0), + const material.SingleActivator(LogicalKeyboardKey.arrowUp): () => + _navigateCell(-1, 0), + const material.SingleActivator(LogicalKeyboardKey.arrowRight): () => + _navigateCell(0, 1), + const material.SingleActivator(LogicalKeyboardKey.arrowLeft): () => + _navigateCell(0, -1), + + // Navigation: Shift + Arrows (range selection) + const material.SingleActivator( + LogicalKeyboardKey.arrowDown, + shift: true, + ): () => _navigateCell(1, 0, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.arrowUp, + shift: true, + ): () => _navigateCell(-1, 0, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.arrowRight, + shift: true, + ): () => _navigateCell(0, 1, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.arrowLeft, + shift: true, + ): () => _navigateCell(0, -1, extendSelection: true), + + // Navigation: Home / End (column jump) + const material.SingleActivator(LogicalKeyboardKey.home): () => + _jumpToCell(column: 0), + const material.SingleActivator( + LogicalKeyboardKey.home, + shift: true, + ): () => _jumpToCell(column: 0, extendSelection: true), + const material.SingleActivator(LogicalKeyboardKey.end): () => + _jumpToCell(column: widget.columns.length - 1), + const material.SingleActivator( + LogicalKeyboardKey.end, + shift: true, + ): () => _jumpToCell( + column: widget.columns.length - 1, + extendSelection: true, + ), + + // Navigation: Ctrl+Home / Ctrl+End (table start / end) + const material.SingleActivator( + LogicalKeyboardKey.home, + control: true, + ): () => _jumpToCell(row: 0, column: 0), + const material.SingleActivator( + LogicalKeyboardKey.home, + meta: true, + ): () => _jumpToCell(row: 0, column: 0), + const material.SingleActivator( + LogicalKeyboardKey.home, + control: true, + shift: true, + ): () => _jumpToCell(row: 0, column: 0, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.home, + meta: true, + shift: true, + ): () => _jumpToCell(row: 0, column: 0, extendSelection: true), + const material.SingleActivator( + LogicalKeyboardKey.end, + control: true, + ): () => _jumpToCell( + row: _sortedRows.length - 1, + column: widget.columns.length - 1, + ), + const material.SingleActivator( + LogicalKeyboardKey.end, + meta: true, + ): () => _jumpToCell( + row: _sortedRows.length - 1, + column: widget.columns.length - 1, + ), + const material.SingleActivator( + LogicalKeyboardKey.end, + control: true, + shift: true, + ): () => _jumpToCell( + row: _sortedRows.length - 1, + column: widget.columns.length - 1, + extendSelection: true, + ), + const material.SingleActivator( + LogicalKeyboardKey.end, + meta: true, + shift: true, + ): () => _jumpToCell( + row: _sortedRows.length - 1, + column: widget.columns.length - 1, + extendSelection: true, + ), + + // Navigation: PageUp / PageDown + const material.SingleActivator(LogicalKeyboardKey.pageDown): () => + _navigateCell(20, 0), + const material.SingleActivator( + LogicalKeyboardKey.pageDown, + shift: true, + ): () => _navigateCell(20, 0, extendSelection: true), + const material.SingleActivator(LogicalKeyboardKey.pageUp): () => + _navigateCell(-20, 0), + const material.SingleActivator( + LogicalKeyboardKey.pageUp, + shift: true, + ): () => _navigateCell(-20, 0, extendSelection: true), + + // Select All: Ctrl+A / Meta+A + const material.SingleActivator( + LogicalKeyboardKey.keyA, + control: true, + ): () => _selectAll(), + const material.SingleActivator( + LogicalKeyboardKey.keyA, + meta: true, + ): () => _selectAll(), + }, + child: material.Focus( + focusNode: _focusNode, + child: material.RepaintBoundary( + child: material.LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final headerHeight = _scaledHeaderHeight(context); + final rowHeight = _scaledRowHeight(context); + final rowsViewportHeight = + math.max(0.0, constraints.maxHeight - headerHeight); + _currentAvailableWidth = availableWidth; + _currentRowsViewportHeight = rowsViewportHeight; + + var displayWidths = _columnWidths; + var tableWidth = _tableWidth; + if (!_userHasResized && + tableWidth < availableWidth && + _columnWidths.isNotEmpty) { + if (identical(_distributedForColumnWidths, _columnWidths) && + _distributedForAvailableWidth == availableWidth) { + displayWidths = _distributedWidths; + } else { + displayWidths = distributeResultGridSpareWidth( + columnWidths: _columnWidths, + columns: widget.columns, + rows: _baseRows, + availableWidth: availableWidth, + maxColumnWidth: + context.scaled(ResultGridMetrics.maxColumnWidth), + ); + _distributedWidths = displayWidths; + _distributedForColumnWidths = _columnWidths; + _distributedForAvailableWidth = availableWidth; + columnWidthDistributionCount++; + } + final distributedWidth = + displayWidths.fold(0.0, (sum, w) => sum + w); + tableWidth = math.max(distributedWidth, availableWidth); + } else if (tableWidth < availableWidth) { + tableWidth = availableWidth; + } + + _currentDisplayOffsets = identical(displayWidths, _columnWidths) + ? _columnOffsets + : computeResultGridColumnOffsets(displayWidths); + + final window = _columnWindow(displayWidths, availableWidth); + _currentDisplayWidths = displayWidths; + _currentWindow = window; + + return material.Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + notificationPredicate: (_) => true, + child: material.SingleChildScrollView( + controller: _horizontalController, + scrollDirection: material.Axis.horizontal, + child: material.SizedBox( + width: tableWidth, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _HeaderRow( + columns: widget.columns, + columnWidths: displayWidths, + window: window, + height: headerHeight, + colorScheme: cs, + sortColumnIndex: _sortColumnIndex, + sortOrder: _sortOrder, + onSortColumn: _toggleSort, + onResizeColumn: _onColumnResize, + onAutoFitColumn: _onColumnAutoFit, + ), + material.Expanded( + child: material.Scrollbar( + controller: _verticalController, + thumbVisibility: true, + // One grid-level cursor region instead of one + // MouseRegion per visible cell (#983). Matches + // the previous per-cell behavior exactly: cursor + // was already uniform across every cell based + // solely on whether a staging buffer exists, not + // on which specific cell is hovered. + child: material.MouseRegion( + cursor: widget.stagingBuffer != null + ? material.SystemMouseCursors.text + : material.SystemMouseCursors.basic, + child: material.Listener( + behavior: material.HitTestBehavior.translucent, + onPointerDown: (e) => + _onGridPointerDown(e, rowHeight: rowHeight), + onPointerMove: (e) => + _onGridPointerMove(e, rowHeight: rowHeight), + onPointerUp: (e) => + _onGridPointerUp(e, rowHeight: rowHeight), + onPointerCancel: _onGridPointerCancel, + child: material.ListView.builder( + controller: _verticalController, + itemCount: _sortedRows.length, + itemExtent: rowHeight, + itemBuilder: (context, rowIndex) { + final row = _sortedRows[rowIndex]; + final isEven = rowIndex.isEven; + final candidate = _DataRow( + key: ValueKey('result-row-$rowIndex'), + rowIndex: rowIndex, + modelRowIndex: _toModelRowIndex(rowIndex), + row: row, + columns: widget.columns, + columnWidths: displayWidths, + window: window, + height: rowHeight, + colorScheme: cs, + striped: !isEven, + selection: _selectionForRow(rowIndex), + stagingBuffer: widget.stagingBuffer, + stagedSignature: widget.stagingBuffer + ?.rowRenderSignature( + _toModelRowIndex(rowIndex)) ?? + 0, + editingCell: _editingCell?.row == rowIndex + ? _editingCell + : null, + canFilter: + widget.onFilterRequested != null, + onCellSecondaryTap: _onCellSecondaryTap, + onCommitEdit: _commitEdit, + onCancelEdit: _cancelEdit, + onOpenInspector: _openInspector, + onCopyCell: _handleCopyCell, + onFilterByValue: _handleFilterByValue, + onFilterComparison: + _handleFilterComparison, + onSetNull: _handleSetNull, + onSetEmpty: _handleSetEmpty, + onRevertCell: _handleRevertCell, + onDuplicateRow: _handleDuplicateRow, + onToggleDeleteRow: _handleToggleDeleteRow, + onRevertRow: _handleRevertRow, + columnDataTypes: widget.columnDataTypes, + ); + final previous = _rowWidgets[rowIndex]; + if (previous != null && + previous.sameAs(candidate)) { + return previous; + } + _rowWidgets[rowIndex] = candidate; + if (_rowWidgets.length > 200) { + _rowWidgets.removeWhere( + (idx, _) => + (idx - rowIndex).abs() > 100, + ); + } + return candidate; + }, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ), + ); +} diff --git a/lib/features/workspace/grid/grid_view_layout.dart b/lib/features/workspace/grid/grid_view_layout.dart new file mode 100644 index 00000000..22f17cfa --- /dev/null +++ b/lib/features/workspace/grid/grid_view_layout.dart @@ -0,0 +1,205 @@ +// ignore_for_file: invalid_use_of_protected_member + +part of '../result_grid_view.dart'; + +/// Scroll, column sizing and sorting for [VirtualResultGrid]. +extension _GridViewLayout on _VirtualResultGridState { + /// The scroll view already translates header and body together; the offset + /// only decides which columns are built (virtualized window). So rebuild only + /// when the viewport runs past the built window, not on every pan frame. + void _onHorizontalScroll() { + if (!_horizontalController.hasClients) return; + final offset = _horizontalController.offset; + if ((offset - _scrollOffset).abs() < 0.5) return; + _scrollOffset = offset; + + final current = _currentWindow; + if (current != null && _currentDisplayWidths.isNotEmpty) { + final visible = computeVisibleColumnWindow( + columnWidths: _currentDisplayWidths, + columnOffsets: _currentDisplayOffsets, + scrollOffset: offset, + viewportWidth: _currentAvailableWidth, + overscanColumns: 0, + ); + // Every visible column is already built (the rest is overscan): keep the + // window until the viewport reaches its edge, then recentre it. + if (!visible.isEmpty && + current.first <= visible.first && + visible.last <= current.last) { + return; + } + } + setState(() {}); + } + + void _onColumnResize(int index, double delta) { + if (index < 0 || index >= _columnWidths.length) return; + setState(() { + _userHasResized = true; + final minWidth = context.scaled(ResultGridMetrics.minColumnWidth); + final maxWidth = context.scaled(ResultGridMetrics.maxColumnWidth * 3); + final newWidth = (_columnWidths[index] + delta).clamp(minWidth, maxWidth); + _columnWidths = List.from(_columnWidths); + _columnWidths[index] = newWidth; + _columnOffsets = computeResultGridColumnOffsets(_columnWidths); + _widthsNeedUpdate = false; + }); + } + + void _onColumnAutoFit(int index) { + if (index < 0 || + index >= widget.columns.length || + index >= _columnWidths.length) { + return; + } + final rows = _baseRows; + final headerWidth = widget.columns[index].length * 7.5 + 38.0; + var maxRowChars = 0; + final sampleCount = math.min(rows.length, 200); + for (var r = 0; r < sampleCount; r++) { + if (index < rows[r].length && rows[r][index].length > maxRowChars) { + maxRowChars = rows[r][index].length; + } + } + final contentWidth = maxRowChars * 7.5 + 24.0; + final naturalWidth = math.max(headerWidth, contentWidth); + final minWidth = context.scaled(ResultGridMetrics.minColumnWidth); + final maxWidth = context.scaled(ResultGridMetrics.maxColumnWidth * 3); + final autoFitWidth = naturalWidth.clamp(minWidth, maxWidth); + + setState(() { + _userHasResized = true; + _columnWidths = List.from(_columnWidths); + _columnWidths[index] = autoFitWidth; + _columnOffsets = computeResultGridColumnOffsets(_columnWidths); + _widthsNeedUpdate = false; + }); + } + + void _toggleSort(int columnIndex) { + if (columnIndex < 0 || columnIndex >= widget.columns.length) return; + setState(() { + if (_sortColumnIndex == columnIndex) { + if (_sortOrder == ResultGridSortOrder.ascending) { + _sortOrder = ResultGridSortOrder.descending; + } else { + _sortColumnIndex = null; + _sortOrder = null; + } + } else { + _sortColumnIndex = columnIndex; + _sortOrder = ResultGridSortOrder.ascending; + } + _updateSortedRows(); + }); + } + + List> get _baseRows { + if (widget.rowIndicesMapping != null) { + return widget.rows; + } + return widget.stagingBuffer?.effectiveRows ?? widget.rows; + } + + void _updateSortedRows({bool asyncIfLarge = true}) { + _rowWidgets.clear(); + final rows = _baseRows; + if (_sortColumnIndex == null || _sortOrder == null) { + _sortedRows = rows; + if (widget.rowIndicesMapping != null) { + _sortedToModelIndices = List.from(widget.rowIndicesMapping!); + } else { + _sortedToModelIndices = + List.generate(rows.length, (i) => i, growable: false); + } + return; + } + + final sortCol = _sortColumnIndex!; + final sortOrd = _sortOrder!; + final version = ++_sortVersion; + + if (asyncIfLarge && rows.length >= kSortIsolateThreshold) { + foundation + .compute( + sortResultGridRowsWithIndicesIsolate, + SortIsolateParams(rows: rows, columnIndex: sortCol, order: sortOrd), + ) + .then((sortedData) { + if (!mounted || version != _sortVersion) return; + setState(() { + _applySortedData(sortedData, rows); + }); + }).catchError((_) { + if (!mounted || version != _sortVersion) return; + setState(() { + final fallback = sortResultGridRowsWithIndices( + rows: rows, + columnIndex: sortCol, + order: sortOrd, + ); + _applySortedData(fallback, rows); + }); + }); + } else { + final sortedData = sortResultGridRowsWithIndices( + rows: rows, + columnIndex: sortCol, + order: sortOrd, + ); + _applySortedData(sortedData, rows); + } + } + + void _applySortedData( + SortedResultGridData sortedData, List> rows) { + _rowWidgets.clear(); + _sortedRows = sortedData.rows; + if (widget.rowIndicesMapping != null) { + final mapping = widget.rowIndicesMapping!; + _sortedToModelIndices = sortedData.sortedToModelIndices + .map((i) => i < mapping.length ? mapping[i] : i) + .toList(growable: false); + } else { + _sortedToModelIndices = sortedData.sortedToModelIndices; + } + } + + + List _computeColumnWidths() { + return computeResultGridColumnWidths( + columns: widget.columns, + rows: _baseRows, + minWidth: context.scaled(ResultGridMetrics.minColumnWidth), + maxWidth: context.scaled(ResultGridMetrics.maxColumnWidth), + ); + } + + double get _tableWidth { + if (_columnWidths.isEmpty) return 0; + return _columnOffsets[_columnWidths.length]; + } + + double _scaledRowHeight(material.BuildContext context) => + context.scaled(ResultGridMetrics.rowHeight); + + 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, + ); + } + +} diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index b961a3af..7ecda1ff 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/foundation.dart' as foundation; @@ -15,648 +14,25 @@ import 'package:querya_desktop/core/ui/querya_tooltip.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/grid_cell_editor.dart'; import 'package:querya_desktop/features/workspace/grid_cell_popover_inspector.dart'; +import 'package:querya_desktop/features/workspace/grid/grid_column_sizer.dart'; +import 'package:querya_desktop/features/workspace/grid/grid_selection_manager.dart'; +import 'package:querya_desktop/features/workspace/grid/grid_sorting.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -/// Layout metrics for [VirtualResultGrid]. -abstract final class ResultGridMetrics { - static const double rowHeight = 36; - static const double headerHeight = 36; - static const double minColumnWidth = 56; - 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. - /// Columns built beyond each viewport edge. A wider margin means the window - /// is rebuilt only every few columns of horizontal scroll, not on each one. - static const int columnOverscan = 6; - - /// Hover tooltip: column SQL type when known, plus the cell value when long. - static String? cellTooltipMessage({ - required String text, - String? dataTypeName, - String columnName = '', - }) { - final type = dataTypeName?.trim() ?? ''; - final typePart = type.isEmpty - ? null - : (columnName.isNotEmpty ? '$columnName · $type' : type); - final showValue = text.length >= tooltipMinLength; - if (typePart == null && !showValue) return null; - if (typePart != null && showValue) return '$typePart\n$text'; - return typePart ?? text; - } -} - -/// 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]. -List computeResultGridColumnWidths({ - required List columns, - required List> rows, - double minWidth = ResultGridMetrics.minColumnWidth, - double maxWidth = ResultGridMetrics.maxColumnWidth, - int sampleRowCount = ResultGridMetrics.columnWidthSampleRows, -}) { - if (columns.isEmpty) return const []; - - final widths = List.filled(columns.length, minWidth); - final sample = rows.length < sampleRowCount ? rows.length : sampleRowCount; - - for (var c = 0; c < columns.length; c++) { - final headerWidth = columns[c].length * 7.5 + 38.0; - var maxRowChars = 0; - for (var r = 0; r < sample; r++) { - if (c < rows[r].length && rows[r][c].length > maxRowChars) { - maxRowChars = rows[r][c].length; - } - } - final contentWidth = maxRowChars * 7.5 + 24.0; - final naturalWidth = math.max(headerWidth, contentWidth); - widths[c] = naturalWidth.clamp(minWidth, maxWidth); - } - return widths; -} - -/// Counts calls to `_GridCell._buildContextMenuItems` — should only fire when -/// a cell's context menu is actually opened (#983), not on every cell build. -@material.visibleForTesting -int gridCellContextMenuItemsBuiltCount = 0; - -/// Distributes spare viewport width adaptively to columns that benefit from expansion, -/// avoiding artificial stretching of compact columns. -List distributeResultGridSpareWidth({ - required List columnWidths, - required List columns, - required List> rows, - required double availableWidth, - double maxColumnWidth = ResultGridMetrics.maxColumnWidth, - int sampleRowCount = ResultGridMetrics.columnWidthSampleRows, -}) { - if (columnWidths.isEmpty || columns.isEmpty) return columnWidths; - - final totalInitial = columnWidths.fold(0.0, (sum, w) => sum + w); - final spare = availableWidth - totalInitial; - if (spare <= 0.5) return columnWidths; - - final sample = rows.length < sampleRowCount ? rows.length : sampleRowCount; - final desiredWidths = List.filled(columns.length, 0); - - for (var c = 0; c < columns.length; c++) { - final headerWidth = columns[c].length * 7.5 + 38.0; - var maxRowChars = 0; - for (var r = 0; r < sample; r++) { - if (c < rows[r].length && rows[r][c].length > maxRowChars) { - maxRowChars = rows[r][c].length; - } - } - final contentWidth = maxRowChars * 7.5 + 24.0; - desiredWidths[c] = math.max(headerWidth, contentWidth); - } - - // Deficit columns: columns whose desired width exceeds the clamped initial width - final deficits = List.filled(columns.length, 0); - var totalDeficit = 0.0; - for (var c = 0; c < columns.length; c++) { - if (desiredWidths[c] > columnWidths[c]) { - final def = desiredWidths[c] - columnWidths[c]; - deficits[c] = def; - totalDeficit += def; - } - } - - final result = List.from(columnWidths); - - if (totalDeficit > 0) { - if (spare <= totalDeficit) { - final ratio = spare / totalDeficit; - for (var c = 0; c < columns.length; c++) { - if (deficits[c] > 0) { - result[c] += deficits[c] * ratio; - } - } - return result; - } else { - for (var c = 0; c < columns.length; c++) { - if (deficits[c] > 0) { - result[c] += deficits[c]; - } - } - final remainingSpare = spare - totalDeficit; - _distributeRemainingSpare(result, remainingSpare); - return result; - } - } else { - _distributeRemainingSpare(result, spare); - return result; - } -} - -void _distributeRemainingSpare(List widths, double spare) { - final weights = List.filled(widths.length, 0); - var totalWeight = 0.0; - for (var i = 0; i < widths.length; i++) { - if (widths[i] > 110) { - final w = widths[i] - 100; - weights[i] = w; - totalWeight += w; - } - } - - if (totalWeight <= 0) { - return; - } - - final ratio = spare / totalWeight; - for (var i = 0; i < widths.length; i++) { - if (weights[i] > 0) { - final extra = math.min(weights[i] * ratio, 120.0); - widths[i] += extra; - } - } -} - -/// 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]: smallest index where columnOffsets[first + 1] > start - var first = 0; - var low = 0; - var high = n - 1; - while (low <= high) { - final mid = (low + high) ~/ 2; - if (columnOffsets[mid + 1] > start) { - first = mid; - high = mid - 1; - } else { - low = mid + 1; - } - } - - // Last column with any pixel before [end]: largest index where columnOffsets[last] < end - var last = n - 1; - low = 0; - high = n - 1; - while (low <= high) { - final mid = (low + high) ~/ 2; - if (columnOffsets[mid] < end) { - last = mid; - low = mid + 1; - } else { - high = mid - 1; - } - } - - 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], - ); -} - -/// Coordinate of a cell in [VirtualResultGrid]. -@immutable -class ResultGridCellCoordinate { - const ResultGridCellCoordinate(this.row, this.column); - - final int row; - final int column; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ResultGridCellCoordinate && - row == other.row && - column == other.column; - - @override - int get hashCode => Object.hash(row, column); -} - -/// Rectangular cell selection range in [VirtualResultGrid]. -@immutable -class ResultGridSelection { - const ResultGridSelection({ - required this.startRow, - required this.startColumn, - required this.endRow, - required this.endColumn, - }); - - factory ResultGridSelection.fromPoints({ - required ResultGridCellCoordinate anchor, - required ResultGridCellCoordinate focus, - }) { - final minR = anchor.row < focus.row ? anchor.row : focus.row; - final maxR = anchor.row > focus.row ? anchor.row : focus.row; - final minC = anchor.column < focus.column ? anchor.column : focus.column; - final maxC = anchor.column > focus.column ? anchor.column : focus.column; - return ResultGridSelection( - startRow: minR, - startColumn: minC, - endRow: maxR, - endColumn: maxC, - ); - } - - final int startRow; - final int startColumn; - final int endRow; - final int endColumn; - - bool contains(int row, int column) => - row >= startRow && - row <= endRow && - column >= startColumn && - column <= endColumn; - - int get rowCount => endRow - startRow + 1; - int get columnCount => endColumn - startColumn + 1; - - /// Formats selected cell values as a Tab-Separated Values (TSV) string. - /// If [columns] is provided, prepends a header row for the selected column range. - String toTsv(List> rows, {List? columns}) { - if (rows.isEmpty) return ''; - final buffer = StringBuffer(); - if (columns != null && columns.isNotEmpty) { - final headerCells = []; - for (var c = startColumn; c <= endColumn; c++) { - final col = c < columns.length ? columns[c] : ''; - headerCells.add(_escapeTsv(col)); - } - buffer.writeln(headerCells.join('\t')); - } - for (var r = startRow; r <= endRow; r++) { - if (r < 0 || r >= rows.length) continue; - final rowData = rows[r]; - final cells = []; - for (var c = startColumn; c <= endColumn; c++) { - final val = c < rowData.length ? rowData[c] : ''; - cells.add(_escapeTsv(val)); - } - buffer.writeln(cells.join('\t')); - } - return buffer.toString().trimRight(); - } - - /// Formats selected cell values as a CSV string. - /// If [columns] is provided, prepends a header row for the selected column range. - String toCsv(List> rows, {List? columns}) { - if (rows.isEmpty) return ''; - final buffer = StringBuffer(); - if (columns != null && columns.isNotEmpty) { - final headerCells = []; - for (var c = startColumn; c <= endColumn; c++) { - final col = c < columns.length ? columns[c] : ''; - headerCells.add(_escapeCsv(col)); - } - buffer.writeln(headerCells.join(',')); - } - for (var r = startRow; r <= endRow; r++) { - if (r < 0 || r >= rows.length) continue; - final rowData = rows[r]; - final cells = []; - for (var c = startColumn; c <= endColumn; c++) { - final val = c < rowData.length ? rowData[c] : ''; - cells.add(_escapeCsv(val)); - } - buffer.writeln(cells.join(',')); - } - return buffer.toString().trimRight(); - } - - /// Formats selected cell values as a formatted JSON string. - /// Returns a JSON object for single-row selection, or a JSON array for multi-row selection. - String toJson(List columns, List> rows) { - if (rows.isEmpty || columns.isEmpty) return '[]'; - final result = >[]; - for (var r = startRow; r <= endRow; r++) { - if (r < 0 || r >= rows.length) continue; - final rowData = rows[r]; - final map = {}; - for (var c = startColumn; c <= endColumn; c++) { - final colName = c < columns.length ? columns[c] : 'col_$c'; - final val = c < rowData.length ? rowData[c] : ''; - if (val == 'NULL') { - map[colName] = null; - } else if (RegExp(r'^0\d+$').hasMatch(val.trim())) { - // Preserve numeric strings with leading zeros (e.g. '01234', '007') - map[colName] = val; - } else if (int.tryParse(val) != null) { - map[colName] = int.parse(val); - } else if (double.tryParse(val) != null) { - map[colName] = double.parse(val); - } else if (val.toLowerCase() == 'true') { - map[colName] = true; - } else if (val.toLowerCase() == 'false') { - map[colName] = false; - } else { - map[colName] = val; - } - } - result.add(map); - } - if (result.length == 1) { - return const JsonEncoder.withIndent(' ').convert(result.first); - } - return const JsonEncoder.withIndent(' ').convert(result); - } - - static String _escapeTsv(String val) { - if (val.contains('\t') || - val.contains('\n') || - val.contains('\r') || - val.contains('"')) { - return '"${val.replaceAll('"', '""')}"'; - } - return val; - } - - static String _escapeCsv(String val) { - if (val.contains(',') || - val.contains('"') || - val.contains('\n') || - val.contains('\r')) { - return '"${val.replaceAll('"', '""')}"'; - } - return val; - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ResultGridSelection && - startRow == other.startRow && - startColumn == other.startColumn && - endRow == other.endRow && - endColumn == other.endColumn; - - @override - int get hashCode => Object.hash(startRow, startColumn, endRow, endColumn); -} - -/// Sorting direction for [VirtualResultGrid]. -enum ResultGridSortOrder { - ascending, - descending, -} - -enum _SortKeyType { nullOrEmpty, numeric, dateTime, string } - -class _SortKey implements Comparable<_SortKey> { - final _SortKeyType type; - final num? numVal; - final DateTime? dtVal; - final String strLower; - final String strRaw; - - _SortKey._({ - required this.type, - this.numVal, - this.dtVal, - this.strLower = '', - this.strRaw = '', - }); - - factory _SortKey.parse(String val) { - if (val == 'NULL' || val.isEmpty) { - return _SortKey._(type: _SortKeyType.nullOrEmpty); - } - final n = num.tryParse(val); - if (n != null) { - return _SortKey._(type: _SortKeyType.numeric, numVal: n, strRaw: val); - } - final dt = DateTime.tryParse(val); - if (dt != null) { - return _SortKey._(type: _SortKeyType.dateTime, dtVal: dt, strRaw: val); - } - return _SortKey._( - type: _SortKeyType.string, - strLower: val.toLowerCase(), - strRaw: val, - ); - } - - @override - int compareTo(_SortKey other) { - if (type == _SortKeyType.nullOrEmpty && - other.type == _SortKeyType.nullOrEmpty) { - return 0; - } - if (type == _SortKeyType.nullOrEmpty) return 1; - if (other.type == _SortKeyType.nullOrEmpty) return -1; - - if (type == _SortKeyType.numeric && other.type == _SortKeyType.numeric) { - return numVal!.compareTo(other.numVal!); - } - if (type == _SortKeyType.dateTime && other.type == _SortKeyType.dateTime) { - return dtVal!.compareTo(other.dtVal!); - } - - final aLower = - type == _SortKeyType.string ? strLower : strRaw.toLowerCase(); - final bLower = other.type == _SortKeyType.string - ? other.strLower - : other.strRaw.toLowerCase(); - final cmp = aLower.compareTo(bLower); - if (cmp != 0) return cmp; - - return strRaw.compareTo(other.strRaw); - } -} - -/// Result of sorting rows in [VirtualResultGrid], preserving model indices. -class SortedResultGridData { - final List> rows; - final List sortedToModelIndices; - - const SortedResultGridData({ - required this.rows, - required this.sortedToModelIndices, - }); -} - -/// Sorts rows by the specified column index with natural numeric / temporal / lexicographic comparison. -/// Uses Schwartzian transform (Decorate-Sort-Undecorate) to precompute sort keys in O(N) time. -/// Returns [SortedResultGridData] containing sorted rows and their corresponding model indices. -SortedResultGridData sortResultGridRowsWithIndices({ - required List> rows, - required int columnIndex, - required ResultGridSortOrder order, -}) { - if (rows.isEmpty || columnIndex < 0) { - return SortedResultGridData( - rows: rows, - sortedToModelIndices: - List.generate(rows.length, (i) => i, growable: false), - ); - } - final n = rows.length; - - final keys = List<_SortKey>.generate(n, (i) { - final row = rows[i]; - final val = columnIndex < row.length ? row[columnIndex] : ''; - return _SortKey.parse(val); - }, growable: false); - - final indices = List.generate(n, (i) => i, growable: false); - - indices.sort((a, b) { - final cmp = keys[a].compareTo(keys[b]); - return order == ResultGridSortOrder.ascending ? cmp : -cmp; - }); - - final sortedRows = - List>.generate(n, (i) => rows[indices[i]], growable: false); - return SortedResultGridData( - rows: sortedRows, - sortedToModelIndices: indices, - ); -} - -/// Sorts rows by the specified column index with natural numeric / temporal / lexicographic comparison. -List> sortResultGridRows({ - required List> rows, - required int columnIndex, - required ResultGridSortOrder order, -}) { - return sortResultGridRowsWithIndices( - rows: rows, - columnIndex: columnIndex, - order: order, - ).rows; -} - -/// Default threshold for offloading data grid sorting to a background isolate. -const int kSortIsolateThreshold = 3000; - -class _SortIsolateParams { - final List> rows; - final int columnIndex; - final ResultGridSortOrder order; - - const _SortIsolateParams({ - required this.rows, - required this.columnIndex, - required this.order, - }); -} - -SortedResultGridData _sortResultGridRowsWithIndicesIsolate( - _SortIsolateParams params) { - return sortResultGridRowsWithIndices( - rows: params.rows, - columnIndex: params.columnIndex, - order: params.order, - ); -} - -/// Adaptive sort function that executes synchronously for small lists (< [kSortIsolateThreshold]), -/// and offloads to a background isolate for large datasets to keep UI fluid. -Future sortResultGridRowsWithIndicesAdaptive({ - required List> rows, - required int columnIndex, - required ResultGridSortOrder order, - int threshold = kSortIsolateThreshold, -}) async { - if (rows.length < threshold) { - return sortResultGridRowsWithIndices( - rows: rows, - columnIndex: columnIndex, - order: order, - ); - } - return foundation.compute( - _sortResultGridRowsWithIndicesIsolate, - _SortIsolateParams(rows: rows, columnIndex: columnIndex, order: order), - ); -} +export 'package:querya_desktop/features/workspace/grid/grid_column_sizer.dart'; +export 'package:querya_desktop/features/workspace/grid/grid_selection_manager.dart'; +export 'package:querya_desktop/features/workspace/grid/grid_sorting.dart'; + +part 'grid/grid_cell_editing.dart'; +part 'grid/grid_view_layout.dart'; +part 'grid/grid_pointer_selection.dart'; +part 'grid/grid_clipboard_service.dart'; +part 'grid/grid_cell_actions.dart'; +part 'grid/grid_keyboard_navigation.dart'; +part 'grid/grid_view_builder.dart'; +part 'grid/grid_header_renderer.dart'; +part 'grid/grid_row_renderer.dart'; +part 'grid/grid_cell_renderer.dart'; /// Virtualized read-only or interactive grid for SQL query results (rows + columns). class VirtualResultGrid extends material.StatefulWidget { @@ -873,2308 +249,7 @@ class _VirtualResultGridState extends material.State { super.dispose(); } - void _startEditing(int row, int column) { - if (widget.stagingBuffer == null) return; - if (row < 0 || - row >= _sortedRows.length || - column < 0 || - column >= widget.columns.length) { - return; - } - setState(() { - _editingCell = ResultGridCellCoordinate(row, column); - _selectionAnchor = _editingCell; - _selectionFocus = _editingCell; - _selection = ResultGridSelection( - startRow: row, - startColumn: column, - endRow: row, - endColumn: column, - ); - }); - } - - /// The selection only when it touches [rowIndex]. Rows outside the range do - /// not depend on it, so a selection change does not rebuild them. - ResultGridSelection? _selectionForRow(int rowIndex) { - final sel = _selection; - if (sel == null || rowIndex < sel.startRow || rowIndex > sel.endRow) { - return null; - } - return sel; - } - - int _toModelRowIndex(int visualRow) { - if (visualRow >= 0 && visualRow < _sortedToModelIndices.length) { - return _sortedToModelIndices[visualRow]; - } - return visualRow; - } - - void _commitEdit( - int row, - int column, - String value, { - bool moveNextCol = false, - bool movePrevCol = false, - bool moveNextRow = false, - bool movePrevRow = false, - }) { - if (widget.stagingBuffer != null) { - final modelRow = _toModelRowIndex(row); - widget.stagingBuffer!.setCell(modelRow, column, value); - } - setState(() { - if (moveNextCol) { - if (column + 1 < widget.columns.length) { - _editingCell = ResultGridCellCoordinate(row, column + 1); - _selection = ResultGridSelection( - startRow: row, - startColumn: column + 1, - endRow: row, - endColumn: column + 1, - ); - } else if (row + 1 < _sortedRows.length) { - _editingCell = ResultGridCellCoordinate(row + 1, 0); - _selection = ResultGridSelection( - startRow: row + 1, - startColumn: 0, - endRow: row + 1, - endColumn: 0, - ); - } else { - _editingCell = null; - } - } else if (movePrevCol) { - if (column > 0) { - _editingCell = ResultGridCellCoordinate(row, column - 1); - _selection = ResultGridSelection( - startRow: row, - startColumn: column - 1, - endRow: row, - endColumn: column - 1, - ); - } else if (row > 0) { - _editingCell = - ResultGridCellCoordinate(row - 1, widget.columns.length - 1); - _selection = ResultGridSelection( - startRow: row - 1, - startColumn: widget.columns.length - 1, - endRow: row - 1, - endColumn: widget.columns.length - 1, - ); - } else { - _editingCell = null; - } - } else if (moveNextRow) { - if (row + 1 < _sortedRows.length) { - _editingCell = ResultGridCellCoordinate(row + 1, column); - _selection = ResultGridSelection( - startRow: row + 1, - startColumn: column, - endRow: row + 1, - endColumn: column, - ); - } else { - _editingCell = null; - } - } else if (movePrevRow) { - if (row > 0) { - _editingCell = ResultGridCellCoordinate(row - 1, column); - _selection = ResultGridSelection( - startRow: row - 1, - startColumn: column, - endRow: row - 1, - endColumn: column, - ); - } else { - _editingCell = null; - } - } else { - _editingCell = null; - } - if (_editingCell != null) { - _selectionAnchor = _editingCell; - _selectionFocus = _editingCell; - widget.onRowSelected?.call(_editingCell!.row); - _scrollToCell(_editingCell!.row, _editingCell!.column); - } - }); - _notifySelectionAndFocus(); - } - - void _cancelEdit() { - setState(() { - _editingCell = null; - }); - } - - Future _openInspector(int row, int column) async { - if (row < 0 || - row >= _sortedRows.length || - column < 0 || - column >= widget.columns.length) { - return; - } - final colName = widget.columns[column]; - final currentVal = - column < _sortedRows[row].length ? _sortedRows[row][column] : ''; - final result = await showGridCellInspectorDialog( - context: context, - columnName: colName, - initialValue: currentVal, - rowIndex: row, - dataTypeName: widget.columnDataTypes?[colName], - ); - if (result != null && widget.stagingBuffer != null) { - widget.stagingBuffer!.setCell(_toModelRowIndex(row), column, result); - } - } - - /// The scroll view already translates header and body together; the offset - /// only decides which columns are built (virtualized window). So rebuild only - /// when the viewport runs past the built window, not on every pan frame. - void _onHorizontalScroll() { - if (!_horizontalController.hasClients) return; - final offset = _horizontalController.offset; - if ((offset - _scrollOffset).abs() < 0.5) return; - _scrollOffset = offset; - - final current = _currentWindow; - if (current != null && _currentDisplayWidths.isNotEmpty) { - final visible = computeVisibleColumnWindow( - columnWidths: _currentDisplayWidths, - columnOffsets: _currentDisplayOffsets, - scrollOffset: offset, - viewportWidth: _currentAvailableWidth, - overscanColumns: 0, - ); - // Every visible column is already built (the rest is overscan): keep the - // window until the viewport reaches its edge, then recentre it. - if (!visible.isEmpty && - current.first <= visible.first && - visible.last <= current.last) { - return; - } - } - setState(() {}); - } - - void _onColumnResize(int index, double delta) { - if (index < 0 || index >= _columnWidths.length) return; - setState(() { - _userHasResized = true; - final minWidth = context.scaled(ResultGridMetrics.minColumnWidth); - final maxWidth = context.scaled(ResultGridMetrics.maxColumnWidth * 3); - final newWidth = (_columnWidths[index] + delta).clamp(minWidth, maxWidth); - _columnWidths = List.from(_columnWidths); - _columnWidths[index] = newWidth; - _columnOffsets = computeResultGridColumnOffsets(_columnWidths); - _widthsNeedUpdate = false; - }); - } - - void _onColumnAutoFit(int index) { - if (index < 0 || - index >= widget.columns.length || - index >= _columnWidths.length) { - return; - } - final rows = _baseRows; - final headerWidth = widget.columns[index].length * 7.5 + 38.0; - var maxRowChars = 0; - final sampleCount = math.min(rows.length, 200); - for (var r = 0; r < sampleCount; r++) { - if (index < rows[r].length && rows[r][index].length > maxRowChars) { - maxRowChars = rows[r][index].length; - } - } - final contentWidth = maxRowChars * 7.5 + 24.0; - final naturalWidth = math.max(headerWidth, contentWidth); - final minWidth = context.scaled(ResultGridMetrics.minColumnWidth); - final maxWidth = context.scaled(ResultGridMetrics.maxColumnWidth * 3); - final autoFitWidth = naturalWidth.clamp(minWidth, maxWidth); - - setState(() { - _userHasResized = true; - _columnWidths = List.from(_columnWidths); - _columnWidths[index] = autoFitWidth; - _columnOffsets = computeResultGridColumnOffsets(_columnWidths); - _widthsNeedUpdate = false; - }); - } - - void _toggleSort(int columnIndex) { - if (columnIndex < 0 || columnIndex >= widget.columns.length) return; - setState(() { - if (_sortColumnIndex == columnIndex) { - if (_sortOrder == ResultGridSortOrder.ascending) { - _sortOrder = ResultGridSortOrder.descending; - } else { - _sortColumnIndex = null; - _sortOrder = null; - } - } else { - _sortColumnIndex = columnIndex; - _sortOrder = ResultGridSortOrder.ascending; - } - _updateSortedRows(); - }); - } - - List> get _baseRows { - if (widget.rowIndicesMapping != null) { - return widget.rows; - } - return widget.stagingBuffer?.effectiveRows ?? widget.rows; - } - - void _updateSortedRows({bool asyncIfLarge = true}) { - _rowWidgets.clear(); - final rows = _baseRows; - if (_sortColumnIndex == null || _sortOrder == null) { - _sortedRows = rows; - if (widget.rowIndicesMapping != null) { - _sortedToModelIndices = List.from(widget.rowIndicesMapping!); - } else { - _sortedToModelIndices = - List.generate(rows.length, (i) => i, growable: false); - } - return; - } - - final sortCol = _sortColumnIndex!; - final sortOrd = _sortOrder!; - final version = ++_sortVersion; - - if (asyncIfLarge && rows.length >= kSortIsolateThreshold) { - foundation - .compute( - _sortResultGridRowsWithIndicesIsolate, - _SortIsolateParams(rows: rows, columnIndex: sortCol, order: sortOrd), - ) - .then((sortedData) { - if (!mounted || version != _sortVersion) return; - setState(() { - _applySortedData(sortedData, rows); - }); - }).catchError((_) { - if (!mounted || version != _sortVersion) return; - setState(() { - final fallback = sortResultGridRowsWithIndices( - rows: rows, - columnIndex: sortCol, - order: sortOrd, - ); - _applySortedData(fallback, rows); - }); - }); - } else { - final sortedData = sortResultGridRowsWithIndices( - rows: rows, - columnIndex: sortCol, - order: sortOrd, - ); - _applySortedData(sortedData, rows); - } - } - - void _applySortedData( - SortedResultGridData sortedData, List> rows) { - _rowWidgets.clear(); - _sortedRows = sortedData.rows; - if (widget.rowIndicesMapping != null) { - final mapping = widget.rowIndicesMapping!; - _sortedToModelIndices = sortedData.sortedToModelIndices - .map((i) => i < mapping.length ? mapping[i] : i) - .toList(growable: false); - } else { - _sortedToModelIndices = sortedData.sortedToModelIndices; - } - } - - void _notifySelectionAndFocus() { - if (widget.onSelectionValuesChanged != null) { - if (_selection == null) { - widget.onSelectionValuesChanged!(const []); - } else { - final rows = _sortedRows; - final values = []; - for (var r = _selection!.startRow; r <= _selection!.endRow; r++) { - if (r >= 0 && r < rows.length) { - for (var c = _selection!.startColumn; - c <= _selection!.endColumn; - c++) { - if (c >= 0 && c < rows[r].length) { - values.add(rows[r][c]); - } - } - } - } - widget.onSelectionValuesChanged!(values); - } - } - - if (widget.onCellFocused != null && _selectionAnchor != null) { - final r = _selectionAnchor!.row; - final c = _selectionAnchor!.column; - final rows = _sortedRows; - if (r >= 0 && r < rows.length && c >= 0 && c < widget.columns.length) { - final colName = widget.columns[c]; - final val = c < rows[r].length ? rows[r][c] : ''; - final modelRow = _toModelRowIndex(r); - widget.onCellFocused!(colName, val, modelRow); - } - } - } - - ResultGridCellCoordinate? _cellAtOffset({ - required Offset localPosition, - required double rowHeight, - }) { - if (_sortedRows.isEmpty || widget.columns.isEmpty) return null; - final rowCount = _sortedRows.length; - final colCount = widget.columns.length; - - final verticalOffset = - _verticalController.hasClients ? _verticalController.offset : 0.0; - final tableY = localPosition.dy + verticalOffset; - final rowIndex = (tableY / rowHeight).floor().clamp(0, rowCount - 1); - - final tableX = localPosition.dx; - final offsets = _currentDisplayOffsets.length >= colCount + 1 - ? _currentDisplayOffsets - : _columnOffsets; - int colIndex = colCount - 1; - for (var i = 0; i < colCount; i++) { - if (i + 1 < offsets.length && tableX < offsets[i + 1]) { - colIndex = i; - break; - } - } - colIndex = colIndex.clamp(0, colCount - 1); - - return ResultGridCellCoordinate(rowIndex, colIndex); - } - - void _onGridPointerDown( - PointerDownEvent event, { - required double rowHeight, - }) { - if (event.buttons != kPrimaryMouseButton) return; - if (_sortedRows.isEmpty || widget.columns.isEmpty) return; - - if (_editingCell != null) { - final downCell = _cellAtOffset( - localPosition: event.localPosition, - rowHeight: rowHeight, - ); - if (downCell == _editingCell) { - // The press landed on the cell currently being edited (e.g. to move - // the caret or select text inside the open GridCellEditor) — let the - // editor's own gesture handling deal with it instead of cancelling - // the in-progress edit out from under the user (#1006). - return; - } - _cancelEdit(); - } - - _isPointerDown = true; - _isDragSelecting = false; - _hasJustDragSelected = false; - _pointerDownGlobalPos = event.position; - _pointerDownLocalPos = event.localPosition; - _lastPointerLocalPos = event.localPosition; - } - - void _onGridPointerMove( - PointerMoveEvent event, { - required double rowHeight, - }) { - if (!_isPointerDown) return; - _lastPointerLocalPos = event.localPosition; - - if (!_isDragSelecting) { - final delta = (event.position - _pointerDownGlobalPos).distance; - if (delta > 4.0) { - _isDragSelecting = true; - _hasJustDragSelected = false; - final startCell = _cellAtOffset( - localPosition: _pointerDownLocalPos, - rowHeight: rowHeight, - ); - if (startCell != null) { - final isShift = HardwareKeyboard.instance.isShiftPressed; - if (!isShift || _selectionAnchor == null) { - _selectionAnchor = startCell; - } - _selectionFocus = startCell; - _selection = ResultGridSelection.fromPoints( - anchor: _selectionAnchor!, - focus: startCell, - ); - _focusNode.requestFocus(); - final modelRow = _toModelRowIndex(startCell.row); - widget.onRowSelected?.call(modelRow); - _notifySelectionAndFocus(); - setState(() {}); - } - _startAutoScroll(rowHeight); - } - } - - if (_isDragSelecting) { - final currentCell = _cellAtOffset( - localPosition: event.localPosition, - rowHeight: rowHeight, - ); - if (currentCell != null && currentCell != _selectionFocus) { - setState(() { - _selectionFocus = currentCell; - _selection = ResultGridSelection.fromPoints( - anchor: _selectionAnchor ?? currentCell, - focus: currentCell, - ); - }); - final modelRow = _toModelRowIndex(currentCell.row); - widget.onRowSelected?.call(modelRow); - _notifySelectionAndFocus(); - } - } - } - - void _onGridPointerUp( - PointerUpEvent event, { - required double rowHeight, - }) { - if (_isDragSelecting) { - _hasJustDragSelected = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - _hasJustDragSelected = false; - }); - } else if (_isPointerDown) { - // A plain click (never crossed the drag threshold): resolve which - // cell was pressed — the down position is the more accurate "which - // cell did the user click" signal than wherever the pointer lifted — - // and treat it as a tap, or as a double-tap if it lands on the same - // cell as the last qualifying tap within the platform's double-tap - // window (#983: replaces a per-cell GestureDetector). - final cell = _cellAtOffset( - localPosition: _pointerDownLocalPos, - rowHeight: rowHeight, - ); - if (cell != null) { - final now = DateTime.now(); - final isDoubleTap = _lastTapCell == cell && - _lastTapUpTime != null && - now.difference(_lastTapUpTime!) <= kDoubleTapTimeout; - final isShift = HardwareKeyboard.instance.isShiftPressed; - _onCellTap(cell.row, cell.column, isShift: isShift); - if (isDoubleTap) { - _startEditing(cell.row, cell.column); - _lastTapUpTime = null; - _lastTapCell = null; - } else { - _lastTapUpTime = now; - _lastTapCell = cell; - } - } - } - _isPointerDown = false; - _isDragSelecting = false; - _stopAutoScroll(); - } - - void _onGridPointerCancel(PointerCancelEvent event) { - _isPointerDown = false; - _isDragSelecting = false; - _stopAutoScroll(); - } - - void _startAutoScroll(double rowHeight) { - _autoScrollTimer?.cancel(); - _autoScrollTimer = Timer.periodic(const Duration(milliseconds: 20), (_) { - if (!_isDragSelecting || _lastPointerLocalPos == null || !mounted) { - _stopAutoScroll(); - return; - } - _performAutoScrollAndSelection(rowHeight); - }); - } - - void _stopAutoScroll() { - _autoScrollTimer?.cancel(); - _autoScrollTimer = null; - } - - void _performAutoScrollAndSelection(double rowHeight) { - if (!mounted) { - _stopAutoScroll(); - return; - } - final pos = _lastPointerLocalPos; - if (pos == null) return; - - const edgeMargin = 32.0; - const maxVelocity = 15.0; - - double dyScroll = 0.0; - if (pos.dy < edgeMargin) { - final factor = ((edgeMargin - pos.dy) / edgeMargin).clamp(0.0, 3.0); - dyScroll = -maxVelocity * factor; - } else if (pos.dy > _currentRowsViewportHeight - edgeMargin) { - final factor = - ((pos.dy - (_currentRowsViewportHeight - edgeMargin)) / edgeMargin) - .clamp(0.0, 3.0); - dyScroll = maxVelocity * factor; - } - - double dxScroll = 0.0; - final hOffset = - _horizontalController.hasClients ? _horizontalController.offset : 0.0; - final screenX = pos.dx - hOffset; - if (screenX < edgeMargin) { - final factor = ((edgeMargin - screenX) / edgeMargin).clamp(0.0, 3.0); - dxScroll = -maxVelocity * factor; - } else if (screenX > _currentAvailableWidth - edgeMargin) { - final factor = - ((screenX - (_currentAvailableWidth - edgeMargin)) / edgeMargin) - .clamp(0.0, 3.0); - dxScroll = maxVelocity * factor; - } - - if (dyScroll != 0.0 && _verticalController.hasClients) { - final targetY = (_verticalController.offset + dyScroll).clamp( - 0.0, - _verticalController.position.maxScrollExtent, - ); - if (targetY != _verticalController.offset) { - _verticalController.jumpTo(targetY); - } - } - - if (dxScroll != 0.0 && _horizontalController.hasClients) { - final targetX = (_horizontalController.offset + dxScroll).clamp( - 0.0, - _horizontalController.position.maxScrollExtent, - ); - if (targetX != _horizontalController.offset) { - _horizontalController.jumpTo(targetX); - } - } - - final currentCell = _cellAtOffset( - localPosition: pos, - rowHeight: rowHeight, - ); - if (currentCell != null && currentCell != _selectionFocus) { - setState(() { - _selectionFocus = currentCell; - _selection = ResultGridSelection.fromPoints( - anchor: _selectionAnchor ?? currentCell, - focus: currentCell, - ); - }); - final modelRow = _toModelRowIndex(currentCell.row); - widget.onRowSelected?.call(modelRow); - _notifySelectionAndFocus(); - } - } - - void _onCellTap(int row, int column, {bool isShift = false}) { - if (_isDragSelecting || _hasJustDragSelected) { - _hasJustDragSelected = false; - return; - } - _focusNode.requestFocus(); - final modelRow = _toModelRowIndex(row); - widget.onRowSelected?.call(modelRow); - setState(() { - final coord = ResultGridCellCoordinate(row, column); - if (isShift && _selectionAnchor != null) { - _selectionFocus = coord; - _selection = ResultGridSelection.fromPoints( - anchor: _selectionAnchor!, - focus: coord, - ); - } else { - _selectionAnchor = coord; - _selectionFocus = coord; - _selection = ResultGridSelection( - startRow: row, - startColumn: column, - endRow: row, - endColumn: column, - ); - } - }); - _notifySelectionAndFocus(); - } - - void _onCellSecondaryTap(int row, int column) { - _focusNode.requestFocus(); - widget.onRowSelected?.call(row); - if (_selection != null && _selection!.contains(row, column)) { - // Keep existing selection intact so context menu operates on whole selection if clicked inside - } else { - setState(() { - final coord = ResultGridCellCoordinate(row, column); - _selectionAnchor = coord; - _selectionFocus = coord; - _selection = ResultGridSelection( - startRow: row, - startColumn: column, - endRow: row, - endColumn: column, - ); - }); - _notifySelectionAndFocus(); - } - } - - void _copySelection( - {bool withHeaders = false, bool asCsv = false, bool asJson = false}) { - if (_selection == null) return; - _copySelectionData( - _selection!, - withHeaders: withHeaders, - asCsv: asCsv, - asJson: asJson, - ); - } - - void _handleCopyCell( - int row, - int column, { - bool withHeaders = false, - bool asCsv = false, - bool asJson = false, - }) { - final sel = (_selection != null && _selection!.contains(row, column)) - ? _selection! - : ResultGridSelection( - startRow: row, - startColumn: column, - endRow: row, - endColumn: column, - ); - _copySelectionData( - sel, - withHeaders: withHeaders, - asCsv: asCsv, - asJson: asJson, - ); - } - - void _copySelectionData( - ResultGridSelection sel, { - bool withHeaders = false, - bool asCsv = false, - bool asJson = false, - }) { - if (_sortedRows.isEmpty) return; - String text; - if (asJson) { - text = sel.toJson(widget.columns, _sortedRows); - } else if (asCsv) { - text = sel.toCsv( - _sortedRows, - columns: withHeaders ? widget.columns : null, - ); - } else { - text = sel.toTsv( - _sortedRows, - columns: withHeaders ? widget.columns : null, - ); - } - if (text.isNotEmpty) { - Clipboard.setData(ClipboardData(text: text)); - } - } - - void _handleFilterByValue(int row, int col, {required bool invert}) { - if (widget.onFilterRequested == null) return; - if (col >= widget.columns.length || row >= _sortedRows.length) return; - final colName = widget.columns[col]; - final rowData = _sortedRows[row]; - final cellVal = col < rowData.length ? rowData[col] : ''; - - String expr; - if (cellVal == 'NULL') { - expr = invert ? '$colName IS NOT NULL' : '$colName IS NULL'; - } else if (num.tryParse(cellVal) != null) { - expr = invert ? '$colName != $cellVal' : '$colName = $cellVal'; - } else { - final escaped = cellVal.replaceAll("'", "''"); - expr = invert ? "$colName != '$escaped'" : "$colName = '$escaped'"; - } - widget.onFilterRequested!(expr); - } - - void _handleFilterComparison(int row, int col, String operator) { - if (widget.onFilterRequested == null) return; - if (col >= widget.columns.length || row >= _sortedRows.length) return; - final colName = widget.columns[col]; - final rowData = _sortedRows[row]; - final cellVal = col < rowData.length ? rowData[col] : ''; - if (num.tryParse(cellVal) == null) return; - - widget.onFilterRequested!('$colName $operator $cellVal'); - } - - void _handleSetNull(int row, int col) { - if (widget.stagingBuffer == null) return; - final sel = (_selection != null && _selection!.contains(row, col)) - ? _selection! - : ResultGridSelection( - startRow: row, startColumn: col, endRow: row, endColumn: col); - for (var r = sel.startRow; r <= sel.endRow; r++) { - final modelRow = _toModelRowIndex(r); - for (var c = sel.startColumn; c <= sel.endColumn; c++) { - widget.stagingBuffer!.setCellNull(modelRow, c); - } - } - } - - void _handleSetEmpty(int row, int col) { - if (widget.stagingBuffer == null) return; - final sel = (_selection != null && _selection!.contains(row, col)) - ? _selection! - : ResultGridSelection( - startRow: row, startColumn: col, endRow: row, endColumn: col); - for (var r = sel.startRow; r <= sel.endRow; r++) { - final modelRow = _toModelRowIndex(r); - for (var c = sel.startColumn; c <= sel.endColumn; c++) { - widget.stagingBuffer!.setCell(modelRow, c, ''); - } - } - } - - void _handleRevertCell(int row, int col) { - if (widget.stagingBuffer == null) return; - final sel = (_selection != null && _selection!.contains(row, col)) - ? _selection! - : ResultGridSelection( - startRow: row, startColumn: col, endRow: row, endColumn: col); - for (var r = sel.startRow; r <= sel.endRow; r++) { - final modelRow = _toModelRowIndex(r); - for (var c = sel.startColumn; c <= sel.endColumn; c++) { - widget.stagingBuffer!.revertCell(modelRow, c); - } - } - } - - void _handleDuplicateRow(int row) { - if (widget.stagingBuffer == null || row >= _sortedRows.length) return; - widget.stagingBuffer!.duplicateRow(_toModelRowIndex(row)); - } - - void _handleToggleDeleteRow(int row) { - if (widget.stagingBuffer == null || row >= _sortedRows.length) return; - widget.stagingBuffer!.toggleDeleteRow(_toModelRowIndex(row)); - } - - void _handleRevertRow(int row) { - if (widget.stagingBuffer == null || row >= _sortedRows.length) return; - widget.stagingBuffer!.revertRow(_toModelRowIndex(row)); - } - - List _computeColumnWidths() { - return computeResultGridColumnWidths( - columns: widget.columns, - rows: _baseRows, - minWidth: context.scaled(ResultGridMetrics.minColumnWidth), - maxWidth: context.scaled(ResultGridMetrics.maxColumnWidth), - ); - } - - double get _tableWidth { - if (_columnWidths.isEmpty) return 0; - return _columnOffsets[_columnWidths.length]; - } - - double _scaledRowHeight(material.BuildContext context) => - context.scaled(ResultGridMetrics.rowHeight); - - 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, - ); - } - - void _scrollToCell(int row, int col) { - if (!mounted) return; - final rowHeight = _scaledRowHeight(context); - final headerHeight = _scaledHeaderHeight(context); - - // Vertical scroll - if (_verticalController.hasClients) { - final targetTop = row * rowHeight; - final targetBottom = targetTop + rowHeight; - final currentOffset = _verticalController.offset; - final viewportHeight = - _verticalController.position.viewportDimension - headerHeight; - - if (targetTop < currentOffset) { - _verticalController.jumpTo(targetTop.clamp( - 0.0, - _verticalController.position.maxScrollExtent, - )); - } else if (targetBottom > currentOffset + viewportHeight && - viewportHeight > 0) { - final newOffset = (targetBottom - viewportHeight).clamp( - 0.0, - _verticalController.position.maxScrollExtent, - ); - _verticalController.jumpTo(newOffset); - } - } - - // Horizontal scroll - if (_horizontalController.hasClients && - col >= 0 && - col < _columnWidths.length) { - final colLeft = _columnOffsets[col]; - final colRight = colLeft + _columnWidths[col]; - final currentOffset = _horizontalController.offset; - final viewportWidth = _horizontalController.position.viewportDimension; - - if (colLeft < currentOffset) { - _horizontalController.jumpTo(colLeft.clamp( - 0.0, - _horizontalController.position.maxScrollExtent, - )); - } else if (colRight > currentOffset + viewportWidth && - viewportWidth > 0) { - final newOffset = (colRight - viewportWidth).clamp( - 0.0, - _horizontalController.position.maxScrollExtent, - ); - _horizontalController.jumpTo(newOffset); - } - } - } - - void _navigateCell(int dRow, int dCol, {bool extendSelection = false}) { - if (_sortedRows.isEmpty || widget.columns.isEmpty) return; - if (_editingCell != null) return; - - if (_selectionAnchor == null) { - setState(() { - _selectionAnchor = const ResultGridCellCoordinate(0, 0); - _selectionFocus = const ResultGridCellCoordinate(0, 0); - _selection = const ResultGridSelection( - startRow: 0, - startColumn: 0, - endRow: 0, - endColumn: 0, - ); - }); - widget.onRowSelected?.call(0); - _scrollToCell(0, 0); - _notifySelectionAndFocus(); - return; - } - - final currentFocus = _selectionFocus ?? _selectionAnchor!; - final newRow = (currentFocus.row + dRow).clamp(0, _sortedRows.length - 1); - final newCol = - (currentFocus.column + dCol).clamp(0, widget.columns.length - 1); - final newFocus = ResultGridCellCoordinate(newRow, newCol); - - setState(() { - if (extendSelection) { - _selectionFocus = newFocus; - _selection = ResultGridSelection.fromPoints( - anchor: _selectionAnchor!, - focus: newFocus, - ); - } else { - _selectionAnchor = newFocus; - _selectionFocus = newFocus; - _selection = ResultGridSelection( - startRow: newRow, - startColumn: newCol, - endRow: newRow, - endColumn: newCol, - ); - widget.onRowSelected?.call(newRow); - } - }); - - _scrollToCell(newRow, newCol); - _notifySelectionAndFocus(); - } - - void _jumpToCell({int? row, int? column, bool extendSelection = false}) { - if (_sortedRows.isEmpty || widget.columns.isEmpty) return; - if (_editingCell != null) return; - - final currentFocus = _selectionFocus ?? - _selectionAnchor ?? - const ResultGridCellCoordinate(0, 0); - final newRow = (row ?? currentFocus.row).clamp(0, _sortedRows.length - 1); - final newCol = - (column ?? currentFocus.column).clamp(0, widget.columns.length - 1); - final newFocus = ResultGridCellCoordinate(newRow, newCol); - - setState(() { - if (extendSelection) { - _selectionAnchor ??= currentFocus; - _selectionFocus = newFocus; - _selection = ResultGridSelection.fromPoints( - anchor: _selectionAnchor!, - focus: newFocus, - ); - } else { - _selectionAnchor = newFocus; - _selectionFocus = newFocus; - _selection = ResultGridSelection( - startRow: newRow, - startColumn: newCol, - endRow: newRow, - endColumn: newCol, - ); - widget.onRowSelected?.call(newRow); - } - }); - - _scrollToCell(newRow, newCol); - _notifySelectionAndFocus(); - } - - void _selectAll() { - if (_sortedRows.isEmpty || widget.columns.isEmpty) return; - if (_editingCell != null) return; - - setState(() { - _selectionAnchor = const ResultGridCellCoordinate(0, 0); - _selectionFocus = ResultGridCellCoordinate( - _sortedRows.length - 1, - widget.columns.length - 1, - ); - _selection = ResultGridSelection( - startRow: 0, - startColumn: 0, - endRow: _sortedRows.length - 1, - endColumn: widget.columns.length - 1, - ); - }); - - _notifySelectionAndFocus(); - } - - @override - material.Widget build(material.BuildContext context) { - if (_widthsNeedUpdate && !_userHasResized) { - _columnWidths = _computeColumnWidths(); - _columnOffsets = computeResultGridColumnOffsets(_columnWidths); - _widthsNeedUpdate = false; - } - final cs = Theme.of(context).colorScheme; - - return material.CallbackShortcuts( - bindings: { - const material.SingleActivator( - LogicalKeyboardKey.keyC, - meta: true, - ): () => _copySelection(), - const material.SingleActivator( - LogicalKeyboardKey.keyC, - control: true, - ): () => _copySelection(), - const material.SingleActivator( - LogicalKeyboardKey.keyC, - meta: true, - shift: true, - ): () => _copySelection(withHeaders: true), - const material.SingleActivator( - LogicalKeyboardKey.keyC, - control: true, - shift: true, - ): () => _copySelection(withHeaders: true), - const material.SingleActivator( - LogicalKeyboardKey.keyD, - control: true, - ): () { - if (widget.stagingBuffer != null && _selection != null) { - _handleDuplicateRow(_selection!.startRow); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.keyD, - meta: true, - ): () { - if (widget.stagingBuffer != null && _selection != null) { - _handleDuplicateRow(_selection!.startRow); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.insert, - control: true, - ): () => widget.stagingBuffer?.addRow(), - const material.SingleActivator( - LogicalKeyboardKey.keyN, - meta: true, - ): () { - if (widget.stagingBuffer != null) { - widget.stagingBuffer!.addRow(); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.delete, - control: true, - ): () { - if (widget.stagingBuffer != null && _selection != null) { - widget.stagingBuffer! - .toggleDeleteRow(_toModelRowIndex(_selection!.startRow)); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.backspace, - meta: true, - ): () { - if (widget.stagingBuffer != null && _selection != null) { - widget.stagingBuffer! - .toggleDeleteRow(_toModelRowIndex(_selection!.startRow)); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.keyZ, - control: true, - ): () { - if (widget.stagingBuffer != null && _selection != null) { - widget.stagingBuffer! - .revertRow(_toModelRowIndex(_selection!.startRow)); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.keyZ, - meta: true, - ): () { - if (widget.stagingBuffer != null && _selection != null) { - widget.stagingBuffer! - .revertRow(_toModelRowIndex(_selection!.startRow)); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.escape, - ): () { - if (_editingCell != null) { - _cancelEdit(); - } else { - widget.onRowSelected?.call(null); - setState(() { - _selection = null; - _selectionAnchor = null; - _selectionFocus = null; - }); - _notifySelectionAndFocus(); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.f2, - ): () { - if (_selection != null && _editingCell == null) { - _startEditing(_selection!.startRow, _selection!.startColumn); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.enter, - ): () { - if (_selection != null && _editingCell == null) { - _startEditing(_selection!.startRow, _selection!.startColumn); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.numpadEnter, - ): () { - if (_selection != null && _editingCell == null) { - _startEditing(_selection!.startRow, _selection!.startColumn); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.space, - ): () { - if (_selection != null && _editingCell == null) { - _openInspector(_selection!.startRow, _selection!.startColumn); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.keyI, - control: true, - ): () { - if (_selection != null && _editingCell == null) { - _openInspector(_selection!.startRow, _selection!.startColumn); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.keyI, - meta: true, - ): () { - if (_selection != null && _editingCell == null) { - _openInspector(_selection!.startRow, _selection!.startColumn); - } - }, - const material.SingleActivator( - LogicalKeyboardKey.keyN, - alt: true, - ): () { - if (_selection != null && widget.stagingBuffer != null) { - _handleSetNull(_selection!.startRow, _selection!.startColumn); - } - }, - - // Navigation: Arrows - const material.SingleActivator(LogicalKeyboardKey.arrowDown): () => - _navigateCell(1, 0), - const material.SingleActivator(LogicalKeyboardKey.arrowUp): () => - _navigateCell(-1, 0), - const material.SingleActivator(LogicalKeyboardKey.arrowRight): () => - _navigateCell(0, 1), - const material.SingleActivator(LogicalKeyboardKey.arrowLeft): () => - _navigateCell(0, -1), - - // Navigation: Shift + Arrows (range selection) - const material.SingleActivator( - LogicalKeyboardKey.arrowDown, - shift: true, - ): () => _navigateCell(1, 0, extendSelection: true), - const material.SingleActivator( - LogicalKeyboardKey.arrowUp, - shift: true, - ): () => _navigateCell(-1, 0, extendSelection: true), - const material.SingleActivator( - LogicalKeyboardKey.arrowRight, - shift: true, - ): () => _navigateCell(0, 1, extendSelection: true), - const material.SingleActivator( - LogicalKeyboardKey.arrowLeft, - shift: true, - ): () => _navigateCell(0, -1, extendSelection: true), - - // Navigation: Home / End (column jump) - const material.SingleActivator(LogicalKeyboardKey.home): () => - _jumpToCell(column: 0), - const material.SingleActivator( - LogicalKeyboardKey.home, - shift: true, - ): () => _jumpToCell(column: 0, extendSelection: true), - const material.SingleActivator(LogicalKeyboardKey.end): () => - _jumpToCell(column: widget.columns.length - 1), - const material.SingleActivator( - LogicalKeyboardKey.end, - shift: true, - ): () => _jumpToCell( - column: widget.columns.length - 1, - extendSelection: true, - ), - - // Navigation: Ctrl+Home / Ctrl+End (table start / end) - const material.SingleActivator( - LogicalKeyboardKey.home, - control: true, - ): () => _jumpToCell(row: 0, column: 0), - const material.SingleActivator( - LogicalKeyboardKey.home, - meta: true, - ): () => _jumpToCell(row: 0, column: 0), - const material.SingleActivator( - LogicalKeyboardKey.home, - control: true, - shift: true, - ): () => _jumpToCell(row: 0, column: 0, extendSelection: true), - const material.SingleActivator( - LogicalKeyboardKey.home, - meta: true, - shift: true, - ): () => _jumpToCell(row: 0, column: 0, extendSelection: true), - const material.SingleActivator( - LogicalKeyboardKey.end, - control: true, - ): () => _jumpToCell( - row: _sortedRows.length - 1, - column: widget.columns.length - 1, - ), - const material.SingleActivator( - LogicalKeyboardKey.end, - meta: true, - ): () => _jumpToCell( - row: _sortedRows.length - 1, - column: widget.columns.length - 1, - ), - const material.SingleActivator( - LogicalKeyboardKey.end, - control: true, - shift: true, - ): () => _jumpToCell( - row: _sortedRows.length - 1, - column: widget.columns.length - 1, - extendSelection: true, - ), - const material.SingleActivator( - LogicalKeyboardKey.end, - meta: true, - shift: true, - ): () => _jumpToCell( - row: _sortedRows.length - 1, - column: widget.columns.length - 1, - extendSelection: true, - ), - - // Navigation: PageUp / PageDown - const material.SingleActivator(LogicalKeyboardKey.pageDown): () => - _navigateCell(20, 0), - const material.SingleActivator( - LogicalKeyboardKey.pageDown, - shift: true, - ): () => _navigateCell(20, 0, extendSelection: true), - const material.SingleActivator(LogicalKeyboardKey.pageUp): () => - _navigateCell(-20, 0), - const material.SingleActivator( - LogicalKeyboardKey.pageUp, - shift: true, - ): () => _navigateCell(-20, 0, extendSelection: true), - - // Select All: Ctrl+A / Meta+A - const material.SingleActivator( - LogicalKeyboardKey.keyA, - control: true, - ): () => _selectAll(), - const material.SingleActivator( - LogicalKeyboardKey.keyA, - meta: true, - ): () => _selectAll(), - }, - child: material.Focus( - focusNode: _focusNode, - child: material.RepaintBoundary( - child: material.LayoutBuilder( - builder: (context, constraints) { - final availableWidth = constraints.maxWidth; - final headerHeight = _scaledHeaderHeight(context); - final rowHeight = _scaledRowHeight(context); - final rowsViewportHeight = - math.max(0.0, constraints.maxHeight - headerHeight); - _currentAvailableWidth = availableWidth; - _currentRowsViewportHeight = rowsViewportHeight; - - var displayWidths = _columnWidths; - var tableWidth = _tableWidth; - if (!_userHasResized && - tableWidth < availableWidth && - _columnWidths.isNotEmpty) { - if (identical(_distributedForColumnWidths, _columnWidths) && - _distributedForAvailableWidth == availableWidth) { - displayWidths = _distributedWidths; - } else { - displayWidths = distributeResultGridSpareWidth( - columnWidths: _columnWidths, - columns: widget.columns, - rows: _baseRows, - availableWidth: availableWidth, - maxColumnWidth: - context.scaled(ResultGridMetrics.maxColumnWidth), - ); - _distributedWidths = displayWidths; - _distributedForColumnWidths = _columnWidths; - _distributedForAvailableWidth = availableWidth; - columnWidthDistributionCount++; - } - final distributedWidth = - displayWidths.fold(0.0, (sum, w) => sum + w); - tableWidth = math.max(distributedWidth, availableWidth); - } else if (tableWidth < availableWidth) { - tableWidth = availableWidth; - } - - _currentDisplayOffsets = identical(displayWidths, _columnWidths) - ? _columnOffsets - : computeResultGridColumnOffsets(displayWidths); - - final window = _columnWindow(displayWidths, availableWidth); - _currentDisplayWidths = displayWidths; - _currentWindow = window; - - return material.Scrollbar( - controller: _horizontalController, - thumbVisibility: true, - notificationPredicate: (_) => true, - child: material.SingleChildScrollView( - controller: _horizontalController, - scrollDirection: material.Axis.horizontal, - child: material.SizedBox( - width: tableWidth, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _HeaderRow( - columns: widget.columns, - columnWidths: displayWidths, - window: window, - height: headerHeight, - colorScheme: cs, - sortColumnIndex: _sortColumnIndex, - sortOrder: _sortOrder, - onSortColumn: _toggleSort, - onResizeColumn: _onColumnResize, - onAutoFitColumn: _onColumnAutoFit, - ), - material.Expanded( - child: material.Scrollbar( - controller: _verticalController, - thumbVisibility: true, - // One grid-level cursor region instead of one - // MouseRegion per visible cell (#983). Matches - // the previous per-cell behavior exactly: cursor - // was already uniform across every cell based - // solely on whether a staging buffer exists, not - // on which specific cell is hovered. - child: material.MouseRegion( - cursor: widget.stagingBuffer != null - ? material.SystemMouseCursors.text - : material.SystemMouseCursors.basic, - child: material.Listener( - behavior: material.HitTestBehavior.translucent, - onPointerDown: (e) => - _onGridPointerDown(e, rowHeight: rowHeight), - onPointerMove: (e) => - _onGridPointerMove(e, rowHeight: rowHeight), - onPointerUp: (e) => - _onGridPointerUp(e, rowHeight: rowHeight), - onPointerCancel: _onGridPointerCancel, - child: material.ListView.builder( - controller: _verticalController, - itemCount: _sortedRows.length, - itemExtent: rowHeight, - itemBuilder: (context, rowIndex) { - final row = _sortedRows[rowIndex]; - final isEven = rowIndex.isEven; - final candidate = _DataRow( - key: ValueKey('result-row-$rowIndex'), - rowIndex: rowIndex, - modelRowIndex: _toModelRowIndex(rowIndex), - row: row, - columns: widget.columns, - columnWidths: displayWidths, - window: window, - height: rowHeight, - colorScheme: cs, - striped: !isEven, - selection: _selectionForRow(rowIndex), - stagingBuffer: widget.stagingBuffer, - stagedSignature: widget.stagingBuffer - ?.rowRenderSignature( - _toModelRowIndex(rowIndex)) ?? - 0, - editingCell: _editingCell?.row == rowIndex - ? _editingCell - : null, - canFilter: - widget.onFilterRequested != null, - onCellSecondaryTap: _onCellSecondaryTap, - onCommitEdit: _commitEdit, - onCancelEdit: _cancelEdit, - onOpenInspector: _openInspector, - onCopyCell: _handleCopyCell, - onFilterByValue: _handleFilterByValue, - onFilterComparison: - _handleFilterComparison, - onSetNull: _handleSetNull, - onSetEmpty: _handleSetEmpty, - onRevertCell: _handleRevertCell, - onDuplicateRow: _handleDuplicateRow, - onToggleDeleteRow: _handleToggleDeleteRow, - onRevertRow: _handleRevertRow, - columnDataTypes: widget.columnDataTypes, - ); - final previous = _rowWidgets[rowIndex]; - if (previous != null && - previous.sameAs(candidate)) { - return previous; - } - _rowWidgets[rowIndex] = candidate; - if (_rowWidgets.length > 200) { - _rowWidgets.removeWhere( - (idx, _) => - (idx - rowIndex).abs() > 100, - ); - } - return candidate; - }, - ), - ), - ), - ), - ), - ], - ), - ), - ), - ); - }, - ), - ), - ), - ); - } -} - -class _HeaderRow extends material.StatelessWidget { - const _HeaderRow({ - required this.columns, - required this.columnWidths, - required this.window, - required this.height, - required this.colorScheme, - this.sortColumnIndex, - this.sortOrder, - this.onSortColumn, - this.onResizeColumn, - this.onAutoFitColumn, - }); - - final List columns; - final List columnWidths; - final ResultGridColumnWindow window; - final double height; - final ColorScheme colorScheme; - final int? sortColumnIndex; - final ResultGridSortOrder? sortOrder; - final material.ValueChanged? onSortColumn; - final void Function(int index, double delta)? onResizeColumn; - final void Function(int index)? onAutoFitColumn; - - @override - material.Widget build(material.BuildContext context) { - return material.Container( - height: height, - decoration: material.BoxDecoration( - color: colorScheme.muted.withValues(alpha: 0.35), - border: material.Border( - bottom: material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.5), - ), - ), - ), - child: material.Row( - children: [ - if (window.leadingWidth > 0) - material.SizedBox(width: window.leadingWidth), - for (var i = window.first; i <= window.last; i++) - _HeaderCell( - text: columns[i], - width: columnWidths[i], - colorScheme: colorScheme, - sortOrder: sortColumnIndex == i ? sortOrder : null, - onSort: onSortColumn != null ? () => onSortColumn!(i) : null, - onResize: onResizeColumn != null - ? (delta) => onResizeColumn!(i, delta) - : null, - onAutoFit: - onAutoFitColumn != null ? () => onAutoFitColumn!(i) : null, - ), - if (window.trailingWidth > 0) - material.SizedBox(width: window.trailingWidth), - ], - ), - ); - } -} - -class _HeaderCell extends material.StatelessWidget { - const _HeaderCell({ - required this.text, - required this.width, - required this.colorScheme, - this.sortOrder, - this.onSort, - this.onResize, - this.onAutoFit, - }); - - final String text; - final double width; - final ColorScheme colorScheme; - final ResultGridSortOrder? sortOrder; - final material.VoidCallback? onSort; - final material.ValueChanged? onResize; - final material.VoidCallback? onAutoFit; - - @override - material.Widget build(material.BuildContext context) { - final isSorted = sortOrder != null; - final style = material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.w600, - color: isSorted ? colorScheme.primary : colorScheme.foreground, - ); - - return material.Container( - width: width, - height: double.infinity, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.3), - ), - ), - ), - child: material.Stack( - clipBehavior: material.Clip.none, - children: [ - material.Positioned.fill( - child: material.MouseRegion( - cursor: onSort != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - child: material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onTap: onSort, - child: material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 10), - child: material.Row( - children: [ - material.Expanded( - child: material.Tooltip( - message: text, - waitDuration: kQueryaTooltipWait, - child: material.Text( - text, - style: style, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - ), - ), - ), - if (sortOrder != null) ...[ - const Gap(4), - material.AnimatedRotation( - turns: sortOrder == ResultGridSortOrder.ascending - ? 0.0 - : 0.5, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.enter), - child: material.Icon( - material.Icons.arrow_upward_rounded, - size: 14, - color: colorScheme.primary, - ), - ), - ], - ], - ), - ), - ), - ), - ), - if (onResize != null || onAutoFit != null) - material.Positioned( - right: -5, - top: 0, - bottom: 0, - width: 12, - child: material.MouseRegion( - cursor: material.SystemMouseCursors.resizeColumn, - child: material.GestureDetector( - behavior: material.HitTestBehavior.translucent, - onDoubleTap: onAutoFit, - onHorizontalDragUpdate: onResize != null - ? (details) => onResize!(details.delta.dx) - : null, - ), - ), - ), - ], - ), - ); - } -} - -class _DataRow extends material.StatefulWidget { - const _DataRow({ - super.key, - required this.rowIndex, - required this.modelRowIndex, - required this.row, - required this.columns, - required this.columnWidths, - required this.window, - required this.height, - required this.colorScheme, - required this.striped, - this.selection, - this.stagingBuffer, - this.stagedSignature = 0, - this.editingCell, - this.canFilter = false, - this.onCellSecondaryTap, - this.onCommitEdit, - this.onCancelEdit, - this.onOpenInspector, - this.onCopyCell, - this.onFilterByValue, - this.onFilterComparison, - this.onSetNull, - this.onSetEmpty, - this.onRevertCell, - this.onDuplicateRow, - this.onToggleDeleteRow, - this.onRevertRow, - this.columnDataTypes, - }); - - final int rowIndex; - final int modelRowIndex; - final List row; - final List columns; - final List columnWidths; - final ResultGridColumnWindow window; - final double height; - final ColorScheme colorScheme; - final bool striped; - final ResultGridSelection? selection; - final DataGridStagingBuffer? stagingBuffer; - - /// [DataGridStagingBuffer.rowRenderSignature] of this row, so equality can - /// see staged-state changes the (shared, mutable) buffer reference hides. - final int stagedSignature; - final ResultGridCellCoordinate? editingCell; - final bool canFilter; - final void Function(int row, int col)? onCellSecondaryTap; - final void Function( - int row, - int col, - String val, { - bool moveNextCol, - bool movePrevCol, - bool moveNextRow, - bool movePrevRow, - })? onCommitEdit; - final material.VoidCallback? onCancelEdit; - final void Function(int row, int col)? onOpenInspector; - final void Function( - int row, - int col, { - bool withHeaders, - bool asJson, - bool asCsv, - })? onCopyCell; - final void Function(int row, int col, {required bool invert})? - onFilterByValue; - final void Function(int row, int col, String operator)? onFilterComparison; - final void Function(int row, int col)? onSetNull; - final void Function(int row, int col)? onSetEmpty; - final void Function(int row, int col)? onRevertCell; - final void Function(int row)? onDuplicateRow; - final void Function(int row)? onToggleDeleteRow; - final void Function(int row)? onRevertRow; - final Map? columnDataTypes; - - /// True when [other] would draw exactly the same row. The grid hands the - /// framework the *previous* widget instance in that case, so - /// [Element.updateChild] skips it and an edit, a selection change or a scroll - /// only rebuilds the rows whose inputs actually changed. - bool sameAs(_DataRow other) => - identical(this, other) || - rowIndex == other.rowIndex && - modelRowIndex == other.modelRowIndex && - identical(row, other.row) && - identical(columns, other.columns) && - (identical(columnWidths, other.columnWidths) || - foundation.listEquals(columnWidths, other.columnWidths)) && - window == other.window && - height == other.height && - colorScheme == other.colorScheme && - striped == other.striped && - selection == other.selection && - identical(stagingBuffer, other.stagingBuffer) && - stagedSignature == other.stagedSignature && - editingCell == other.editingCell && - canFilter == other.canFilter && - onCellSecondaryTap == other.onCellSecondaryTap && - onCommitEdit == other.onCommitEdit && - onCancelEdit == other.onCancelEdit && - onOpenInspector == other.onOpenInspector && - onCopyCell == other.onCopyCell && - onFilterByValue == other.onFilterByValue && - onFilterComparison == other.onFilterComparison && - onSetNull == other.onSetNull && - onSetEmpty == other.onSetEmpty && - onRevertCell == other.onRevertCell && - onDuplicateRow == other.onDuplicateRow && - onToggleDeleteRow == other.onToggleDeleteRow && - onRevertRow == other.onRevertRow && - identical(columnDataTypes, other.columnDataTypes); - - @override - material.State<_DataRow> createState() => _DataRowState(); - - /// One label for the whole row (visible columns only), read by screen - /// readers instead of a separate semantics node per cell — see #981. - String _semanticsLabel(StagedRowStatus rowStatus) { - final buffer = StringBuffer('Row ${modelRowIndex + 1}'); - switch (rowStatus) { - case StagedRowStatus.deleted: - buffer.write(' (deleted)'); - case StagedRowStatus.inserted: - buffer.write(' (new)'); - case StagedRowStatus.modified: - buffer.write(' (modified)'); - case StagedRowStatus.unchanged: - break; - } - for (var c = window.first; c <= window.last && c < columns.length; c++) { - buffer.write(', ${columns[c]}: ${c < row.length ? row[c] : ''}'); - } - return buffer.toString(); - } - - material.Widget _buildRow(Map cache) { - final rowStatus = - stagingBuffer?.getRowStatus(modelRowIndex) ?? StagedRowStatus.unchanged; - - return material.Semantics( - container: true, - label: _semanticsLabel(rowStatus), - child: material.RepaintBoundary( - child: material.SizedBox( - height: height, - child: material.Row( - children: [ - if (window.leadingWidth > 0) - material.SizedBox( - key: const material.ValueKey('lead'), - width: window.leadingWidth, - ), - for (var c = window.first; c <= window.last; c++) - _reuseCell( - cache, - c, - _GridCell( - key: material.ValueKey(c), - row: rowIndex, - column: c, - columnName: c < columns.length ? columns[c] : '', - text: c < row.length ? row[c] : '', - width: columnWidths[c], - colorScheme: colorScheme, - striped: striped, - rowStatus: rowStatus, - cellStatus: - stagingBuffer?.getCellStatus(modelRowIndex, c) ?? - StagedCellStatus.clean, - isSelected: selection?.contains(rowIndex, c) ?? false, - isEditing: editingCell?.row == rowIndex && - editingCell?.column == c, - isSelectionTop: selection != null && - selection!.contains(rowIndex, c) && - rowIndex == selection!.startRow, - isSelectionBottom: selection != null && - selection!.contains(rowIndex, c) && - rowIndex == selection!.endRow, - isSelectionLeft: selection != null && - selection!.contains(rowIndex, c) && - c == selection!.startColumn, - isSelectionRight: selection != null && - selection!.contains(rowIndex, c) && - c == selection!.endColumn, - canFilter: canFilter, - hasStagingBuffer: stagingBuffer != null, - dataTypeName: - columnDataTypes?[c < columns.length ? columns[c] : ''], - onSecondaryTap: onCellSecondaryTap, - onCommitEdit: onCommitEdit, - onCancelEdit: onCancelEdit, - onOpenInspector: onOpenInspector, - onCopyCell: onCopyCell, - onFilterByValue: onFilterByValue, - onFilterComparison: onFilterComparison, - onSetNull: onSetNull, - onSetEmpty: onSetEmpty, - onRevertCell: onRevertCell, - onDuplicateRow: onDuplicateRow, - onToggleDeleteRow: onToggleDeleteRow, - onRevertRow: onRevertRow, - ), - ), - if (window.trailingWidth > 0) - material.SizedBox( - key: const material.ValueKey('trail'), - width: window.trailingWidth, - ), - ], - ), - ), - ), - ); - } -} - -/// Returns the cell built last time for [column] when it would look the same, -/// so the framework skips it; otherwise remembers and returns [candidate]. -_GridCell _reuseCell( - Map cache, int column, _GridCell candidate) { - final previous = cache[column]; - if (previous != null && previous.sameAs(candidate)) return previous; - cache[column] = candidate; - return candidate; -} - -class _DataRowState extends material.State<_DataRow> { - final Map _cells = {}; - - @override - material.Widget build(material.BuildContext context) { - final w = widget.window; - // Cells that scrolled out of the window are dropped, not kept alive. - _cells.removeWhere((c, _) => c < w.first || c > w.last); - return widget._buildRow(_cells); - } -} - -class _GridCell extends material.StatelessWidget { - const _GridCell({ - super.key, - required this.row, - required this.column, - this.columnName = '', - required this.text, - required this.width, - required this.colorScheme, - required this.striped, - this.rowStatus = StagedRowStatus.unchanged, - this.cellStatus = StagedCellStatus.clean, - this.isSelected = false, - this.isEditing = false, - this.isSelectionTop = false, - this.isSelectionBottom = false, - this.isSelectionLeft = false, - this.isSelectionRight = false, - this.canFilter = false, - this.hasStagingBuffer = false, - this.dataTypeName, - this.onSecondaryTap, - this.onCommitEdit, - this.onCancelEdit, - this.onOpenInspector, - this.onCopyCell, - this.onFilterByValue, - this.onFilterComparison, - this.onSetNull, - this.onSetEmpty, - this.onRevertCell, - this.onDuplicateRow, - this.onToggleDeleteRow, - this.onRevertRow, - }); - - final int row; - final int column; - final String columnName; - final String text; - final double width; - final ColorScheme colorScheme; - final bool striped; - final StagedRowStatus rowStatus; - final StagedCellStatus cellStatus; - final bool isSelected; - final bool isEditing; - final bool isSelectionTop; - final bool isSelectionBottom; - final bool isSelectionLeft; - final bool isSelectionRight; - final bool canFilter; - final bool hasStagingBuffer; - final String? dataTypeName; - final void Function(int row, int col)? onSecondaryTap; - final void Function( - int row, - int col, - String val, { - bool moveNextCol, - bool movePrevCol, - bool moveNextRow, - bool movePrevRow, - })? onCommitEdit; - final material.VoidCallback? onCancelEdit; - final void Function(int row, int col)? onOpenInspector; - final void Function( - int row, - int col, { - bool withHeaders, - bool asJson, - bool asCsv, - })? onCopyCell; - final void Function(int row, int col, {required bool invert})? - onFilterByValue; - final void Function(int row, int col, String operator)? onFilterComparison; - final void Function(int row, int col)? onSetNull; - final void Function(int row, int col)? onSetEmpty; - final void Function(int row, int col)? onRevertCell; - final void Function(int row)? onDuplicateRow; - final void Function(int row)? onToggleDeleteRow; - final void Function(int row)? onRevertRow; - - /// True when [other] would draw exactly the same cell (see [_DataRow.sameAs]): - /// scrolling the column window keeps the cells that stay visible untouched. - bool sameAs(_GridCell other) => - identical(this, other) || - row == other.row && - column == other.column && - columnName == other.columnName && - text == other.text && - width == other.width && - colorScheme == other.colorScheme && - striped == other.striped && - rowStatus == other.rowStatus && - cellStatus == other.cellStatus && - isSelected == other.isSelected && - isEditing == other.isEditing && - isSelectionTop == other.isSelectionTop && - isSelectionBottom == other.isSelectionBottom && - isSelectionLeft == other.isSelectionLeft && - isSelectionRight == other.isSelectionRight && - canFilter == other.canFilter && - hasStagingBuffer == other.hasStagingBuffer && - dataTypeName == other.dataTypeName && - onSecondaryTap == other.onSecondaryTap && - onCommitEdit == other.onCommitEdit && - onCancelEdit == other.onCancelEdit && - onOpenInspector == other.onOpenInspector && - onCopyCell == other.onCopyCell && - onFilterByValue == other.onFilterByValue && - onFilterComparison == other.onFilterComparison && - onSetNull == other.onSetNull && - onSetEmpty == other.onSetEmpty && - onRevertCell == other.onRevertCell && - onDuplicateRow == other.onDuplicateRow && - onToggleDeleteRow == other.onToggleDeleteRow && - onRevertRow == other.onRevertRow; - - List _buildContextMenuItems(material.BuildContext context) { - gridCellContextMenuItemsBuiltCount++; - final preview = text.length > 24 ? '${text.substring(0, 22)}…' : text; - final isNull = text == 'NULL'; - final numVal = num.tryParse(text); - - return [ - MenuButton( - leading: - const material.Icon(material.Icons.content_copy_rounded, size: 16), - trailing: const material.Text('Ctrl+C', - style: material.TextStyle(fontSize: 11)), - onPressed: (_) => onCopyCell?.call(row, column), - child: const material.Text('Copy Value'), - ), - MenuButton( - leading: - const material.Icon(material.Icons.table_chart_outlined, size: 16), - trailing: const material.Text('Ctrl+Shift+C', - style: material.TextStyle(fontSize: 11)), - onPressed: (_) => onCopyCell?.call(row, column, withHeaders: true), - child: const material.Text('Copy with Headers'), - ), - MenuButton( - leading: - const material.Icon(material.Icons.data_object_rounded, size: 16), - onPressed: (_) => onCopyCell?.call(row, column, asJson: true), - child: const material.Text('Copy as JSON'), - ), - MenuButton( - leading: const material.Icon(material.Icons.grid_on_rounded, size: 16), - onPressed: (_) => onCopyCell?.call(row, column, asCsv: true), - child: const material.Text('Copy as CSV'), - ), - if (canFilter && columnName.isNotEmpty) ...[ - const MenuDivider(), - MenuButton( - leading: - const material.Icon(material.Icons.filter_alt_outlined, size: 16), - onPressed: (_) => onFilterByValue?.call(row, column, invert: false), - child: material.Text( - isNull - ? 'Filter by NULL ($columnName IS NULL)' - : 'Filter by Value ($columnName = $preview)', - ), - ), - MenuButton( - leading: const material.Icon(material.Icons.filter_alt_off_outlined, - size: 16), - onPressed: (_) => onFilterByValue?.call(row, column, invert: true), - child: material.Text( - isNull - ? 'Filter Out NULL ($columnName IS NOT NULL)' - : 'Filter Out ($columnName != $preview)', - ), - ), - if (numVal != null) ...[ - MenuButton( - leading: const material.Icon(material.Icons.chevron_right_rounded, - size: 16), - onPressed: (_) => onFilterComparison?.call(row, column, '>'), - child: material.Text('Filter > $text ($columnName > $text)'), - ), - MenuButton( - leading: const material.Icon(material.Icons.chevron_left_rounded, - size: 16), - onPressed: (_) => onFilterComparison?.call(row, column, '<'), - child: material.Text('Filter < $text ($columnName < $text)'), - ), - ], - ], - const MenuDivider(), - MenuButton( - leading: - const material.Icon(material.Icons.visibility_outlined, size: 16), - trailing: const material.Text('Ctrl+I', - style: material.TextStyle(fontSize: 11)), - onPressed: (_) => onOpenInspector?.call(row, column), - child: const material.Text('Inspect Cell Value…'), - ), - if (hasStagingBuffer) ...[ - MenuButton( - leading: const material.Icon( - material.Icons.remove_circle_outline_rounded, - size: 16), - trailing: const material.Text('Alt+N', - style: material.TextStyle(fontSize: 11)), - onPressed: (_) => onSetNull?.call(row, column), - child: const material.Text('Set NULL'), - ), - MenuButton( - leading: const material.Icon(material.Icons.format_clear_rounded, - size: 16), - onPressed: (_) => onSetEmpty?.call(row, column), - child: const material.Text('Set to Empty String'), - ), - if (cellStatus == StagedCellStatus.modified) - MenuButton( - leading: const material.Icon(material.Icons.undo_rounded, size: 16), - onPressed: (_) => onRevertCell?.call(row, column), - child: const material.Text('Revert Cell Changes'), - ), - const MenuDivider(), - MenuButton( - leading: const material.Icon(material.Icons.content_paste_go_rounded, - size: 16), - trailing: const material.Text('Ctrl+D', - style: material.TextStyle(fontSize: 11)), - onPressed: (_) => onDuplicateRow?.call(row), - child: const material.Text('Duplicate Row'), - ), - MenuButton( - leading: material.Icon( - rowStatus == StagedRowStatus.deleted - ? material.Icons.restore_from_trash_rounded - : material.Icons.delete_outline_rounded, - size: 16, - color: rowStatus == StagedRowStatus.deleted - ? null - : colorScheme.destructive, - ), - onPressed: (_) => onToggleDeleteRow?.call(row), - child: material.Text( - rowStatus == StagedRowStatus.deleted - ? 'Restore Deleted Row' - : 'Delete Row', - style: material.TextStyle( - color: rowStatus == StagedRowStatus.deleted - ? null - : colorScheme.destructive, - ), - ), - ), - if (rowStatus == StagedRowStatus.modified || - rowStatus == StagedRowStatus.deleted) - MenuButton( - leading: - const material.Icon(material.Icons.restore_rounded, size: 16), - onPressed: (_) => onRevertRow?.call(row), - child: const material.Text('Revert Row Changes'), - ), - ], - ]; - } - - @override - material.Widget build(material.BuildContext context) { - if (isEditing) { - return GridCellEditor( - initialValue: text, - width: width, - height: double.infinity, - dataTypeName: dataTypeName, - onCommit: (val, - {moveNextCol = false, - movePrevCol = false, - moveNextRow = false, - movePrevRow = false}) { - onCommitEdit?.call( - row, - column, - val, - moveNextCol: moveNextCol, - movePrevCol: movePrevCol, - moveNextRow: moveNextRow, - movePrevRow: movePrevRow, - ); - }, - onCancel: () => onCancelEdit?.call(), - onOpenInspector: () => onOpenInspector?.call(row, column), - ); - } - - final isNull = text == 'NULL'; - final isDeleted = rowStatus == StagedRowStatus.deleted; - final isInserted = rowStatus == StagedRowStatus.inserted; - final isModified = cellStatus == StagedCellStatus.modified; - - var style = material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.normal, - fontFamily: 'monospace', - color: isDeleted - ? colorScheme.destructive.withValues(alpha: 0.7) - : (isNull - ? colorScheme.mutedForeground.withValues(alpha: 0.5) - : colorScheme.foreground), - decoration: isDeleted ? material.TextDecoration.lineThrough : null, - fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, - ); - - var bg = isSelected - ? colorScheme.primary.withValues(alpha: 0.18) - : (isDeleted - ? colorScheme.destructive.withValues(alpha: 0.08) - : (isModified - ? colorScheme.primary.withValues(alpha: 0.14) - : (isInserted - ? colorScheme.primary.withValues(alpha: 0.08) - : (striped - ? colorScheme.muted.withValues(alpha: 0.12) - : material.Colors.transparent)))); - - final cell = material.Container( - width: width, - height: double.infinity, - padding: const material.EdgeInsets.symmetric(horizontal: 10), - alignment: material.Alignment.centerLeft, - decoration: material.BoxDecoration( - color: bg, - border: material.Border( - right: material.BorderSide( - color: isSelectionRight - ? colorScheme.primary - : colorScheme.border.withValues(alpha: 0.3), - width: isSelectionRight ? 1.5 : 1.0, - ), - left: isSelectionLeft - ? material.BorderSide(color: colorScheme.primary, width: 1.5) - : material.BorderSide.none, - top: isSelectionTop - ? material.BorderSide(color: colorScheme.primary, width: 1.5) - : material.BorderSide.none, - bottom: isSelectionBottom - ? material.BorderSide(color: colorScheme.primary, width: 1.5) - : material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.15), - ), - ), - ), - child: material.Stack( - clipBehavior: material.Clip.none, - alignment: material.Alignment.centerLeft, - children: [ - material.Text( - text, - style: style, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - ), - if (isModified) - material.Positioned( - top: -8, - right: -8, - child: material.CustomPaint( - size: const material.Size(6, 6), - painter: _TriangleCornerPainter(color: colorScheme.primary), - ), - ), - ], - ), - ); - - // Tap / double-tap / drag-select are handled by one Listener at the grid - // level (_onGridPointerDown/Move/Up + _cellAtOffset hit-testing) instead - // of a GestureDetector per cell, and the cursor by one grid-level - // MouseRegion instead of one per cell (#983) — both were previously - // among the ~10 widgets/render objects built for every visible cell. - material.Widget content = cell; - final tooltip = ResultGridMetrics.cellTooltipMessage( - text: text, - dataTypeName: dataTypeName, - columnName: columnName, - ); - if (tooltip != null) { - content = material.Tooltip( - message: tooltip, - waitDuration: kQueryaTooltipWait, - child: content, - ); - } - - // The row this cell belongs to carries one semantics node with a label - // for all its visible cells (see _DataRow._semanticsLabel, #981); a - // GestureDetector/Tooltip/Text node per cell on top of that would be - // redundant and is one of the grid's largest per-frame costs. - return material.ExcludeSemantics( - child: material.Listener( - onPointerDown: (event) { - if (event.buttons == kSecondaryMouseButton) { - onSecondaryTap?.call(row, column); - } - }, - child: ContextMenu( - // Lazy: right-clicking a cell is rare relative to how often cells - // get rebuilt (scroll, selection, ...); building the item list - // eagerly on every build was pure waste (#983). - itemsBuilder: _buildContextMenuItems, - child: content, - ), - ), - ); - } -} - -class _TriangleCornerPainter extends material.CustomPainter { - const _TriangleCornerPainter({required this.color}); - final material.Color color; - - @override - void paint(material.Canvas canvas, material.Size size) { - final paint = material.Paint()..color = color; - final path = material.Path() - ..moveTo(0, 0) - ..lineTo(size.width, 0) - ..lineTo(size.width, size.height) - ..close(); - canvas.drawPath(path, paint); - } - @override - bool shouldRepaint(covariant _TriangleCornerPainter oldDelegate) => - oldDelegate.color != color; + material.Widget build(material.BuildContext context) => + _buildGrid(context); } From 6a20ea3372806e61feeffc6f7e78180e4bfcfd45 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 17:44:10 +0300 Subject: [PATCH 2/3] fix(grid): close _buildGrid method in grid_view_builder (#1035) --- lib/features/workspace/grid/grid_view_builder.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/features/workspace/grid/grid_view_builder.dart b/lib/features/workspace/grid/grid_view_builder.dart index 08662637..60b6fc63 100644 --- a/lib/features/workspace/grid/grid_view_builder.dart +++ b/lib/features/workspace/grid/grid_view_builder.dart @@ -456,4 +456,5 @@ extension _GridViewBuilder on _VirtualResultGridState { ), ), ); + } } From aa51c4e9d8292ddb56ed13efec26e432d7a9bcb5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 17:51:32 +0300 Subject: [PATCH 3/3] fix(grid): keep row callbacks stable so unchanged rows and cells are reused (#1035) --- .../workspace/grid/grid_view_builder.dart | 27 +++++++++---------- lib/features/workspace/result_grid_view.dart | 17 ++++++++++++ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/lib/features/workspace/grid/grid_view_builder.dart b/lib/features/workspace/grid/grid_view_builder.dart index 60b6fc63..eb2eeff3 100644 --- a/lib/features/workspace/grid/grid_view_builder.dart +++ b/lib/features/workspace/grid/grid_view_builder.dart @@ -411,20 +411,19 @@ extension _GridViewBuilder on _VirtualResultGridState { : null, canFilter: widget.onFilterRequested != null, - onCellSecondaryTap: _onCellSecondaryTap, - onCommitEdit: _commitEdit, - onCancelEdit: _cancelEdit, - onOpenInspector: _openInspector, - onCopyCell: _handleCopyCell, - onFilterByValue: _handleFilterByValue, - onFilterComparison: - _handleFilterComparison, - onSetNull: _handleSetNull, - onSetEmpty: _handleSetEmpty, - onRevertCell: _handleRevertCell, - onDuplicateRow: _handleDuplicateRow, - onToggleDeleteRow: _handleToggleDeleteRow, - onRevertRow: _handleRevertRow, + onCellSecondaryTap: _cellSecondaryTapCb, + onCommitEdit: _commitEditCb, + onCancelEdit: _cancelEditCb, + onOpenInspector: _openInspectorCb, + onCopyCell: _copyCellCb, + onFilterByValue: _filterByValueCb, + onFilterComparison: _filterComparisonCb, + onSetNull: _setNullCb, + onSetEmpty: _setEmptyCb, + onRevertCell: _revertCellCb, + onDuplicateRow: _duplicateRowCb, + onToggleDeleteRow: _toggleDeleteRowCb, + onRevertRow: _revertRowCb, columnDataTypes: widget.columnDataTypes, ); final previous = _rowWidgets[rowIndex]; diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 7ecda1ff..fa70b5bd 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -98,6 +98,23 @@ class _VirtualResultGridState extends material.State { /// same row (see [_DataRow.sameAs]). final Map _rowWidgets = {}; + // Row callbacks live in extension methods, whose tear-offs are not equal + // across evaluations. They are captured once so [_DataRow.sameAs] can keep + // reusing unchanged rows. + late final _cellSecondaryTapCb = _onCellSecondaryTap; + late final _commitEditCb = _commitEdit; + late final _cancelEditCb = _cancelEdit; + late final _openInspectorCb = _openInspector; + late final _copyCellCb = _handleCopyCell; + late final _filterByValueCb = _handleFilterByValue; + late final _filterComparisonCb = _handleFilterComparison; + late final _setNullCb = _handleSetNull; + late final _setEmptyCb = _handleSetEmpty; + late final _revertCellCb = _handleRevertCell; + late final _duplicateRowCb = _handleDuplicateRow; + late final _toggleDeleteRowCb = _handleToggleDeleteRow; + late final _revertRowCb = _handleRevertRow; + @material.visibleForTesting int get rowWidgetsCacheCount => _rowWidgets.length;