From 54a2003bc35b98bdd96074ffbe43ac3d0d9d48fd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 16:41:47 +0300 Subject: [PATCH] perf(grid): hoist secondary click (context menu) and tooltip handling from _GridCell to grid level (#1346) --- .../workspace/grid/grid_cell_actions.dart | 170 +++++++++++++ .../workspace/grid/grid_cell_renderer.dart | 239 +----------------- .../grid/grid_pointer_selection.dart | 16 +- .../workspace/grid/grid_row_renderer.dart | 52 ---- .../workspace/grid/grid_view_builder.dart | 15 +- .../workspace/grid/grid_view_layout.dart | 1 + lib/features/workspace/result_grid_view.dart | 151 ++++++++++- .../workspace/result_grid_cell_ux_test.dart | 78 ++++++ .../lib/src/components/menu/context_menu.dart | 59 ++++- 9 files changed, 462 insertions(+), 319 deletions(-) diff --git a/lib/features/workspace/grid/grid_cell_actions.dart b/lib/features/workspace/grid/grid_cell_actions.dart index b84f1b70..6dae9a29 100644 --- a/lib/features/workspace/grid/grid_cell_actions.dart +++ b/lib/features/workspace/grid/grid_cell_actions.dart @@ -93,4 +93,174 @@ extension _GridCellActions on _VirtualResultGridState { widget.stagingBuffer!.revertRow(_toModelRowIndex(row)); } + void _openCellContextMenu(int row, int col, material.Offset globalPosition) { + showContextMenuBuilder( + context: context, + position: globalPosition, + itemsBuilder: (ctx) => _buildCellContextMenuItems(ctx, row, col), + ); + } + + List _buildCellContextMenuItems( + material.BuildContext context, + int row, + int column, + ) { + gridCellContextMenuItemsBuiltCount++; + final rowData = row < _sortedRows.length ? _sortedRows[row] : null; + final text = (rowData != null && column < rowData.length) + ? rowData[column] + : ''; + final columnName = + column < widget.columns.length ? widget.columns[column] : ''; + final preview = text.length > 24 ? '${text.substring(0, 22)}…' : text; + final isNull = text == 'NULL'; + final numVal = num.tryParse(text); + final canFilter = widget.onFilterRequested != null; + final hasStagingBuffer = widget.stagingBuffer != null; + final modelRow = _toModelRowIndex(row); + final rowStatus = widget.stagingBuffer?.getRowStatus(modelRow) ?? + StagedRowStatus.unchanged; + final cellStatus = widget.stagingBuffer?.getCellStatus(modelRow, column) ?? + StagedCellStatus.clean; + final cs = Theme.of(context).colorScheme; + + 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: (_) => _handleCopyCell(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: (_) => _handleCopyCell(row, column, withHeaders: true), + child: const material.Text('Copy with Headers'), + ), + MenuButton( + leading: + const material.Icon(material.Icons.data_object_rounded, size: 16), + onPressed: (_) => _handleCopyCell(row, column, asJson: true), + child: const material.Text('Copy as JSON'), + ), + MenuButton( + leading: const material.Icon(material.Icons.grid_on_rounded, size: 16), + onPressed: (_) => _handleCopyCell(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: (_) => _handleFilterByValue(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: (_) => _handleFilterByValue(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: (_) => _handleFilterComparison(row, column, '>'), + child: material.Text('Filter > $text ($columnName > $text)'), + ), + MenuButton( + leading: const material.Icon(material.Icons.chevron_left_rounded, + size: 16), + onPressed: (_) => _handleFilterComparison(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: (_) => _openInspector(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: (_) => _handleSetNull(row, column), + child: const material.Text('Set NULL'), + ), + MenuButton( + leading: const material.Icon(material.Icons.format_clear_rounded, + size: 16), + onPressed: (_) => _handleSetEmpty(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: (_) => _handleRevertCell(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: (_) => _handleDuplicateRow(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 + : cs.destructive, + ), + onPressed: (_) => _handleToggleDeleteRow(row), + child: material.Text( + rowStatus == StagedRowStatus.deleted + ? 'Restore Deleted Row' + : 'Delete Row', + style: material.TextStyle( + color: rowStatus == StagedRowStatus.deleted + ? null + : cs.destructive, + ), + ), + ), + if (rowStatus == StagedRowStatus.modified || + rowStatus == StagedRowStatus.deleted) + MenuButton( + leading: + const material.Icon(material.Icons.restore_rounded, size: 16), + onPressed: (_) => _handleRevertRow(row), + child: const material.Text('Revert Row Changes'), + ), + ], + ]; + } } diff --git a/lib/features/workspace/grid/grid_cell_renderer.dart b/lib/features/workspace/grid/grid_cell_renderer.dart index 6997a797..2dfb4770 100644 --- a/lib/features/workspace/grid/grid_cell_renderer.dart +++ b/lib/features/workspace/grid/grid_cell_renderer.dart @@ -18,22 +18,10 @@ class _GridCell extends material.StatelessWidget { 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; @@ -51,10 +39,7 @@ class _GridCell extends material.StatelessWidget { 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, @@ -66,22 +51,6 @@ class _GridCell extends material.StatelessWidget { })? 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. @@ -102,167 +71,10 @@ class _GridCell extends material.StatelessWidget { 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'), - ), - ], - ]; - } + onOpenInspector == other.onOpenInspector; @override material.Widget build(material.BuildContext context) { @@ -297,7 +109,7 @@ class _GridCell extends material.StatelessWidget { final isInserted = rowStatus == StagedRowStatus.inserted; final isModified = cellStatus == StagedCellStatus.modified; - var style = material.TextStyle( + final style = material.TextStyle( fontSize: 12, fontWeight: material.FontWeight.normal, fontFamily: 'monospace', @@ -310,7 +122,7 @@ class _GridCell extends material.StatelessWidget { fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, ); - var bg = isSelected + final bg = isSelected ? colorScheme.primary.withValues(alpha: 0.18) : (isDeleted ? colorScheme.destructive.withValues(alpha: 0.08) @@ -322,7 +134,10 @@ class _GridCell extends material.StatelessWidget { ? colorScheme.muted.withValues(alpha: 0.12) : material.Colors.transparent)))); - final cell = material.Container( + // Primary/secondary gestures and hover tooltips are hoisted to the grid + // level (#983, #1346). _GridCell is now a pure lightweight leaf widget + // without per-cell ContextMenu, Listener, or Tooltip. + return material.Container( width: width, height: double.infinity, padding: const material.EdgeInsets.symmetric(horizontal: 10), @@ -371,46 +186,6 @@ class _GridCell extends material.StatelessWidget { ], ), ); - - // 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, - ), - ), - ); } } diff --git a/lib/features/workspace/grid/grid_pointer_selection.dart b/lib/features/workspace/grid/grid_pointer_selection.dart index a98b4668..1fdba943 100644 --- a/lib/features/workspace/grid/grid_pointer_selection.dart +++ b/lib/features/workspace/grid/grid_pointer_selection.dart @@ -87,9 +87,23 @@ extension _GridPointerSelection on _VirtualResultGridState { PointerDownEvent event, { required double rowHeight, }) { - if (event.buttons != kPrimaryMouseButton) return; + _hideCellTooltip(); if (_sortedRows.isEmpty || widget.columns.isEmpty) return; + if (event.buttons == kSecondaryMouseButton) { + final cell = _cellAtOffset( + localPosition: event.localPosition, + rowHeight: rowHeight, + ); + if (cell != null) { + _onCellSecondaryTap(cell.row, cell.column); + _openCellContextMenu(cell.row, cell.column, event.position); + } + return; + } + + if (event.buttons != kPrimaryMouseButton) return; + if (_editingCell != null) { final downCell = _cellAtOffset( localPosition: event.localPosition, diff --git a/lib/features/workspace/grid/grid_row_renderer.dart b/lib/features/workspace/grid/grid_row_renderer.dart index df5ba19c..9a711d69 100644 --- a/lib/features/workspace/grid/grid_row_renderer.dart +++ b/lib/features/workspace/grid/grid_row_renderer.dart @@ -16,20 +16,9 @@ class _DataRow extends material.StatefulWidget { 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, this.piiKinds, }); @@ -50,8 +39,6 @@ class _DataRow extends material.StatefulWidget { /// 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, @@ -63,22 +50,6 @@ class _DataRow extends material.StatefulWidget { })? 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; /// Per-column PII kind while "Mask sensitive data" is on (null when off). @@ -104,20 +75,9 @@ class _DataRow extends material.StatefulWidget { 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) && identical(piiKinds, other.piiKinds); @@ -195,23 +155,11 @@ class _DataRow extends material.StatefulWidget { 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) diff --git a/lib/features/workspace/grid/grid_view_builder.dart b/lib/features/workspace/grid/grid_view_builder.dart index 79c03cd7..e720a3b9 100644 --- a/lib/features/workspace/grid/grid_view_builder.dart +++ b/lib/features/workspace/grid/grid_view_builder.dart @@ -375,6 +375,9 @@ extension _GridViewBuilder on _VirtualResultGridState { cursor: widget.stagingBuffer != null ? material.SystemMouseCursors.text : material.SystemMouseCursors.basic, + onHover: (e) => + _onGridPointerHover(e, rowHeight: rowHeight), + onExit: (_) => _onGridPointerExit(), child: material.Listener( behavior: material.HitTestBehavior.translucent, onPointerDown: (e) => @@ -411,21 +414,9 @@ extension _GridViewBuilder on _VirtualResultGridState { editingCell: _editingCell?.row == rowIndex ? _editingCell : null, - canFilter: - widget.onFilterRequested != null, - 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, piiKinds: piiKinds, ); diff --git a/lib/features/workspace/grid/grid_view_layout.dart b/lib/features/workspace/grid/grid_view_layout.dart index a93ac688..8b662f82 100644 --- a/lib/features/workspace/grid/grid_view_layout.dart +++ b/lib/features/workspace/grid/grid_view_layout.dart @@ -8,6 +8,7 @@ extension _GridViewLayout on _VirtualResultGridState { /// 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() { + _hideCellTooltip(); if (!_horizontalController.hasClients) return; final offset = _horizontalController.offset; if ((offset - _scrollOffset).abs() < 0.5) return; diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index baa635d5..581b0d17 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -3,7 +3,11 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart' as foundation; import 'package:flutter/gestures.dart' - show kDoubleTapTimeout, kPrimaryMouseButton, kSecondaryMouseButton; + show + kDoubleTapTimeout, + kPrimaryMouseButton, + kSecondaryMouseButton, + PointerHoverEvent; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show Clipboard, ClipboardData, HardwareKeyboard, LogicalKeyboardKey; @@ -106,22 +110,16 @@ class _VirtualResultGridState extends material.State { List? _piiKindsSource; List? _piiKinds; - // Row callbacks live in extension methods, whose tear-offs are not equal + // Edit 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; + + Timer? _cellTooltipTimer; + ResultGridCellCoordinate? _cellTooltipCoord; + material.OverlayEntry? _cellTooltipOverlayEntry; @material.visibleForTesting int get rowWidgetsCacheCount => _rowWidgets.length; @@ -165,6 +163,7 @@ class _VirtualResultGridState extends material.State { void initState() { super.initState(); _horizontalController.addListener(_onHorizontalScroll); + _verticalController.addListener(_onVerticalScroll); PiiMaskingController.instance.addListener(_onPiiMaskingChanged); widget.stagingBuffer?.addListener(_onStagingBufferChanged); _lastKnownStagedRowCount = _baseRows.length; @@ -260,8 +259,135 @@ class _VirtualResultGridState extends material.State { } } + void _onVerticalScroll() { + _hideCellTooltip(); + } + + void _onGridPointerHover( + PointerHoverEvent event, { + required double rowHeight, + }) { + if (_sortedRows.isEmpty || widget.columns.isEmpty) { + _hideCellTooltip(); + return; + } + + final cell = _cellAtOffset( + localPosition: event.localPosition, + rowHeight: rowHeight, + ); + if (cell == null) { + _hideCellTooltip(); + return; + } + + if (cell == _cellTooltipCoord) { + return; + } + + _hideCellTooltip(); + _cellTooltipCoord = cell; + + final rowData = + cell.row < _sortedRows.length ? _sortedRows[cell.row] : null; + final text = (rowData != null && cell.column < rowData.length) + ? _maskCellText(_piiKinds, cell.column, rowData[cell.column]) + : ''; + final colName = + cell.column < widget.columns.length ? widget.columns[cell.column] : ''; + final dataTypeName = widget.columnDataTypes?[colName]; + + final tooltipMsg = ResultGridMetrics.cellTooltipMessage( + text: text, + dataTypeName: dataTypeName, + columnName: colName, + ); + if (tooltipMsg == null) return; + + _cellTooltipTimer = Timer(kQueryaTooltipWait, () { + if (!mounted || _cellTooltipCoord != cell) return; + _showCellTooltip(event.position, tooltipMsg); + }); + } + + void _onGridPointerExit() { + _hideCellTooltip(); + } + + void _showCellTooltip(Offset globalPosition, String message) { + _hideCellTooltip(preserveCoord: true); + final overlay = material.Overlay.maybeOf(context); + if (overlay == null || !mounted) return; + + final theme = Theme.of(context); + final cs = theme.colorScheme; + final media = material.MediaQuery.maybeOf(context); + final screenW = media?.size.width ?? 1920.0; + final screenH = media?.size.height ?? 1080.0; + + final left = (globalPosition.dx + 10.0).clamp(8.0, screenW - 320.0); + final top = (globalPosition.dy + 18.0).clamp(8.0, screenH - 120.0); + + _cellTooltipOverlayEntry = material.OverlayEntry( + builder: (context) { + return material.Positioned( + left: left, + top: top, + child: material.IgnorePointer( + child: material.Material( + color: material.Colors.transparent, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 5, + ), + decoration: material.BoxDecoration( + color: cs.popover, + borderRadius: BorderRadius.circular(theme.radiusSm), + border: material.Border.all( + color: cs.border, + width: 0.8, + ), + boxShadow: const [ + material.BoxShadow( + color: material.Color(0x33000000), + blurRadius: 6, + offset: material.Offset(0, 2), + ), + ], + ), + constraints: const material.BoxConstraints(maxWidth: 360), + child: material.Text( + message, + style: material.TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: cs.popoverForeground, + ), + ), + ), + ), + ), + ); + }, + ); + overlay.insert(_cellTooltipOverlayEntry!); + } + + void _hideCellTooltip({bool preserveCoord = false}) { + _cellTooltipTimer?.cancel(); + _cellTooltipTimer = null; + if (!preserveCoord) { + _cellTooltipCoord = null; + } + _cellTooltipOverlayEntry?.remove(); + _cellTooltipOverlayEntry?.dispose(); + _cellTooltipOverlayEntry = null; + } + @override void dispose() { + _hideCellTooltip(); _selectionValuesDebounce?.cancel(); PiiMaskingController.instance.removeListener(_onPiiMaskingChanged); _stopAutoScroll(); @@ -269,6 +395,7 @@ class _VirtualResultGridState extends material.State { widget.stagingBuffer?.removeListener(_onStagingBufferChanged); _horizontalController.removeListener(_onHorizontalScroll); _horizontalController.dispose(); + _verticalController.removeListener(_onVerticalScroll); _verticalController.dispose(); _focusNode.dispose(); _sortedRows = const []; diff --git a/test/features/workspace/result_grid_cell_ux_test.dart b/test/features/workspace/result_grid_cell_ux_test.dart index 6b69a416..b57e9f97 100644 --- a/test/features/workspace/result_grid_cell_ux_test.dart +++ b/test/features/workspace/result_grid_cell_ux_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.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/result_grid_view.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../support/querya_theme_test_shell.dart'; @@ -216,4 +217,81 @@ void main() { expect(find.byType(GridCellEditor), findsNothing); }); }); + + group('VirtualResultGrid hoisted context menu and tooltips (#1346)', () { + testWidgets('_GridCell does not mount per-cell ContextMenu or Tooltip widgets', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: ['id', 'bio'], + rows: [ + ['1', 'Very long bio text that exceeds standard display bounds'], + ['2', 'Another long bio description value for testing'], + ], + columnDataTypes: {'id': 'integer', 'bio': 'text'}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Ensure no per-cell ContextMenu or Tooltip widgets exist inside row cells. + expect( + find.descendant( + of: find.byKey(const material.ValueKey('result-row-0')), + matching: find.byType(material.Tooltip), + ), + findsNothing, + ); + expect( + find.descendant( + of: find.byKey(const material.ValueKey('result-row-0')), + matching: find.byType(ContextMenu), + ), + findsNothing, + ); + }); + + testWidgets('hovering over cell shows tooltip overlay and mouse exit hides it', + (tester) async { + const longBio = 'Very long bio text that exceeds standard display bounds'; + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: ['id', 'bio'], + rows: [ + ['1', longBio], + ], + columnDataTypes: {'id': 'integer', 'bio': 'text'}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + + // Move mouse to cell with long text and schema metadata + await gesture.moveTo(tester.getCenter(find.text(longBio))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Tooltip is shown in overlay + expect(find.text('bio · text\n$longBio'), findsOneWidget); + + // Moving mouse away hides the tooltip + await gesture.moveTo(const material.Offset(10, 10)); + await tester.pump(); + + expect(find.text('bio · text\n$longBio'), findsNothing); + }); + }); } diff --git a/third_party/shadcn_flutter/lib/src/components/menu/context_menu.dart b/third_party/shadcn_flutter/lib/src/components/menu/context_menu.dart index 1da7097a..06f461ef 100644 --- a/third_party/shadcn_flutter/lib/src/components/menu/context_menu.dart +++ b/third_party/shadcn_flutter/lib/src/components/menu/context_menu.dart @@ -631,13 +631,21 @@ class _ContextMenuState extends State { onSecondaryTapDown: !widget.enabled ? null : (details) { - _showContextMenu(context, details.globalPosition, - _resolveChildren(context), widget.direction); + showContextMenuListenable( + context: context, + position: details.globalPosition, + children: _resolveChildren(context), + direction: widget.direction, + ); }, onLongPressStart: enableLongPress && widget.enabled ? (details) { - _showContextMenu(context, details.globalPosition, - _resolveChildren(context), widget.direction); + showContextMenuListenable( + context: context, + position: details.globalPosition, + children: _resolveChildren(context), + direction: widget.direction, + ); } : null, child: widget.child, @@ -645,12 +653,43 @@ class _ContextMenuState extends State { } } -Future _showContextMenu( - BuildContext context, - Offset position, - ValueListenable> children, - Axis direction, -) async { +/// Shows a context menu at [position] with the given [items]. +Future showContextMenu({ + required BuildContext context, + required Offset position, + required List items, + Axis direction = Axis.vertical, +}) { + return showContextMenuListenable( + context: context, + position: position, + children: ValueNotifier(items), + direction: direction, + ); +} + +/// Shows a context menu at [position] with lazily evaluated [itemsBuilder]. +Future showContextMenuBuilder({ + required BuildContext context, + required Offset position, + required List Function(BuildContext context) itemsBuilder, + Axis direction = Axis.vertical, +}) { + return showContextMenuListenable( + context: context, + position: position, + children: ValueNotifier(itemsBuilder(context)), + direction: direction, + ); +} + +/// Shows a context menu at [position] with [children] listenable. +Future showContextMenuListenable({ + required BuildContext context, + required Offset position, + required ValueListenable> children, + Axis direction = Axis.vertical, +}) async { final key = GlobalKey(); final theme = Theme.of(context); final overlayManager = OverlayManager.of(context);