From 35fc599c1442574599dc0ecb4a3c22e0885d5a9b Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 18 Feb 2025 13:07:56 -0500 Subject: [PATCH] feat: Resize columns option in web UI (#2358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following below draft spec by @dsmmcken - **Resize Column**: If the column is currently manually sized, switch it to auto-resize, and reset the max column width seen to only what's in the current viewport (NOT the max ever seen, but the actual max visible in current viewport). If the column is currently auto sized, set the column to manual and size to max column width seen in the current viewport. - The assumption that can be made is that whatever the current behaviour is, it’s not what the users wants, so no need to require the user to make a conscious choice between auto and resize, just present one option to resize, and do the opposite of what we are doing currently. - **Resize All Columns**: Same behaviour as above but apply across all columns, and treat like an indeterminate checkbox. If any are manual, resize all as above, but flip all to manual. If all are manual, resize and flip all to auto. - We should try this and see if it feels natural. Fixed an issue where double clicking the column separator in the header bar resizes the column without changing the column to auto resize. - There is now a discrepancy between double clicking the column separator in the header bar and clicking "Resize Column", as the former auto-resizes to the largest cached width, whilst the latter resizes to the width of content in the current viewport. - I assume double clicking the column separator follows the behaviour of Excel so this discrepancy is intended. Below is a useful snippet for testing, that creates a table that has two columns that continuously gets bigger ``` from deephaven import time_table tt = time_table("PT0.5s").update(["x=`a`.repeat(i)", "y=`b`.repeat(i)"]) ``` Closes #1486 --- packages/grid/src/GridMetricCalculator.ts | 58 +++++++- packages/grid/src/GridMetrics.ts | 4 + .../GridSeparatorMouseHandler.ts | 11 +- packages/iris-grid/src/IrisGrid.test.tsx | 137 ++++++++++++++++++ packages/iris-grid/src/IrisGrid.tsx | 53 +++++++ .../iris-grid/src/IrisGridMetricCalculator.ts | 4 + .../IrisGridContextMenuHandler.tsx | 18 ++- 7 files changed, 270 insertions(+), 15 deletions(-) diff --git a/packages/grid/src/GridMetricCalculator.ts b/packages/grid/src/GridMetricCalculator.ts index b007f11754..abe85f9e06 100644 --- a/packages/grid/src/GridMetricCalculator.ts +++ b/packages/grid/src/GridMetricCalculator.ts @@ -130,12 +130,18 @@ export class GridMetricCalculator { /** User set row heights */ protected userRowHeights: ModelSizeMap; - /** Calculated column widths based on cell contents */ + /** Calculated column widths based on cell contents and caching largest value */ protected calculatedColumnWidths: ModelSizeMap; - /** Calculated row heights based on cell contents */ + /** Calculated row heights based on cell contents and caching largest value */ protected calculatedRowHeights: ModelSizeMap; + /** Calculated column widths based on cell contents */ + protected contentColumnWidths: ModelSizeMap; + + /** Calculated row heights based on cell contents */ + protected contentRowHeights: ModelSizeMap; + /** Cache of fonts to estimated width of the smallest char */ protected fontWidthsLower: Map; @@ -162,6 +168,8 @@ export class GridMetricCalculator { userRowHeights = new Map(), calculatedColumnWidths = new Map(), calculatedRowHeights = new Map(), + contentColumnWidths = new Map(), + contentRowHeights = new Map(), fontWidthsLower = new Map(), fontWidthsUpper = new Map(), allCharWidths = new Map(), @@ -178,6 +186,8 @@ export class GridMetricCalculator { this.calculatedColumnWidths = calculatedColumnWidths; this.allCharWidths = allCharWidths; this.fontWidthsLower = fontWidthsLower; + this.contentColumnWidths = contentColumnWidths; + this.contentRowHeights = contentRowHeights; this.fontWidthsUpper = fontWidthsUpper; // Need to track the last moved rows/columns array so we know if we need to reset our models cache @@ -518,6 +528,8 @@ export class GridMetricCalculator { userRowHeights, calculatedRowHeights, calculatedColumnWidths, + contentColumnWidths, + contentRowHeights, } = this; return { @@ -608,6 +620,10 @@ export class GridMetricCalculator { visibleRowHeights, visibleColumnWidths, + // Map of the height/width of visible rows/columns without caching largest value + contentColumnWidths, + contentRowHeights, + // Array of floating rows/columns, by grid index floatingRows, floatingColumns, @@ -1647,8 +1663,12 @@ export class GridMetricCalculator { return rowHeight; } + // Not sure how to accurately get the height of text. For now just return the theme height. + this.contentRowHeights.set(modelRow, Math.ceil(rowHeight)); + trimMap(this.contentRowHeights); + const cachedValue = this.calculatedRowHeights.get(modelRow); - if (cachedValue != null) { + if (cachedValue != null && cachedValue > rowHeight) { return cachedValue; } @@ -1695,6 +1715,9 @@ export class GridMetricCalculator { let columnWidth = Math.ceil(Math.max(headerWidth, dataWidth)); columnWidth = Math.max(minColumnWidth, columnWidth); columnWidth = Math.min(maxColumnWidth, columnWidth); + this.contentColumnWidths.set(modelColumn, columnWidth); + trimMap(this.contentColumnWidths); + if (cachedValue != null && cachedValue > columnWidth) { columnWidth = cachedValue; } else { @@ -1768,7 +1791,8 @@ export class GridMetricCalculator { let columnWidth = 0; - const rowsPerPage = height / rowHeight; + const gridY = this.getGridY(state); + const rowsPerPage = Math.floor((height - gridY) / rowHeight); const bottom = Math.ceil(top + rowsPerPage); const cellPadding = cellHorizontalPadding * 2; GridUtils.iterateAllItems( @@ -1941,6 +1965,19 @@ export class GridMetricCalculator { this.userColumnWidths = userColumnWidths; } + /** + * Sets the calculated width for the specified column + * @param column The column model index to set + * @param size The size to set it to + */ + setCalculatedColumnWidth(column: ModelIndex, size: number): void { + // Always use a new instance of the map so any consumer of the metrics knows there has been a change + const calculatedColumnWidths = new Map(this.calculatedColumnWidths); + calculatedColumnWidths.set(column, Math.ceil(size)); + trimMap(calculatedColumnWidths); + this.calculatedColumnWidths = calculatedColumnWidths; + } + /** * Resets all the calculated column widths * Useful if the theme minimum column width changes @@ -1974,6 +2011,19 @@ export class GridMetricCalculator { this.calculatedRowHeights.delete(row); } + /** + * Sets the calculated height for the specified row + * @param row The column model index to set + * @param size The size to set it to + */ + setCalculatedRowHeight(row: ModelIndex, size: number): void { + // Always use a new instance of the map so any consumer of the metrics knows there has been a change + const calculatedRowHeights = new Map(this.calculatedRowHeights); + calculatedRowHeights.set(row, Math.ceil(size)); + trimMap(calculatedRowHeights); + this.calculatedColumnWidths = calculatedRowHeights; + } + /** * Resets all the calculated row heights * Useful if the theme row height changes diff --git a/packages/grid/src/GridMetrics.ts b/packages/grid/src/GridMetrics.ts index 03b35c0908..a32c3ce89b 100644 --- a/packages/grid/src/GridMetrics.ts +++ b/packages/grid/src/GridMetrics.ts @@ -173,6 +173,10 @@ export type GridMetrics = { calculatedRowHeights: ModelSizeMap; calculatedColumnWidths: ModelSizeMap; + // Map of calculated row/column height/width without caching largest value + contentColumnWidths: ModelSizeMap; + contentRowHeights: ModelSizeMap; + // Max depth of column headers. Depth of 1 for a table without column groups columnHeaderMaxDepth: number; }; diff --git a/packages/grid/src/mouse-handlers/GridSeparatorMouseHandler.ts b/packages/grid/src/mouse-handlers/GridSeparatorMouseHandler.ts index d27e0b03ff..20a3426e7d 100644 --- a/packages/grid/src/mouse-handlers/GridSeparatorMouseHandler.ts +++ b/packages/grid/src/mouse-handlers/GridSeparatorMouseHandler.ts @@ -277,16 +277,7 @@ abstract class GridSeparatorMouseHandler extends GridMouseHandler { const modelIndexes = metrics[this.modelIndexesProperty]; const modelIndex = getOrThrow(modelIndexes, separator.index); - const calculatedSize = - metrics[this.calculatedSizesProperty].get(modelIndex); - const defaultSize = - metricCalculator[this.initialSizesProperty].get(modelIndex); - - if (calculatedSize === defaultSize || calculatedSize == null) { - this.resetSize(metricCalculator, modelIndex); - } else { - this.setSize(metricCalculator, modelIndex, calculatedSize); - } + this.resetSize(metricCalculator, modelIndex); grid.forceUpdate(); diff --git a/packages/iris-grid/src/IrisGrid.test.tsx b/packages/iris-grid/src/IrisGrid.test.tsx index f6709a5acd..7e887507f6 100644 --- a/packages/iris-grid/src/IrisGrid.test.tsx +++ b/packages/iris-grid/src/IrisGrid.test.tsx @@ -222,3 +222,140 @@ it('should set gotoValueSelectedColumnName to empty string if no columns are giv expect(component.state.gotoValueSelectedColumnName).toEqual(''); }); + +describe('handleResizeColumn', () => { + let irisGrid; + let metricCalculator; + + beforeAll(() => { + irisGrid = makeComponent( + irisGridTestUtils.makeModel( + irisGridTestUtils.makeTable({ + columns: irisGridTestUtils.makeColumns(1), + }) + ) + ); + metricCalculator = irisGrid.state.metricCalculator; + }); + + it('should set column width to content width if undefined user width', async () => { + const modelIndex = 0; + const mockMetricCalculator = { + ...metricCalculator, + userColumnWidths: new Map(), + setColumnWidth: jest.fn((column, size) => { + mockMetricCalculator.userColumnWidths.set(column, size); + }), + }; + Object.assign(irisGrid.state.metricCalculator, mockMetricCalculator); + const contentWidth = + irisGrid.state.metrics.contentColumnWidths.get(modelIndex); + expect(contentWidth).toBeDefined(); + + irisGrid.handleResizeColumn(modelIndex); + + expect(mockMetricCalculator.userColumnWidths.get(modelIndex)).toEqual( + contentWidth + ); + }); + + it('should reset user width & set calculated width to content width if column has defined user width', () => { + const modelIndex = 0; + const mockMetricCalculator = { + ...metricCalculator, + userColumnWidths: new Map([[modelIndex, 100]]), + setCalculatedColumnWidth: jest.fn((column, size) => { + mockMetricCalculator.calculatedColumnWidths.set(column, size); + }), + resetColumnWidth: jest.fn(() => { + mockMetricCalculator.userColumnWidths.delete(modelIndex); + }), + }; + Object.assign(irisGrid.state.metricCalculator, mockMetricCalculator); + const contentWidth = + irisGrid.state.metrics.contentColumnWidths.get(modelIndex); + expect(contentWidth).toBeDefined(); + + irisGrid.handleResizeColumn(modelIndex); + + expect( + mockMetricCalculator.userColumnWidths.get(modelIndex) + ).toBeUndefined(); + expect(mockMetricCalculator.calculatedColumnWidths.get(modelIndex)).toEqual( + contentWidth + ); + }); +}); + +// auto resize -> reset user width and set calculated width to content width +// manual resize -> set user width to content width +describe('handleResizeAllColumns', () => { + let irisGrid; + let metricCalculator; + + beforeAll(() => { + irisGrid = makeComponent( + irisGridTestUtils.makeModel( + irisGridTestUtils.makeTable({ + columns: irisGridTestUtils.makeColumns(3), + }) + ) + ); + metricCalculator = irisGrid.state.metricCalculator; + }); + + it('should auto resize all columns if all were manually sized', () => { + const mockMetricCalculator = { + ...metricCalculator, + userColumnWidths: new Map([ + [0, 100], + [1, 100], + [2, 100], + ]), + setCalculatedColumnWidth: jest.fn((column, size) => { + mockMetricCalculator.calculatedColumnWidths.set(column, size); + }), + resetColumnWidth: jest.fn(column => { + mockMetricCalculator.userColumnWidths.delete(column); + }), + }; + Object.assign(irisGrid.state.metricCalculator, mockMetricCalculator); + const contentWidths = irisGrid.state.metrics.contentColumnWidths; + + irisGrid.handleResizeAllColumns(); + + expect(mockMetricCalculator.userColumnWidths.size).toEqual(0); + + contentWidths.forEach((contentWidth, modelIndex) => { + expect( + mockMetricCalculator.calculatedColumnWidths.get(modelIndex) + ).toEqual(contentWidth); + }); + }); + + it('should manual resize all columns if not all were manually sized', () => { + const mockMetricCalculator = { + ...metricCalculator, + userColumnWidths: new Map([ + [0, 100], + [1, 100], + ]), + setColumnWidth: jest.fn((column, size) => { + mockMetricCalculator.userColumnWidths.set(column, size); + }), + resetColumnWidth: jest.fn(column => { + mockMetricCalculator.userColumnWidths.delete(column); + }), + }; + Object.assign(irisGrid.state.metricCalculator, mockMetricCalculator); + const contentWidths = irisGrid.state.metrics.contentColumnWidths; + + irisGrid.handleResizeAllColumns(); + + contentWidths.forEach((contentWidth, modelIndex) => { + expect(mockMetricCalculator.userColumnWidths.get(modelIndex)).toEqual( + contentWidth + ); + }); + }); +}); diff --git a/packages/iris-grid/src/IrisGrid.tsx b/packages/iris-grid/src/IrisGrid.tsx index 937eb7394c..81a00c51b2 100644 --- a/packages/iris-grid/src/IrisGrid.tsx +++ b/packages/iris-grid/src/IrisGrid.tsx @@ -3443,6 +3443,59 @@ class IrisGrid extends Component { this.grid?.forceUpdate(); } + handleResizeColumn(modelIndex: number): void { + const { metrics, metricCalculator } = this.state; + if (!metrics) throw new Error('Metrics not set'); + + const contentWidth = getOrThrow(metrics.contentColumnWidths, modelIndex); + + const userWidths = metricCalculator.getUserColumnWidths(); + if (userWidths.has(modelIndex)) { + metricCalculator.resetColumnWidth(modelIndex); + metricCalculator.setCalculatedColumnWidth(modelIndex, contentWidth); + } else { + metricCalculator.setColumnWidth(modelIndex, contentWidth); + } + + this.grid?.forceUpdate(); + } + + handleResizeAllColumns(): void { + const { metrics, metricCalculator } = this.state; + if (!metrics) throw new Error('Metrics not set'); + + const allColumns = [...metrics.allColumnWidths.entries()]; + const visibleColumns = allColumns + .filter(([_, width]) => width !== 0) + .map(([modelIndex]) => modelIndex); + + const contentWidths = metrics.contentColumnWidths; + const userWidths = metricCalculator.getUserColumnWidths(); + + const manualColumns = visibleColumns.filter(modelIndex => + userWidths.has(modelIndex) + ); + + if (visibleColumns.length === manualColumns.length) { + // All columns are manually sized, flip all to auto resize + for (let i = 0; i < visibleColumns.length; i += 1) { + const modelIndex = visibleColumns[i]; + const contentWidth = getOrThrow(contentWidths, modelIndex); + metricCalculator.resetColumnWidth(modelIndex); + metricCalculator.setCalculatedColumnWidth(modelIndex, contentWidth); + } + } else { + // Flip all to manual sized + for (let i = 0; i < visibleColumns.length; i += 1) { + const modelIndex = visibleColumns[i]; + const contentWidth = getOrThrow(contentWidths, modelIndex); + metricCalculator.setColumnWidth(modelIndex, contentWidth); + } + } + + this.grid?.forceUpdate(); + } + /** * User added, removed, or changed the order of aggregations, or position * @param aggregationSettings The new aggregation settings diff --git a/packages/iris-grid/src/IrisGridMetricCalculator.ts b/packages/iris-grid/src/IrisGridMetricCalculator.ts index e8ce356848..39acdc53c5 100644 --- a/packages/iris-grid/src/IrisGridMetricCalculator.ts +++ b/packages/iris-grid/src/IrisGridMetricCalculator.ts @@ -53,6 +53,10 @@ export class IrisGridMetricCalculator extends GridMetricCalculator { getUserColumnWidths(): ModelSizeMap { return this.userColumnWidths; } + + getCalculatedColumnWidths(): ModelSizeMap { + return this.calculatedColumnWidths; + } } export default IrisGridMetricCalculator; diff --git a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx index 8916c00923..f385c4c7f6 100644 --- a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx +++ b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx @@ -231,7 +231,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { this.irisGrid.freezeColumnByColumnName(column.name); } }, - order: 10, + order: 30, }); actions.push({ title: 'Show All Columns', @@ -241,6 +241,22 @@ class IrisGridContextMenuHandler extends GridMouseHandler { }, disabled: !isColumnHidden, }); + actions.push({ + title: 'Resize Column', + group: IrisGridContextMenuHandler.GROUP_HIDE_COLUMNS, + action: () => { + this.irisGrid.handleResizeColumn(modelIndex); + }, + order: 10, + }); + actions.push({ + title: 'Resize All Columns', + group: IrisGridContextMenuHandler.GROUP_HIDE_COLUMNS, + action: () => { + this.irisGrid.handleResizeAllColumns(); + }, + order: 20, + }); actions.push({ title: 'Quick Filters', icon: vsRemove,