diff --git a/packages/grid/src/GridUtils.test.ts b/packages/grid/src/GridUtils.test.ts index 34e59056e..5b3552853 100644 --- a/packages/grid/src/GridUtils.test.ts +++ b/packages/grid/src/GridUtils.test.ts @@ -1,7 +1,10 @@ +import { TestUtils } from '@deephaven/test-utils'; import { type AxisRange, type BoundedAxisRange } from './GridAxisRange'; import { type ModelIndex, type MoveOperation } from './GridMetrics'; import type GridMetrics from './GridMetrics'; +import type GridModel from './GridModel'; import GridRange, { type GridRangeIndex } from './GridRange'; +import GridTheme from './GridTheme'; import GridUtils, { type Token, type TokenBox } from './GridUtils'; function expectModelIndexes( @@ -1067,3 +1070,145 @@ describe('translateTokenBox', () => { expect(GridUtils.translateTokenBox(input, metrics)).toEqual(expectedValue); }); }); + +describe('getColumnSeparatorIndex', () => { + const mockTheme = { + ...GridTheme, + allowColumnResize: true, + headerSeparatorHandleSize: 10, + columnHeaderHeight: 30, + }; + + const createMockMetrics = ( + columnHeaderMaxDepth = 1 + ): Partial => ({ + rowHeaderWidth: 50, + columnHeaderHeight: 30, + columnHeaderMaxDepth, + floatingColumns: [], + floatingLeftWidth: 0, + visibleColumns: [0, 1, 2, 3], + allColumnXs: new Map([ + [0, 0], + [1, 100], + [2, 200], + [3, 300], + ]), + allColumnWidths: new Map([ + [0, 100], + [1, 100], + [2, 100], + [3, 100], + ]), + modelColumns: new Map([ + [0, 0], + [1, 1], + [2, 2], + [3, 3], + ]), + // Additional properties needed for getRowAtY (called by getColumnHeaderDepthAtY) + gridY: 30, + floatingTopRowCount: 0, + floatingBottomRowCount: 0, + rowCount: 100, + visibleRows: [], + allRowYs: new Map(), + allRowHeights: new Map(), + }); + + /** + * Creates a mock GridModel with grouped column headers for testing + */ + const createMockGroupedGridModel = ( + headerGroups: Map> + ): GridModel => + TestUtils.createMockProxy({ + columnCount: 4, + rowCount: 100, + columnHeaderMaxDepth: headerGroups.size, + textForColumnHeader: (column: ModelIndex, depth = 0) => + headerGroups.get(depth)?.get(column) ?? '', + }); + + const singleLevelHeaderGroups = new Map([ + [ + 0, + new Map([ + [0, 'A'], + [1, 'B'], + [2, 'C'], + [3, 'D'], + ]), + ], + ]); + + const multiLevelHeaderGroups = new Map([ + [ + 0, + new Map([ + [0, 'A'], + [1, 'B'], + [2, 'C'], + [3, 'D'], + ]), + ], + [ + 1, + new Map([ + [0, 'Group1'], + [1, 'Group1'], + [2, 'Group2'], + [3, 'Group2'], + ]), + ], + ]); + + it.each([ + { + description: 'detects separator at column boundary', + x: 150, // At boundary between column 0 and 1 (100 + 50) + y: 15, // Middle of the top header (maxDepth - 1) + headerGroups: singleLevelHeaderGroups, + maxDepth: 1, + expected: 0, + }, + { + description: 'detects there is no separator within the column', + x: 120, // Within column 1 + y: 15, + headerGroups: singleLevelHeaderGroups, + maxDepth: 1, + expected: null, + }, + { + description: + 'should return null at depth 1 when no separator exists (columns in same group)', + x: 150, // Between column 0 and 1 + y: 15, // Middle of the top header (maxDepth - 1) + headerGroups: multiLevelHeaderGroups, + maxDepth: 2, + expected: null, + }, + { + description: 'should detect separator at depth 1 when groups differ', + x: 250, // Between Group1 and Group2 + y: 15, + headerGroups: multiLevelHeaderGroups, + maxDepth: 2, + expected: 1, + }, + ])('$description', ({ x, y, headerGroups, maxDepth, expected }) => { + const metrics = createMockMetrics(maxDepth) as GridMetrics; + const model = createMockGroupedGridModel(headerGroups); + + const result = GridUtils.getColumnSeparatorIndex( + x, + y, + metrics, + mockTheme, + model + ); + + expect(result).toBe(expected); + }); +}); diff --git a/packages/grid/src/GridUtils.ts b/packages/grid/src/GridUtils.ts index 86038e9ff..499065cea 100644 --- a/packages/grid/src/GridUtils.ts +++ b/packages/grid/src/GridUtils.ts @@ -25,6 +25,7 @@ import { } from './GridAxisRange'; import { isExpandableGridModel } from './ExpandableGridModel'; import { type GridRenderState } from './GridRendererTypes'; +import type GridModel from './GridModel'; export type GridPoint = { x: Coordinate; @@ -453,19 +454,53 @@ export class GridUtils { ); } + /** + * Check if a separator exists between a column and the next column at a given depth. + * A separator exists if adjacent columns have different header text at the specified depth. + * + * @param model The grid model + * @param depth The header depth to check at + * @param columnIndex The current model column index + * @param nextColumnIndex The next model column index (undefined for last column) + * @returns true if a separator should be shown, false otherwise + */ + static hasColumnSeparatorAtDepth( + model: GridModel, + depth: number | undefined, + columnIndex: ModelIndex | undefined, + nextColumnIndex: ModelIndex | undefined + ): boolean { + if (depth == null || columnIndex == null) { + return false; + } + + // Always show separator for the last column + if (nextColumnIndex == null) { + return true; + } + + // A separator exists if adjacent columns have different header text at this depth + return ( + model.textForColumnHeader(columnIndex, depth) !== + model.textForColumnHeader(nextColumnIndex, depth) + ); + } + /** * Gets the column index if the x/y coordinates provided are close enough to the separator, otherwise null * @param x Mouse x coordinate * @param y Mouse y coordinate * @param metrics The grid metrics * @param theme The grid theme with potential user overrides + * @param model The grid model * @returns Index of the column separator at the coordinates provided, or null if none match */ static getColumnSeparatorIndex( x: Coordinate, y: Coordinate, metrics: GridMetrics, - theme: GridTheme + theme: GridTheme, + model: GridModel ): VisibleIndex | null { const { rowHeaderWidth, @@ -476,6 +511,7 @@ export class GridUtils { allColumnXs, allColumnWidths, columnHeaderMaxDepth, + modelColumns, } = metrics; const { allowColumnResize, headerSeparatorHandleSize } = theme; @@ -489,6 +525,7 @@ export class GridUtils { const gridX = x - rowHeaderWidth; const halfSeparatorSize = headerSeparatorHandleSize * 0.5; + const depth = GridUtils.getColumnHeaderDepthAtY(y, metrics); // Iterate through the floating columns first since they're on top let isPreviousColumnHidden = false; @@ -507,7 +544,16 @@ export class GridUtils { const minX = midX - halfSeparatorSize; const maxX = midX + halfSeparatorSize; - if (minX <= gridX && gridX <= maxX) { + if ( + minX <= gridX && + gridX <= maxX && + GridUtils.hasColumnSeparatorAtDepth( + model, + depth, + modelColumns.get(column), + modelColumns.get(column + 1) + ) + ) { return column; } @@ -538,7 +584,16 @@ export class GridUtils { const minX = midX - halfSeparatorSize; const maxX = midX + halfSeparatorSize; - if (minX <= gridX && gridX <= maxX) { + if ( + minX <= gridX && + gridX <= maxX && + GridUtils.hasColumnSeparatorAtDepth( + model, + depth, + modelColumns.get(column), + modelColumns.get(column + 1) + ) + ) { return column; } diff --git a/packages/grid/src/mouse-handlers/GridColumnSeparatorMouseHandler.ts b/packages/grid/src/mouse-handlers/GridColumnSeparatorMouseHandler.ts index 149c2bcce..7ec20ffe7 100644 --- a/packages/grid/src/mouse-handlers/GridColumnSeparatorMouseHandler.ts +++ b/packages/grid/src/mouse-handlers/GridColumnSeparatorMouseHandler.ts @@ -28,7 +28,8 @@ class GridColumnSeparatorMouseHandler extends GridSeparatorMouseHandler { x, y, metrics, - theme + theme, + model ); if (separatorIndex == null || depth == null) {